《effective modern c++》笔记 第7章 并行API
第七章
-
c++对并行的支持是一大进步,而且也只是个开始。
-
标准库中有两个
future模板
:std::future
和std::shared_future
,大部分情况下这两者的差异不重要,书中讲到的future
通常同时表示这两者。
35. 尽量用任务式的程序设计取代线程式的设计
-
假如要非同步执行doAsyncWork函数,有两种选择:
- 使用线程式(thread-based)的设计:
std::threaad t(doAsyncWork);
- 使用任务式(task-based)的设计:
auto fut = std::async(doAsyncWork);
- 使用线程式(thread-based)的设计:
-
通常任务式的设计有更多好处:
- 有返回值。线程式设计是没办法直接获取返回值的。而
std::async
提供get函数轻松获取返回值。 - 支持异常捕获。通过get函数可以取得异常。而线程式的设计一旦异常就导致程序终止(通过调用std::terminate)。
- 有返回值。线程式设计是没办法直接获取返回值的。而
-
几个概念要区分清楚:
- 硬件线程(hardware thread):现代的计算机CPU在每个核心上都提供一个以上的线程。
- 软件线程(software thread):操作系统进程内部的线程,排在硬件线程上执行。一旦阻塞(如果等待IO操作、等待互斥量),硬件线程就会切换到其他未阻塞的软件线程来执行。
- std::thread:这是C++的类,是底层软件线程在C++的句柄(handle)。当然,一个std::thread也可能不代表任何底层软件线程(而是代表null),可能是因为处于刚刚被构造的状态、曾经作为移动操作的来源等。
-
多线程编程会有两个棘手情况:
- 软件线程资源耗尽:软件线程是有限资源,操作系统是限制了线程数量的,如果超出会抛出
std::system_error
异常。 - 过度订阅(oversubscription):线程数量太多,一个线程被系统不停地调度到不同的CPU核心上运行,线程上下文切换成本变得很高。
- 软件线程资源耗尽:软件线程是有限资源,操作系统是限制了线程数量的,如果超出会抛出
-
std::async
在面对上述两个棘手问题时是有做了一些工作的:std::async
不保证会建立新的软件线程。当上述两个棘手情况发生时,计算工作会被调度到调用get或wait操作
的线程。当然,这可能会对调用get或wait操作的线程造成影响,如果这个线程是负责处理GUI的,那可能会导致界面失去响应。第36条会讲解启动策略来配置这个行为。 -
当然,
std::thread
也有其适用场景:- 需要存取底层线程实现API:有时候需要操作底层的pthread或者Windows threads,因为他们提供了更丰富的功能。
std::thread
会提供native_handle
成员函数。 - 需要针对硬件环境、软件逻辑优化线程的使用:软件逻辑比较特殊,或者硬件特性比较特殊。这种情况下需要开发者自己来调整调度方法。
- 要实现标准库没有提供的线程特性:比如线程池。
- 需要存取底层线程实现API:有时候需要操作底层的pthread或者Windows threads,因为他们提供了更丰富的功能。
36. 有必要非同步时就指定std::launch::async
-
std::async
有两种启动策略(launch policy):- std::launch::async:表示计算必须以非同步的方式在另外一个线程执行。
- std::launch::deferred:表示计算会延迟(defferred)执行,会在返回的future调用get或者wait时才执行。调用者线程会被阻塞到计算完成。
-
std::async的默认启动策略,是以上两种方式的
或
:
// 以下两个future具有相同的启动策略
auto fut1 = std::async(f);
auto fut2 = std::async(std::launch::async | std::launch::deferred, f);
-
当我们在线程t上执行
auto fut = std::async(f);
时,我们使用了默认的启动策略,我们会面临以下问题:- 无法预测f是否会跟当前线程t并行执行。f可能会延迟执行。
- 无法预测f是否会在执行fut的get和wait的线程里执行。尤其是当在t里执行get或wait时,更是没办法预测。
- 无法预测f是否被执行。因为fut的get和wait可能根本没有被调用。
- 由于第1、2点,有时不太适合使用
thread local
变量。这会使用线程本地存储(thread local storage, TLS),但我们无法预测f执行时会存取那个线程的变量。 - 由于第3点,
wait_for
和wait_until
这类看起来可以等待到f运行结束的调用,实际上可能永远不会等到。
-
要想知道一个future对象是否是deffered,没有直接的接口获取,只能通过
wait_for(0)
的返回码是否是std::future_status::deferred
来判断。 -
如果想要保证f一定是与调用线程异步执行的,就指定启动策略为
std::launch::async
。
37. 确保std::thread在所有路径都是unjoinable
-
所有std::thread的状态不是joinable(可链接)就是unjoinable(不可连接)。
-
joinable
表示这个std::thread对象关联了一个底层线程。 -
为什么我们要关注是否可连接的状态呢?这一点非常重要:对一个joinable的std::thread对象调用析构函数会导致程序崩溃退出。
echo:我亲自试了一下,确实是会导致coredump。 代码里就是简单的声明了一个thread对象(当然要指定其执行函数)。屏幕上会输出: terminate called without an active exception [1] 11661 abort (core dumped) ./a.out
-
std::thread的状态是unjoinable的情况列举如下:
- 使用默认构造函数构造的std::thread对象。因为它没有要执行的函数,因此一定是不可连接的。
- 曾经作为移动操作来源的std::thread对象。因为底层的线程已经被移动到了其他对象。
- std::thread已经被join了,join结束后的std::thread对象已经执行完毕,不在对应底层的线程。
- std::thread已经被detach了,这会切断std::thread跟底层线程的关系。
echo:上面的这几种情况描述在std::thread::~thread()文档也有描述。
- 只要不是以上情况的,均视为joinable。
echo:因此我们要注意,一个thread对象如果没有被join过,即使底层线程已经执行完毕了,thread对象也仍然是joinable的。因为线程是异步执行的,系统并不知道你将来是否会调用join,所以就要求要么join过,要么detach过,否则就是joinable的。
我们可能会想,为什么不让thread对象默认就join或者默认就detach。 书中也给出了标准委员会的考虑: 1. 默认join:不符合一般直觉。这会导致声明一个线程对象直接就阻塞了。 2. 默认detach:有些场合是需要join()的。比如,线程里用到了调用者代码里的栈变量,这时调用者需要等待线程执行完毕才能弹出栈。 标准委员会基于这些考虑,决定不能默认join和默认detach。
- 我们在写代码的时候,可能不小心就会发生调用joinable对象的析构函数的情况:
bool func()
{
// 声明了一个线程对象,线程的工作就是打印一句话。
// 这个线程对象是joinable的。
std::thread t([](){ cout << "do some work" << endl; });
if (condition_satisfied()) // 如果条件满足,就join()等待线程执行完
{
t.join();
}
// 如果条件没满足,函数返回,线程对象的析构函数会被调用,这是程序会崩溃退出。
// 一种正确的方案,是调用detach:
// if (t.joinable())
// t.detach();
return false;
}
注意,上面这段代码的正解中,我们先判断了t.joinable()才进行了detach操作。这时因为:对unjoinable的thread对象调用detach会引发崩溃。 这里就会有个隐藏的问题:如果我们调用t.detach()前,有另外的线程也调用了detach(),就会引发崩溃。因此这里开发者自己要确认这里是否会存在并发调用的情况。
-
诸如continue、break、goto这类语句,都可能会引发这个问题。更隐蔽的,析构函数中对成员变量的析构顺序也可能会引发这个问题。例如成员变量中有个线程对象,该线程使用了其他成员变量,那开发者就要确保线程在其他成员变量析构前结束。
-
因此,虽然本章标题说要保证thread对象从定义到离开调用范围的所有路径都要保证unjoinable,但实际上实操起来是比较复杂的。
echo:线程坑太多了,还是多进程安全些。
38. 注意线程对象处理析构函数的行为的差异
-
这段代码
auto fut = std::async(f);
将启动一个线程执行函数f,函数的执行结果会保存在某个地方,然后调用者在调用fut对象的get函数时会通过这个地方得到执行结果。这个保存执行结果的地方是一个通道对象,它是将结果从计算线程发送到调用线程的通道,通常是一个std::promise
对象。 -
上面的代码,我们面临这样的情况:
- 线程可能在
fut对象构造
之前就执行完毕。因此通道对象肯定不能属于线程,否则线程执行完毕就把通道对象给释放了。 - 那我们想,通道对象可以属于调用线程,并通过构造函数传递给fut对象。但
shared_future
可能会导致多个future对象共用通道对象的情况发生。
- 线程可能在
-
基于以上的考虑,通道对象通常是以堆变量的形式实现的(这完全看具体标准库的实现),多个future对象会共用这个通道对象。
-
future对象其实代表了一个底层线程,这跟std::thread是一样的。因此第36条的规则对future也是有效的(把joinable的线程析构会导致崩溃,必须join或者detach)。future对象的析构函数会自动等待线程执行完毕(自动join),因此可能会导致阻塞。
-
注意,如果是
launch::deferred
状态启动的async调用,则future对应的线程是unjoinable的,因为线程根本都没有启动。因此这种future对象的析构函数是肯定不会导致阻塞的了。 -
如果多个future对象共享了一个通道,那这些future对象析构时,最后一个被析构的future对象的析构函数会等待线程执行完毕。
39. 考虑使用void future作为一次性事件通讯
-
我们经常要实现这样的功能:A线程通知B线程,B线程被通知后才开始进行某项工作。通常,实现这种功能有两种思路。
-
思路一:使用条件变量。B线程等待条件变量,A线程在某个时刻设置条件变量。这个思路有两个缺点:
- B线程在等待条件变量前,有可能A线程早都已经设置过条件变量了,导致B线程会永久等待下去。要解决这个问题,就必须在等待条件变量前先判断下条件变量是否已经被设置了,如果没被设置才能开始等待。而且这个检查必须在互斥量的保护下才能保证正确工作,因为这里是两个操作(一次判断,一次等待,不是原子的)。
- 条件变量的等待有可能被“伪唤醒”,我们要写代码处理这种情况。
-
思路二:使用布尔标志位。B线程循环检查该标志位,A线程在某个时刻设置此标志位。这个思路的缺点是,B线程要付出轮询的成本。
-
更复杂但好一些的做法是结合上面两种思路,使用条件变量来避免轮询。
-
实际上,我们还有更精简、更安全的做法,这种方法不需要使用条件变量、互斥量、标志位。而是使用future天然的需要wait()一次的特性。两个线程的通信使用第38条中提到的通道来进行(std::promise对象)
// 声明通道对象
std::promise<void> p; // 因为不需要在通道里真的传输数据,所以用void作为模板参数。
// A线程的代码
p.set_value(); // 很简单,这就相当于做了通知了。
// B线程的代码
p.get_future().wait(); // 很简单,不需要担心通知和等待的顺序,不需要担心伪唤醒。
-
上面这种方法虽然非常简单易用,也有其局限性:
- 每个通道只能使用一次。不能反复使用。
- 由于通道是堆变量,因此有new/delete的成本。
-
如果不在意上面的两点缺点,这是最精简、最安全的通知方法了。原来老的思路实在是太复杂。
40. 并行用std::atomic,特殊内存用volatile
-
首先要知道volatile跟并行没什么关系,本章的重点是std::atomic。
-
std::atomic可以比mutex更高效的完成原子操作。
- std::atomic可以防止编译器优化执行顺序,保证处理器多核对内存访问的顺序。
echo:类似内存屏障的功能,可以让atomic的复制操作前的内存写操作一定同步到各个核。
- volatile只是用于特殊内存,比如显示器、各种输入输出设备的memory-mapped I/O。编译器对这类内存访问的优化会导致跟设备交互发生问题。