同步并发操作——限制等待时间

前言

 
在本章前文所述的各项等待操作中,线程阻塞的时间并不确定,但在实际应用中存在这样的需求:限制线程等待时间——在线程等待特定的一段时间后,开发者既可以向用户发出“该事件仍未完成”的提示,也可以接受由于用户放弃等待从而关闭线程的命令。

判断程序运行超时有两种方式:

  1. duration-based
    该方式需要用户指定一段时间,例如30ms。
  2. absolute
    该方式需要用户提供一个时间点,例如17:30:15.045987023 UTC on November 30, 2011。

大多数等待函数均支持这两种方式,处理duration-based的后缀为_for,处理absolute的后缀为_until。举例而言,std::condition_variable的成员函数wait_for()wait_until()。此外,这些函数具备2种重载,第一种重载形式等待着信号触发或虚假唤醒,另一种则在唤醒时检查谓词,并仅在结果为true的情况下返回,或直接超时返回。更多相关信息可见本节末尾的图表。

clock

 
在探讨超时之前,有必要先了解一下时间在C++中的表现形式,首先从clock开始。
对C++标准库而言,clock意味着时间信息源,具体来说,clock是一个类,提供了4种不同的信息:

  1. 当前时间
  2. 时间类型
  3. 时钟节拍
  4. 时钟是否稳定(由时钟节拍确定)

当前时间

当前时间可通过调用该类的静态成员函数now获得,如std::chrono::system_clock::now()将返回系统时钟当前时间。

时间类型

时间类型由time_point中的typedef指定,因此some_clock::now()的类型为some_clock::time_point

时钟节拍

时钟节拍被指定为1/x(x取决于硬件)秒。若当前时钟一秒有25个节拍,则其一个时钟周期为std::ratio<1, 25>。若时钟的时钟节拍每2.5秒一次,则时钟周期则可表示为std::ratio<5, 2>。时钟周期通常会在数据手册中给出,但如果当前并不知悉,则可以通过多次运行特定程序的方法求解平均值(这种方法并不保证精确性)。

时钟稳定性

若时钟节拍均匀分布(无论该速率与时钟周期匹配与否)且无法调整,则称该时钟为稳定时钟,在此类情况下,时钟类中的is_steady静态数据成员为true,否则为false。通常情况下我们可以认为std::chrono::system_clock不稳定,因为该时钟可被调整(即使这种调整是为了抑制本地时钟漂移而自发进行的)。

对时钟的调整行为可能导致一个问题:当前调用now返回的时间要早于之前调用now返回的时间。(举例而言,做手机app的耗时监控,在app启动后用户调整了时间,从而导致计算错误,此时应当使用绝对时间)。

时钟的稳定性对于超时计算而言非常重要,因此C++标准库提供了std::chrono::steady_clockstd::chrono::system_clock代表了系统时钟的“实际时间”,并且提供了可将时间点转化为time_t类型的接口;std::chrono::high_resolution_clock提供了具备最高分辨率(即最小的时钟周期)的时钟。上述时钟及其相关工具均位于头文件<chrono>中。


Durations

 
时延是本节最简单的内容:它们由std::chrono::duration<>函数模板进行处理(位于std::chrono命名空间内)。第一个模板参数是一个时延外在表现的类型(如int,long或double),第二个模板参数是制定部分,表示每一个单元所用秒数。举例而言,当存在分钟级时延需要存储于short类型中时,可以写成std::chrono::duration<short,std::ratio<60,1>>,因为一分钟包含60秒。若需要将毫秒级时延存储于double类型中时,则写作std::chrono::duration<short,std::ratio<1,1000>>,原因在于一秒包含1000毫秒。

在隐式转换下,时延转换不存在截断(即可由时转为秒,但不可由秒转为时)。我们可以通过显式操作std::chrono::duration_cast<>来完成转换(具备截断):

1
2
std::chrono::milliseconds ms(54802); 
std::chrono::seconds s= std::chrono::duration_cast<std::chrono::seconds>(ms); // s = 54

时延支持算术操作,因此开发者可以对它进行加减乘除操作。

1
5*seconds(1) == seconds(5) == minutes(1) - seconds(55)

此外,可以通过成员函数count获取时延中的单元数量,如std::chrono::milliseconds(1234).count()是1234。

为了方便起见,在C++1 中引入的std::chrono_literals命名空间内有许多预定义字面值后缀操作符,因此后续可以直接写为:

1
2
3
4
using namespace std::chrono_literals; 
auto one_day=24h;
auto half_an_hour=30min;
auto max_time_between_messages=15ns; // == std::chrono::nanoseconds(15)

具体应用

假设开发者期待在35ms内一个future对象状态就绪,以便于执行相关操作,那么该需求可被写为:

1
2
3
std::future<int> f=std::async(some_task); 
if(f.wait_for(std::chrono::milliseconds(35)) == std::future_status::ready)
do_something_with(f.get());

所有的wait函数均返回一个状态,用以指示当前时间已经耗尽或者说等待事件已经发生。在刚才的代码实例中,由于我们调用的是future的程序函数wait_for,因此它存在三种返回状态:

  1. std::future_status::timeout 时延耗尽
  2. std::future_status::ready future 已就绪
  3. std::future_status::deferred future 任务被延迟

计算时延所使用的是系统内部提供的稳定时钟,因此无论系统时钟system_clock发生怎样的调整,线程耗时总是35毫秒。当然,由于难以预料的系统调度和操作系统间不同的时钟精度,从调用该线程到返回该线程的时间可能要远长于35毫秒。


time_point

 
时钟的时间点可以用std::chrono::time_point<>类模板实例来表示。模板的第一个参数用来指定所要使用的时钟,第二个参数表示时延的计量单位(std::chrono::duration<>的特例化 )。一个时间点的值就是以一个时间戳(epoch)作为起点的时间的长度(其必然为指定时延的倍数)。时间戳是时钟的基本属性,但它既不可直接查询也不并非由C++标准指定。通常采用的时间戳为1970年1月1日00:00,或运行应用程序的计算机启动时。不同的时钟实例可能共享一个时间戳,也可能具备各自独立的时间戳。虽然我们并不了解当前使用的是何种时间戳,但开发者可通过对指定time_point类型使用time_since_epoch()来获取时延值,该时延值即为当前时间距离时间戳的距离。

具体使用

举例而言,std::chrono::time_point<std:: chrono::system_clock, std::chrono::minutes>表示当前时间点与系统时钟相关联,且其时延计量单位为分钟。我们可以自由地通过对一个时间点执行算术运算,从而获取另一个时间点,如:

1
std::chrono::high_resolution_clock:: now() + std::chrono::nanoseconds(500)

我们也可以在两个时间点之间执行算术运算(二者需要共享一个时钟),从而获取时间差。这常见于代码块的计时需求:
1
2
3
4
auto start=std::chrono::high_resolution_clock::now(); 
do_something();
auto stop=std::chrono::high_resolution_clock::now();
std::cout<<”do_something() took “<<std::chrono::duration<double,std::chrono::seconds>(stop-start).count()<<” seconds”<<std::endl;

应当尽量避免在计时代码块中调整std::chrono::time_point所指定的时钟,因为这将导致计时错误。

假设当前存在一个事件,而我们至多等待该事件500毫秒,则有程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <condition_variable> 
#include <mutex>
#include <chrono>
std::condition_variable cv;
bool done;
std::mutex m;
bool wait_loop() {
auto const timeout = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
std::unique_lock<std::mutex> lk(m);
while(!done) {
if(cv.wait_until(lk,timeout) == std::cv_status::timeout)
break;
}
return done;
}


Functions that accept timeouts

 
开发者经常需要为特定的线程添加延迟,让它们休眠一段指定时间或休眠至特定时间点,而这两个需求的实现依赖于函数std::this_thread::sleep_for()std::this_thread::sleep_until()

显然,休眠只是超时(timeout)处理的一种形式,在上文中,超时配合条件变量与期望一起使用。超时甚至可以在尝试获取一个互斥锁时(前提是该互斥锁支持timeout)使用,传统的std::mutexstd::recursive_mutex并不支持超时,但std::timed_mutexstd::recursive_timed_mutex则可以。它们具备了名为try_lock_fortry_lock_until的成员函数。下表则更具体地展示了支持timeout的函数,并给出了它们所需参数与返回值。

屏幕快照 2019-06-23 下午11.09.46.png-457.7kB