Effective Modern C++:并发 API
C++11 中,提供了强大的并发特性,使得我们在编写并发程序时,不需要再去调用操作系统提供的并发接口(如 pthread 和 Windows 线程库)。Modern C++ 的程序员,必须熟练掌握并发 API 的用法,本章节会展开讨论几个使用 C++ 并发 API 时需要考虑和注意的问题。
条款 35:优先选用 std::async 替代 std::thread
如果想要以异步运行的方式启动一个程序,有两种 API 可供使用:
1 | int doSomeAsyncWork(); |
两种方式各有不同的特点。书中介绍的什么是线程、什么是硬件线程和软件线程,这里不再重复赘述。
std::thread 就是启动一个软件线程,交给操作系统去调用执行。简单点来看,就是以一个独立的线程去执行这个异步函数。std::async 创建的对象 fut 的类型是 std::future,它的底层实现,也是一个软件线程,但它能做到的更多。
首先,std::async 可以返回一个期值 future,它作为一个对象,可以在之后通过 get() 等 API 来访问这个异步任务的返回状态,包括异步函数的返回值,和运行的异常。然而 std::thread 很难做到,std::thread 可以通过线程通信的一些方式(比如共享内存)来做数据交换,而其异常也无法在线程外部捕获到。std::async 也可对任务的状态做更详细的控制(如控制任务延迟启动,任务生命周期和 future 对象绑定)。
操作系统对软件线程的数量是有限制的,如果启动操作限制的数量,会导致线程启动失败,std::thread 会抛出 std::system_error 异常。而 std::async 默认的行为是,当软件线程超出限制时,采用同步的方式去执行这个程序,从而避免抛出异常。当然,也可以传入配置,要求 std::async 必须以异步方式执行程序。
即使没有超出操作系统限制,当软件线程数量大于硬件线程时,操作系统为了让所有线程都有机会运行,会对 CPU 做切片,按不同的时间片去以合适地调度策略去执行这些线程。虽然这种行为很常见,但线程数量过多(比如我之前做过的一个测试程序,要起很高的并发度去运行测试用例)时,还是会很容易触及性能瓶颈,因为过于频繁的线程切换,会带来额外的负担。std::async 会代替你去面对这种调度和负载均衡的问题。
所以,如果使用 std::thread 去编写一个多线程程序,我们会很容易触及操作系统线程管理的领域,比如是否要考虑硬件线程资源的数量,操作系统中其他进程的运行状态,我们的整个程序中,需要小心谨慎地处理线程耗尽、线程调度和负载均衡。而使用 std::async 则得心应手很多。
当然,有几种情况,依然需要使用 std::thread。
- 如果需要访问底层线程的 API,比如 pthread 和 Windows 线程库,
std::thread提供了这种机会,而std::async则没有; - 能完全掌握一个系统的运行状态,并且要深度优化线程调用,或者做性能分析和测试时;
- 需要实现一些
std::async没有支持的线程计数时,如手动实现高性能线程池;
这些都不常见,优先选择,依然是使用 std::async。
条款 36:注意 std::async 的启动策略
上一个条款中提到,std::async 并不总是会以异步的方式启动程序。这是因为,它为了保证如果线程负载超出操作系统限制之后,不要抛出异常,所以会在合适的情况下,采用同步的方式去执行程序。
如果我们的应用依赖于这种异步的假设(比如盲目地将 std::thread 替代为 std::async),那可能会在一些极端情况时出现问题。
首先,了解下 std::async 的启动策略,它有两种模式:
std::launch::async:这种策略要求必须以异步的方式启动程序;std::launch::defered:这种策略,要求在std::async返回的期值对象,调用其get或wait接口时,才启动程序,也就是同步且延迟执行的策略。
可以在使用 std::async 时,通过这个枚举来指定启动策略:
1 | auto fut1 = std::async(std::launch::async, f); |
如果什么都不指定,那就是让 std::async 的实现自己去有策略的选择二者之一。通常的方法是,优先选用 std::launch::async 异步执行程序,如果线程负载超出操作系统限制,那就选用 std::launch::defered 延迟执行程序。
因此,如果使用默认行为,那就不要对启动的任务做任何异步的假设,也不要假设同步执行时,程序一定在当前线程环境(和 fut 定义同一个线程)中执行,这是因为,fut 对象完全可以被移动到另一个线程中,再去调用 get 接口。
如果以这种错误的假设去设计了软件,在大多数情况下,问题也并不会暴露,只有当线程负荷很大时,才有可能出现,这导致常规的测试用例难以暴露问题。
陷阱:
std::async的默认启动行为,不保证任务一定会异步启动。
遗憾的是,期值对象并没有直接的接口返回它实际的启动策略。不过,可以用另一种方式来判断:
1 | using namespace std::literals; |
通过 wait_for 或 wait_until 的接口,如果任务以推迟执行的方式启动,它们将一定返回 std::future_status::defered。
如果一定需要任务以异步的方式启动,那么在调用 std::async 时,必须显式指定 std::launch::async。书中给出了一种包装异步启动任务的函数实现,它的返回值实现值得学习一下:
1 | template <typename F, typename... Ts> |
条款 37:如果使用 std::thread,别忘了在结束时回收
每个 std::thread 都有两种状态:可联结和不可联结,使用 t.joinable() 接口可以判断它当前的状态。本条款介绍了一个很简单的知识点,所以我不会特别大篇幅的展开。
简单来说,如果使用 std::thread 创建了一个线程,那么,当线程对象离开作用域前,需要手动使用 t.join() 或 t.detach() 来回收线程(使其从可联结状态变成不可联结状态)。
这里需要区分两个概念,std::thread 类型的对象,被称为线程对象,它是一个 C++ 中的资源句柄,它对应的资源是一个操作系统线程(也可能不对应任何线程)。线程对象遵循 C++ 约定的生命周期和析构行为,而其对应的线程,遗憾的是,并没有默认添加到线程对象的析构函数中,并自动完成析构。
究其原因,可以分情况讨论:
- 如果线程对象的析构函数中使用
join来回收线程资源。由于join是一个阻塞行为,如果线程没有及时结束,那么线程对象的析构就会阻塞;如果线程永远不会结束,那么线程对象的析构(也就是父进程)行为,也将永远阻塞。在大型项目中,这种行为会产生很难调试的性能问题。 - 如果析构函数中使用
detach来回收线程资源,或者说,这种操作是主动分离线程对象和线程资源,确实不会阻塞父进程,然而,很可能带来更严重的恶性 bug。比如说,线程中通过引用操作着父进程中的一些临时对象(比如栈),如果父进程通过detach提前退出并销毁临时对象,那么之后,线程依然会有可能修改这块内存空间。这种问题极难调试。
标准委员会意识到了这个问题,如果主动将资源回收的任务代劳,就会带来性能问题或者潜在 bug 。倒不如开放给程序员,让软件在适当的时候,灵活地根据需求,回收线程资源。为了避免程序员忘记回收资源,C++ 规定,线程对象在析构时,如果线程还处于可联结状态,就让程序执行终止。
关于什么是不可联结的线程对象,书中有一个清单,我认为不错,可以了解一下。以下几类线程对象都是不可联结的:
- 如果构造线程对象时,没有给它一个可执行的函数,那就没有对应的底层线程
- 线程对象已经通过移动操作移走
- 已经通过
join或detach操作之后的线程对象
书中后边部分介绍了使用 C++ 的 RAII 特性,对线程对象做一次包装,实现类似于智能指针的资源管理类型。将线程的回收动作放在包装类的析构函数中即可。实现比较简单,就不列出代码了。
条款 38:期值的析构函数中,注意多样化的联结线程情况
让我们回到任务和期值的话题。
上一条款中,我们提到,std::thread 是一个指向实际线程资源的资源句柄,所以讨论析构和资源回收的话题时,析构 std::thread 线程对象,和释放线程(物理资源),是两个不同的操作。在 std::async 这边,也有同样的问题需要关注。
当我们使用 std::async 创建一个异步任务时(通过 std::launch::async 手动指定,或者系统自动启动异步任务而非延迟任务),系统会分配一个线程资源,并通过 std::future 期值对象来指向这个线程资源。我们可以通过期值对象来获取线程的返回值,这个返回值既不位于子线程的空间,也不位于启动任务一方的作用域内,而是放在堆上,它是另一块需要处理的资源。
如果 std::future 对象指向的是一个异步任务,那情况很简单,它一定会需要去析构这些资源。表现在运行时行为,便是 join 当前的线程,阻塞父进程直到子线程结束。
但是,还有一种情况,任务创建后,可以交给 std::shared_future 这种共享型期值,它和 std::shared_ptr 一样,也拥有一个控制块,控制块中存在着引用计数。多个 std::shared_future 对象可以指向相同的线程资源,但只有最后一个执行资源的对象(当引用计数为 0 时),才负责回收资源。
所以总结以上的情况,当启动的是一个异步任务时,期值对象的析构动作,有两种可能:
- 不对线程资源(包括返回值的内存)做任何处理,只负责析构期值对象自身,并将控制块中的引用计数减 1。如果对应
std::thread,这里对待线程的行为类似隐式detach; - 最后一个指向资源的期值对象,负责析构行为,这包括使用隐式的
join操作回收线程,以及回收共享内存;
如果最后一个期值对象走到生命周期的末尾时,子线程还没有运行结束,那么期值对象的析构操作就会阻塞。上一条款已经提到,隐式 join 会带来隐式的性能问题。但标准委员会考虑再三,还是决定在这里妥协这个问题。
这也带来了进一步的一些现象,比如下例中的情况,期值对象自己,并不知道什么时候会发生阻塞析构:
1 | // 容器的析构操作可能会阻塞 |
使用 packaged_task 创建返回值内存的情况
除了使用 std::async 来创建任务时,会产生共享的返回值资源外,还有另一种情况,使用 std::packaged_task 也可以创建返回值。它相当于是对函数的一层异步封装,封装后,对象交由 std::thread 或 std::async 可以以异步方式运行(书中的描述不准确)。
当使用 std::thread 运行时:
1 | int func(); |
可以通过 get_future() 接口来取得 std::packaged_task 的期值。也可以通过它创建 std::thread 线程对象。明显,线程对象和线程资源是分开的,所以期值对象和线程对象其实是可以同时存在的。
这就带来一个有趣的问题。考虑我们这一条款的话题,线程资源会被谁析构?
实际上,期值对象将不需要负责去析构线程资源。讨论可能的三种情况:
- 如果线程对象
t没有任何后续操作,在t的作用域结束时,线程是可联结的,这会导致程序终止; - 如果线程对象
t在作用域结束前,执行join,那么t将负责析构线程资源,而期值对象fut不需要在析构时隐式join; - 如果线程对象
t在作用域结束前,执行detach,线程资源将由系统回收,fut仍然不需要做任何事情。
总结来说,就是如果期值对象是经由 std::packaged_task 获取,而不是通过 std::async 获取时,它的析构中,不需要负责回收线程资源。
使用 std::async 来运行 std::packaged_task 包装的任务函数,没有讨论的必要,它的析构行为和前述开头的结论一致。而且,使用 std::async 时,也不需要 std::packaged_task,它自己便可以完成函数包装的功能。
条款 39:在一次性事件的异步通信时,可以选用期值方案
这一条款,我们开始讨论一下异步任务之间通信的问题。先来看下问题的由来。
使用锁和条件变量实现
如果你做过一定程度的 C++ 异步编程工作,那么在异步通信时,很容易会遇到以下的问题。看这个代码:
1 | // 使用条件变量和锁配合,是最常见的做法 |
这是一个很典型的面试题,考虑下其中存在哪些问题。
存在 2 个问题。首先,如果在发出事件任务执行完 notify_all() 之后,接收事件任务还没有执行到 wait(lk) 时,那么当接收事件任务最终执行到 wait(lk) 时,便会继续等待,如果后续没有新的通知过来,那将永远阻塞下去。
其次,wait 有一个叫 “虚假唤醒” 的特性,这是一个系统级的行为。简单展开来说,系统和硬件对于调度线程的成本很高,唤醒操作如果做到高精度,可能的性能代价是难以接受的,所以,在系统层面,明确指出了这个问题,并将精确接收并唤醒线程的最后确认工作交给软件完成。它不是一种 bug,虽然概率不高,但仍然需要认真对待。为了保证精确唤醒,在接收端,必须通过条件确认。
解决这两个问题的答案是统一的,也就是把 cv.wait(lk) 这一行改成:
1 | cv.wait(lk, []{ return condition_check(); }); |
当后边的 lambda 表达式中,返回值为真时,wait 将不再继续阻塞。检查条件状态,需要在发出事件的任务中,对状态做修改(比如一个 bool 值),完整的改进:
1 | std::condition_variable cv; |
这是 C++ 异步编程中,处理通信的最常见答案。在 demo 程序中,可能看起来比较清晰,但当这样一个设计模式,放在一个大型的异步程序中,多个锁、多个条件变量,以及更复杂的状态检查条件,会让程序本身开发和维护变得很棘手。
使用期值来实现
之前条款中,我们提到,创建的子任务中,返回值会通过 std::promise 类型的对象来发送,而父进程的返回值接收,是通过期值 std::future 对象来接收。
由于返回值通信并不是泛化的任务间通信,它只能通信一次,所以,本条款也明确指出,只有在一次性通信任务中,可以使用这个方案。由于我们只需要传送状态,而不需要带有任何额外信息,故而返回值的类型,只需要设置为 void 即可。
1 | // 需要共用的数据 |
这种实现,既可以解决上一节提到的通知遗漏和虚假唤醒问题,也看起来更整洁。但它也不是完美的:
- 如上所述,它只能适合一次性通信,返回值状态不能被重复接收;
- 我们知道两端为了保存返回值,需要分配一块共享内存,这带来了堆分配成本;
如果能接受这样的问题,使用期值来实现通信也是一个不错的方案。一个完整的 demo 如下:
1 | void received(); // 实现接受事件后,需要做的事情 |
书中还提到了这里存在的一个小问题。假设在线程 t 创建启动之后,p.set_value() 之前,父进程程序发生了异常,那么 p.set_value() 不会被执行,那么子线程就会始终阻塞下去。使用第一种条件变量的方案也会遇到这个问题。
书中没有给出这个问题的答案,以下是我的个人想法,仅供参考。完整的改善代码为:
1 | void received(); |
这块代码中的几个细节。首先,在创建线程 t 之前,先获取 p 的期值 fut。这个操作很关键,在获取期值的同时,编译器会为父进程和子线程创建共享的数据区域,来存放返回值,这块区域既不属于父进程,也不属于子线程,而是独立于两者的作用域,而且,只有当两个任务都结束之后,这块空间才会被释放。
其次,将期值 fut 以移动的方式交给子线程,这样,我们就同时获得了发送端、接收端和一块独立的共享对象。当对象 p 被析构后,原来的实现中,子线程中的对象 p 的资源以引用方式(std::promise 也是一个资源句柄)捕获,便会变成悬空引用。这种改动可以避免这个问题。
最后,也是最关键的一点,std::promise 的析构函数在执行时,如果其共享对象没有被设置(没有调用 set_value 或没有发生过异常),那么析构函数会将一个异常状态 std::future_error 存入共享状态中。当任何在该共享对象上等待的操作发生时(如 f.wait()),则会立即被唤醒。
书中还建议利用 RAII 来包装线程,我认为在这个示例中没必要,如果包装,包装类对象和 std::promise 对象 p 位于相同的作用域,如果包装类的析构先于 p 的析构执行,那么又会遇到 t.join() 与 f.wait() 之间死锁的问题,还需要去处理析构顺序,就有点偏题了。
总的来说,我个人的意见是,本本分分地去使用条件变量和锁的方案为好。换成期值方案,不但有一些限制条件,而且并没有完全弱化异步编程的难度。
技巧:在某些场合下,可以使用期值特性来实现异步通信,获得特别的一些好处。
条款 40:区分 std::atomic 和 volatile 的不用用法
实话说,在看到这个条款时,我是困惑的,因为我从来没有想过这两个东西可以并列在一起讨论。但估计有些人确实会混淆二者,所以我考虑还是展开陈述一下。
std::atomic 的作用
本章讨论并发编程,所以原子操作是不应该缺席的,毕竟它太常用了,也有很多值得说道的地方。
首先,有关于 std::atomic 对象的操作,是原子的,它意味着多线程并发访问该对象时,硬件可以保证访问不会出现数据竞争,这是很简单的知识。不过需要注意,虽然对原子对象本身的操作是原子的,但如果原子操作位于另外一个非原子的表达式中,那么别把整个表达式也看作原子的。
其次,std::atomic 操作还会对指令的重排序产生限制。我们知道,编译器会做一些优化,对一些它认为没有数据依赖和控制依赖的表达式,做重排序,这样做可能会改进整个程序的性能;另外,硬件本身也有可能对指令乱序发射,它也会去分析程序的依赖性。如果我们的程序中使用了一条原子操作,那么在默认情况下,原子操作前后的指令重排序,不会跨过该原子操作。
这个地方涉及到内存模型的问题。重排序操作通常是在单线程模式下计算的,所以在多线程场景下,可能会出问题,原子操作利用内存屏障(memory fence)的机制,阻止了重排序跨过自身。
内存模型有多种不同的内存序,默认的内存序是顺序一致性,而顺序一致性保证了原子操作之前的指令,不会调度到之后,以及原子操作之后的指令,不会调度到之前。这是最强的一致性保证。
还有其他的内存序,不在这里单独展开。
volatile 的作用
做过嵌入式软件开发的同学应该都对此关键字很熟悉。它最常用的地方在于,声明一个变量是外部接口,比如内存映射的 I/O,端口,传感器等外设。它们的特点是,变量本身的读写,不完全取决于程序内部指令,还可能 “随机地” 出现在程序运行期间的任何时间点。
这种情况下,我们就不能完全按程序指令的静态假设,来对该变量做优化。比如:
1 | int x; |
其中,第一条 y=x 和第二条 y=x 在程序逻辑中,是可以通过删除第一条赋值操作来优化的;但第一条 z=v 和第二条 z=v,由于变量 v 被声明为 volatile,两条赋值操作都不会被优化。也许这里的 v 映射到了外部的传感器,而传感器每时每刻的读取值都可能不同,所以每次赋值操作都可能不同。
volatile 便是告诉编译器,不要对这个变量做任何优化。
最后总结一下,虽然看似两个概念都涉及到避免编译器或硬件操作改变程序行为,但两者的行为机制并不相同。
std::atomic对于并发程序是有用的,它可以保证操作的原子性避免数据竞争,也能约束指令调度的范围。但它做不到声明访问特殊内存时,避免编译器优化;volatile则相反,基本上专用于声明特殊内存,避免编译器优化;但做不到前者那些在并发程序中的用途;
特别的,如果一个变量,既需要并发编程时的原子性,也刚好是个特殊内存,那么它可以同时声明二者:
1 | volatile std::atomic<int> va; |
谬误:在异步程序中,使用
volatile来修饰原子操作。
本系列的其他文章:
- Effective Modern C++ 读书笔记:类型推导
- Effective Modern C++ 读书笔记:auto
- Effective Modern C++ 读书笔记:转向现代C++
- Effective Modern C++ 读书笔记:智能指针
- Effective Modern C++:右值引用、移动语义和完美转发
- Effective Modern C++:lambda 表达式
- Effective Modern C++:并发 API
- Effective Modern C++:微调
本文同步发布在知乎账号下:https://zhuanlan.zhihu.com/p/1962242496819098139













