[笔记]C++并发编程实战 《四》同步并发操作(二)

简介: [笔记]C++并发编程实战 《四》同步并发操作(二)

4.2.4 将异常存与期望值中

看完下面短小的代码段,思考一下,当你传递-1到square_root()中时,它将抛出一个异常,并且让这个异常被调用者看到:

double square_root(double x)
{
  if(x<0)
  {
    throw std::out_of_range(“x<0”);
  }
  return sqrt(x);
}

假设调用square_root()函数不是当前线程,

double y=square_root(-1);

将调用改为异步调用:

std::future<double> f=std::async(square_root,-1);
double y=f.get();

如果行为是完全相同时,其结果是理想的;任何情况下,y获得函数调用的结果,当线程调用f.get()时,就能再看到异常了,即使在一个单线程例子中。

好吧,事实的确如此:函数作为 std::async 的一部分时,当调用抛出一个异常时,这个异常就会存储到期望值中,之后期望值的状态被置为“就绪”,之后调用get()会抛出这个已存储异常(注意:标准级别没有指定重新抛出的这个异常是原始的异常对象,还是一个拷贝;不同的编译器和库将会在这方面做出不同的选择)。将函数打包入 std::packaged_task 任务包中后,到任务被调用时,同样的事情也会发生;打包函数抛出一个异常,这个异常将被存储在期望值中,准备在get()调用时再次抛出。

当然,通过函数的显式调用, std::promise 也能提供同样的功能。当存入的是一个异常而非一个数值时,就需要调用set_exception()成员函数,而非set_value()。这通常是用在一个catch块中,并作为算法的一部分,为了捕获异常,使用异常填充承诺值:

extern std::promise<double> some_promise;
try
{
  some_promise.set_value(calculate_value());
}
catch(...)
{
  some_promise.set_exception(std::current_exception());
}

这里使用了 std::current_exception() 来检索抛出的异常,可用 std::copy_exception() 作为一个替代方案, std::copy_exception() 会直接存储一个新的异常而不抛出:

some_promise.set_exception(std::copy_exception(std::logic_error("foo ")));

这比使用try/catch块更加清晰,当异常类型已知,就应该优先被使用;不是因为代码实现简单,而是它给编译器提供了极大的代码优化空间。

另一种向期望值中存储异常的方式是,在没有调用承诺值上的任何设置函数前,或正在调用包装好的任务时,销毁与 std::promise 或 std::packaged_task 相关的期望值对象。任何情况下,当期望值的状态还不是“就绪”时,调用 std::promise 或 std::packaged_task 的析构函数,将会存储一个与 std::future_errc::broken_promise 错误状态相关的 std::future_error 异常;

通过创建一个期望值,可以构造一个承诺值为其提供值或异常;可以通过销毁值和异常源,去违背承诺值。这种情况下,编译器没有在期望值中存储任何东西,等待线程可能会永远的等下去。

直到现在,例子都在用 std::future 。不过, std::future 也有局限性。很多线程在等待的时候,只有一个线程能获取等待结果。当多个线程需要等待相同的事件的结果,就需要使用 std::shared_future 来替代 std::future 了。

4.2.5 多个线程的等待

虽然 std::future 可以处理所有在线程间数据转移的同步,但是调用某一特殊 std::future 对象的成员函数,就会让这个线程的数据和其他线程的数据不同步。多线程在没有额外同步的情况下,访问一个独立的 std::future 对象时,就会有数据竞争和未定义的行为。这是因为 std::future 模型独享同步结果的所有权,并且通过调用get()函数,一次性的获取数据,这就让并发访问变的毫无意义——只有一个线程可以获取结果值,因为在第一次调用get()后,就没有值可以再获取了。

如果并行代码没有办法让多个线程等待同一个事件,先别太失落, std::shared_future 可以来帮你解决。因为 std::future 是只移动的,所以其所有权可以在不同的实例中互相传递,但是只有一个实例可以获得特定的同步结果,而 std::shared_future 实例是可拷贝的,所以多个对象可以引用同一关联期望值的结果。

每一个 std::shared_future 的独立对象上,成员函数调用返回的结果还是不同步的,所以为了在多个线程访问一个独立对象时避免数据竞争,必须使用锁来对访问进行保护。优先使用的办法:为了替代只有一个拷贝对象的情况,可以让每个线程都拥有自己对应的拷贝对象。

这样,当每个线程都通过自己拥有的 std::shared_future 对象获取结果,那么多个线程访问共享同步结果就是安全的。可见图4.1。

可能会使用 std::shared_future 的情况,例如:实现类似于复杂的电子表格的并行执行;每一个单元格有单一的终值,这个终值可能是由其他单元格中的数据通过公式计算得到的。公式计算得到的结果依赖于其他单元格,然后可以使用一个 std::shared_future 对象引用第一个单元格的数据。当每个单元格内的所有公式并行执行后,任务会以期望的方式完成工作;

不过,当其中有计算需要依赖其他单元格的值,那么它就会被阻塞,直到依赖单元格的数据准备就绪。这将让系统在最大程度上使用硬件并发。

std::shared_future 的实例同步 std::future 实例的状态。当 std::future 对象没有与其他对象共享同步状态所有权,那么所有权必须使用 std::move 将所有权传递到 std::shared_future ,其默认构造函数如下:

std::promise<int> p;
std::future<int> f(p.get_future());
assert(f.valid()); // 1 期望值 f 是合法的
std::shared_future<int> sf(std::move(f));
assert(!f.valid()); // 2 期望值 f 现在是不合法的
assert(sf.valid()); // 3 sf 现在是合法的

这里,期望值f开始是合法的①,因为它引用的是承诺值p的同步状态,但是在转移sf的状态后,f就不合法了②,而sf就是合法的了③。

如其他可移动对象一样,转移所有权是对右值的隐式操作,所以可以通过 std::promise 对象的成员函数get_future()的返回值,直接构造一个 std::shared_future 对象,例如:

std::promise<std::string> p;
std::shared_future<std::string> sf(p.get_future()); // 1 隐式转移
所有权

转移所有权是隐式的,用一个右值构造 std::shared_future<> ,得

到 std::futurestd::string 类型的实例①。

std::future 的这种特性,可促进 std::shared_future 的使用,容器可以自动的对类型进行推断,从而初始化这个类型的变量(详见附录A,A.6节)。 std::future 有一个share()成员函数,可用来创建新的 std::shared_future ,并且可以直接转移期望值的所有权。这样也就能保存很多类型,并且使得代码易于修改:

std::promise< std::map< SomeIndexType, SomeDataType,
SomeComparator,
SomeAllocator>::iterator> p;
auto sf=p.get_future().share();

这个例子中,sf的类型推到为 std::shared_future<std::map<SomeIndexType, SomeDataType,SomeComparator, SomeAllocator>::iterator> ,一口气还真的很难念完。当比较器或分配器有所改动,你只需要对承诺值的类型进行修改即可;期望值的类型会自动更新,与承诺值的修改进行匹配。

有时你需要限定等待事件的时间,不论是因为时间上有硬性规定(一段指定的代码需要在某段时间内完成),还是因为在事件没有很快的触发时,有必要的工作需要特定线程来完成。为了处理这种情况,有非常多各种等待函数都能够指定超时任务。

[1] 《银河系漫游指南》(The Hitchhiker’s Guide to the Galaxy)中, 计算机在经过深度思考后,将“人生之匙和宇宙万物”的答案确定为42。

4.3 限定等待时间

之前介绍过阻塞调用,会阻塞一段不确定的时间,将线程挂起到等待的事件发生。很多情况下,这样的方式很不错,但是在一些情况下,就需要限制一下线程等待的时间。可以发送一些类似“我还存活”的信息,无论是对交互式用户,或是其他进程,亦或当用户放弃等待,也可以按下“取消”键直接终止等待。

介绍两种指定超时方式:

  • 一种是“时延”, 需要指定一段时间(例如,30毫秒);
  • 另一种是“绝对时间点”。就是指定一个时间点(例如,世界标准时间[UTC]17:30:15.045987023,2011年11月30日)。

多数等待函数提供变量,对两种超时方式进行处理。

处理持续时间的变量以“_for”作为后缀,处理绝对时间的变量以"_until"作为后缀。

所以,当 std::condition_variable 的两个成员函数wait_for()wait_until()成员函数分别有两个负载,这两个负载都与wait()成员函数的负载相关——其中一个负载只是等待信号触发,或时间超期,亦或是伪唤醒,并且醒来时会检查锁提供的谓词,并且只有在检查为true时才会返回(这时条件变量的条件达成),或直接超时。

观察使用超时函数的细节前,让我们来检查一下在C++中指定时间的方式,就从“时钟”开始吧!

4.3.1 时钟

对于C++标准库来说,时钟就是时间信息源。并且,时钟是一个类,提供了四种不同的信息:

  • 当前时间
  • 时间类型
  • 时钟节拍

通过时钟节拍的分布,判断时钟是否稳定当前时间可以通过调用静态成员函数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时,表明这个时钟就是稳定的;否则,就是不稳定的。通常情况下, std::chrono::system_clock 是不稳定的,因为时钟是可调的,即是这种是完全自动适应本地账户的调节。这种调节可能造成的是,首次调用now()返回的时间要早于上次调用now()所返回的时间,这就违反了节拍频率的均匀分布。稳定闹钟对于超时的计算很重要,所以C++标准库提供一个稳定时钟 std::chrono::steady_clock 。C++标准库提供的其他时钟可表示为 std::chrono::system_clock (在上面已经提到过),它代表了系统时钟的“实际时间”,并且提供了函数可将时间点转化为time_t类型的值; std::chrono::high_resolution_clock 可能是标准库中提供的具有最小节拍周期(因此具有最高的精度[分辨率])的时钟。它实际上是typedef的另一种时钟,这些时钟和其他与时间相关的工具,都被定义在 库头文件中。

我们先看一下持续时间是怎么表示的。

4.3.2 时延

时间部分最简单的就是时延, std::chrono::duration<> 函数模板能够对时延进行处理(线程库使用到的所有C++时间处理工具,都在 std::chrono 命名空间内)。第一个模板参数是一个类型表示(比如,int,long或double),第二个模板参数是定制部分,表示每一个单元所用秒数。

例如,当几分钟的时间要存在short类型中时,可以写成 std::chrono::duration<short,std::ratio<60, 1>> ,因为60秒是才是1分钟,所以第二个参数写成 std::ratio<60, 1> 。

当需要将毫秒级计数存在double类型中时,可以写成 std::chrono::duration<double, std::ratio<1,1000>> ,因为1秒等于1000毫秒。

标准库在 std::chrono 命名空间内,为延时变量提供一系列预定义类型:

  • nanoseconds[纳秒] ,
  • microseconds[微秒] ,
  • milliseconds[毫秒] ,
  • seconds[秒] ,
  • minutes[分]
  • hours[时]。

比如,你要在一个合适的单元表示一段超过500年的时延,预定义类型可充分利用了大整型,来表示所要

表示的时间类型。当然,这里也定义了一些国际单位制(SI, [法]le Système internationald’unités)分数,可从 std::atto(10^(-18)) 到 std::exa(10^(18)) (题外话:当你的平台支持128位整型);也可以指定自定义时延类型,例如, std::duration<double, std::centi> ,就可以使用一个double类型的变量表示1/100。

方便起见,C++14中 std::chrono_literals 命名空间中,有许多预定义的后缀操作符用来表示时长。下面简单的代码就是使用硬编码的方式赋予具体的时长值:

using namespace std::chrono_literals;
auto one_day=24h;
auto half_an_hour=30min;
auto max_time_between_messages=30ms;

当使用整型字面符,这些后缀类似使用了预定义的类型,比如:15ns和 std::chrono::nanoseconds(15) 就是等价的。不过,当使用浮点字面量时,且未指明表示类型时,数值上会对浮点时长进行适当的缩放。因此,2.5min会被表示

为 std::chrono::duration<some-floating-point-type,std::ratio<60,1>> 。如果非常关心所选的浮点类型表示的范围或精度,那么需要自己来构造相应的对象来保证表示范围或精度,而不是去苛求字面值来对范围或精度进行期望性的表达。

当不要求截断值的情况下(时转换成秒是没问题,但是秒转换成时就不行)时延的转换是隐式的。显示转换可以由 std::chrono::duration_cast<> 来完成。

std::chrono::milliseconds ms(54802);
std::chrono::seconds s=
std::chrono::duration_cast<std::chrono::seconds>(ms);

这里的结果就是截断的,而不是进行了舍入,所以s最后的值为54。

延迟支持四则运算,所以你能够对两个时延变量进行加减,或者是对一个时延变量乘除一个常数(模板的第一个参数)来获得一个新延迟变量。例如,5*seconds(1)与seconds(5)或minutes(1)-seconds(55)一样。在时延中可以通过count()成员函数获得单位时间的数量。例如, std::chrono::milliseconds(1234).count() 就是1234。

基于时延的等待可由 std::chrono::duration<> 来完成,例如:等待期望值状态变为就绪已经35毫秒:

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());

等待函数会返回一个状态值,表示是等待是超时,还是继续等待。这里可以等待期望值,所以当函数等待超时时,会返回 std::future_status::timeout ;当期望值状态改变,函数会返回 std::future_status::ready ;当与期望值相关的任务延迟了,函数会返回 std::future_status::deferred 。基于时延的等待是使用内部库的稳定时钟来计时的;所以,即使系统时钟在等待时被调整(向前或向后),35毫秒的时延在这里意味着,的确耗时35毫秒。当然,系统调度的不确定性和不同操作系统的时钟精度都意味着:线程调用和返回的实际时间间隔可能要比35毫秒长。

时延中没有特别好的办法来处理以上情况,先暂停对时延的讨论吧。现在,我们就要来看看“时间点”是如何工作的。

4.3.3 时间点

时间点可以用 std::chrono::time_point<> 类型模板来表示,实例的第一个参数用来指定所要使用的时钟,第二个函数参数用来表示时间的计量单位(特化的 std::chrono::duration<> )。

一个时间点的值就是时间的长度(在指定时间的倍数内),例如,指定“unix时间戳”(epoch)为一个时间点。时间戳是时钟的一个基本属性,但是不可以直接查询,或在C++标准中已经指定。通常,unix时间戳表示1970年1月1日 00:00,即计算机启动应用程序时。时钟可能共享一个时间戳,或具有独立的时间戳。当两个时钟共享一个时间戳时,其中一个time_point类型可以与另一个时钟类型中的time_point相关联。这里,虽然不知道unix时间戳是什么,但可以通过对

指定time_point类型使用time_since_epoch()来获取时间戳,该成员函数会返回一个时延值,这个时延值是指定时间点与unix时间戳的时间间隔。

例如,你可能指定了一个时间点 std::chrono::time_pointstd::chrono::system_clock,std::chrono::minutes 。这就与系统时钟有关,且实际中的一分钟与系统时钟精度应该不相同(通常差几秒)。

你可以通过 std::chrono::time_point<> 实例来加/减时延,来获得一个新的时间点,所以 std::chrono::hight_resolution_clock::now() + std::chrono::nanoseconds(500) 将得到500纳秒后的时间。这对于计算绝对时间超时是一个好消息,当知道一块代码的最大时延时,在等待时间内进行多次调用等待函数,或非等待函数占用了等待函数时延中的时间。

你也可以减去一个时间点(二者需要共享同一个时钟)。结果是两个时间点的时间差。这对于代码块的计时是很有用的,例如:

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>(stopstart).count()
<<” seconds”<<std::endl;

std::chrono::time_point<> 实例的时钟参数不仅能够指定unix时间戳。当一个等待函数(绝对时间超时的方式)传递时间点时,时间点的时钟参数就被用来测量时间。当时钟变更时,会产生严重的后果,因为等待轨迹随着时钟的改变而改变,并且直到调用now()成员函数时,才能返回一个超过超时时间的值。当时钟向前调整,就有可能减少等待时间的长度(与稳定时钟的测量相比);当时钟向后调整,就有可能增加等待时间的长度。

如你所愿,后缀为_unitl的(等待函数的)变量会使用时间点。通常是使用某些时钟的 ::now() (程序中一个固定的时间点)作为偏移,虽然时间点与系统时钟有关,可以使用 std::chrono::system_clock::to_time_point() 静态成员函数,在用户可视时间点上进行调度操作。例如,当有一个对多等待500毫秒的,且与条件变量相关的事件,可以参考如下代

码:

#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;
}

我们推荐种方式,当没有什么可以等待时,就可在一定时限中等待条件变量。

这种方式中,循环的整体长度有限。在4.1.1节中所示,当使用条件变量(且无事可待)时,就需要使用循环,这是为了处理假唤醒。当在循环中使用wait_for()时,可能在等待了足够长的时间后结束等待(在假唤醒之前),且下一次等待又开始了。这可能重复很多次,使得等待时间无边无际。

到此,有关时间点超时的基本知识你已经了解了。现在,让我们来了解一下如何在函数中使用超时。

4.3.4 具有超时功能的函数

使用超时的最简单方式,就是对一个特定线程添加一个延迟处理;当这个线程无所事事时,就不会占用其他线程的处理时间。4.1节中看过一个例子,循环检查“done”标志。两个处理函数分别是 std::this_thread::sleep_for() 和 std::this_thread::sleep_until() 。它们的工作就像一个简单的闹钟:当线程因为指定时延而进入睡眠时,可使用sleep_for()唤醒;因指定时间点修眠的,可使用sleep_until唤醒。sleep_for()的使用如同在4.1节中的例子,有些事必须在指定时间范围内完成,所以这里的耗时就很重要。另一方面,sleep_until()允许在某个特定时

间点将调度线程唤醒。可能在晚间备份或在早上6:00打印工资条时使用,亦或挂起线程直到

下一帧刷新时进行视频播放。

当然,休眠只是超时处理的一种形式,超时可以配合条件变量和期望值一起使用。超时甚至可以在尝试获取互斥锁时(当互斥量支持超时时)使用。 std::mutex 和 std::recursive_mutex 都不支持超时锁,但是 std::timed_mutex 和 std::recursive_timed_mutex 支持。这两种类型也有try_lock_for()和try_lock_until()成员函数,可以在一段时期内尝试获取锁或在指定时间点前获取互斥锁。表4.1展示了C++标准库中支持超时的函数。

参数列表为“延时”(duration)必须是 std::duration<> 的实例,并且列出为时间点(time_point)必须是 std::time_point<> 的实例。

现在,我们讨论过的机制有:

  • 条件变量
  • 期望值
  • 承诺值,
  • 还有打包的任务。

是时候从更高的角度去看待这些机制,以及怎么样使用这些机制,简化线程的同步操作。

相关文章
|
4月前
|
C++ 容器
C++中向量的操作vector
C++中向量的操作vector
|
2月前
|
并行计算 安全 调度
C++ 11新特性之并发
C++ 11新特性之并发
82 0
|
3月前
|
C++ 容器
【C/C++笔记】迭代器
【C/C++笔记】迭代器
25 1
|
3月前
|
存储 安全 程序员
【C/C++笔记】迭代器范围
【C/C++笔记】迭代器范围
66 0
|
4月前
|
C++ Windows
FFmpeg开发笔记(三十九)给Visual Studio的C++工程集成FFmpeg
在Windows上使用Visual Studio 2022进行FFmpeg和SDL2集成开发,首先安装FFmpeg至E:\msys64\usr\local\ffmpeg,然后新建C++控制台项目。在项目属性中,添加FFmpeg和SDL2的头文件及库文件目录。接着配置链接器的附加依赖项,包括多个FFmpeg及SDL2的lib文件。在代码中引入FFmpeg的`av_log`函数输出"Hello World",编译并运行,若看到"Hello World",即表示集成成功。详细步骤可参考《FFmpeg开发实战:从零基础到短视频上线》。
161 0
FFmpeg开发笔记(三十九)给Visual Studio的C++工程集成FFmpeg
|
4月前
|
安全 程序员 C++
C++一分钟之-C++中的并发容器
【7月更文挑战第17天】C++11引入并发容器,如`std::shared_mutex`、`std::atomic`和线程安全的集合,以解决多线程中的数据竞争和死锁。常见问题包括原子操作的误用、锁的不当使用和迭代器失效。避免陷阱的关键在于正确使用原子操作、一致的锁管理以及处理迭代器失效。通过示例展示了如何安全地使用这些工具来提升并发编程的安全性和效率。
61 1
|
5月前
|
存储 设计模式 安全
C++一分钟之-并发编程基础:线程与std::thread
【6月更文挑战第26天】C++11的`std::thread`简化了多线程编程,允许并发执行任务以提升效率。文中介绍了创建线程的基本方法,包括使用函数和lambda表达式,并强调了数据竞争、线程生命周期管理及异常安全等关键问题。通过示例展示了如何用互斥锁避免数据竞争,还提及了线程属性定制、线程局部存储和同步工具。理解并发编程的挑战与解决方案是提升程序性能的关键。
82 3
|
5月前
|
C++
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
|
5月前
|
算法 C++ 容器
C++之vector容器操作(构造、赋值、扩容、插入、删除、交换、预留空间、遍历)
C++之vector容器操作(构造、赋值、扩容、插入、删除、交换、预留空间、遍历)
238 0
|
7天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
33 4