条款三十五:优先选用基于任务而非基于线程的程序设计
基于任务?
auto fut = std::async(doAsyncWork);
基于线程?
int doAsyncWork(); std::thread t(doAsyncWork);
基于线程vs基于任务
-
基于线程未提供获取返回值的途径,而且如果函数抛出异常,程序会终止;
-
基于线程的程序要求手动管理线程耗尽,超订,负载均衡以及新平台适配等种种;
-
基于任务拥有默认策略,能够避免以上这些问题;
基于线程的应用场景:
-
需要访问底层线程实现API,可以通过native_handle成员函数访问;
-
你需要且有能力为你的应用优化线程用法,对于个人能力和业务了解有一定的要求;
-
你需要实现超越C++并发API的线程技术,例如线程池;
条款三十六:如果异步是需要的,则指定std::launch::async
单纯std::async只是执行任务的默认启动策略,而默认启动策略既允许任务以异步方式进行,也允许任务以同步方式执行。这样忙的时候有可能永远不执行。执行是否立刻可能会导致thread_local变量的不确定性,因为不同时间的thread_local变量可能有变化,也可能导致超时。
为了解决这个问题,如果明确是异步的,则把std::async的第一个参数设为std::launch::async
auto fut = std::async(std::launch::async, f); // 自动使用std::launch::async的封装函数 // C++11 template<typename F, typename... Ts> inline std::future<typename std::result_of<F(Ts...)>::type> reallyAsync(F&& f, Ts&&... params) { return std::async(std::launch,::async, std::forward<F>(f), std::forward<Ts>(params)...); } // C++14 template<typename F, typename... Ts> inline auto reallyAsync(F&& f, Ts&&... params) { return std::async(std::launch,::async, std::forward<F>(f), std::forward<Ts>(params)...); }
条款三十七:使std::thread型别对象在所有路径皆不可联结
可联结?
其实就是joinable。可联结的std::thread对应底层以异步方式已运行或可运行的线程。不管是处于阻塞,等待调度或者已运行至结束,都是可联结的。
为什么要保证std::thread在所有路径皆不可join?
当函数异常结束时,跳出了正常thread的join路径后会调用函数的析构函数,对于一个可join的thread调用异构函数会导致程序终止,这是规定。
例如下面这个函数:
void test1() { int num = 0; auto sum = [&num]() { ++num; }; std::thread t(sum); // 跳过了正常的join流程 if (false) { t.join();、 return; } return; }
如何保证在所有路径皆不可join?
采用RAII技术,进行thread的封装,见下面代码:
class ThreadRAII { public: enum class DtorAction ( join, detach ); ThreadRAII(std::thread&& t, DtorAction a) // 类成员初始化列表要求匹配变量声明顺序 : action(a), t(std::move(t)) {} ~ThreadRAII() { if (t.joinable()) { if (action == DtorAction::join) { t.join(); } else { t.detach(); } } } ThreadRAII(ThreadRAII&&) = default; ThreadRAII operator=(ThreadRAII&&) = default; std::thread& get() { return t; } private: DtorAction action; // thread初始化好之后可能就马上要运行函数,最好放在最后声明 std::thread t; };
经过封装修改后的代码就可正常运行:
void test2() { int num = 0; auto sum = [&num]() { ++num; }; ThreadRAII t( std::thread(sum), ThreadRAII::DtorAction::join ); if (false) { t.get().join(); } return; }
注意:在析构时调用join可能导致难以调试的性能异常,调用detach可能导致难以调试的未定义行为。
条款三十八:对变化多端的线程句柄析构函数行为保持关注
什么是线程句柄?
joinable的线程对应着一个底层系统执行线程,而std::future与系统线程也有类似关系,所以std::thread和std::future都可以视作系统线程的句柄。
std::future的析构
std::future的析构函数在正常情况下,只会析构std::future的成员变量。
std::async异步执行时,需要有个地方保存运行结果,这个结果并不在std::future对象中,也不再运行函数中的对象中,通常是堆上的对象,称作共享状态,会有多个std::future关联到这个共享状态,而最后一个std::future就需要阻塞直到异步运行的任务结束(为了同时去析构这个对象?),相当于对std::async运行的任务实施了一次join。
例外析构的条件如下:
-
std::future关联的共享状态时由于调用了std::async才创建的(例如不是通过std::packaged_task);
-
该任务的启动策略是std::launch::async,可以是运行时系统的选择,也可以是调用时显式指定的;
-
该std::future是关联到该共享状态的最后一个std::future;
条款三十九:考虑针对一次性事件通信使用以void为模板型别实参的期值
对于事件通信的解决方案:
-
条件变量+互斥量,如果两个线程没有临界资源,互斥量的存在显得多余,而且条件变量等待代码可能会被虚假唤醒,此时需要去校验条件是否已经满足;
-
使用标志位避免了互斥量的使用,也避免了虚假唤醒的问题,但是这是基于轮询的机制而非基于通知机制;
-
条件变量+标志位,条件变量通知了还需要去检查标志位,有点多余;
-
std::promise+std::future或std::shared_future,此方案的问题在于promise只能设置一次(貌似可以考虑不用void来实现多次),而且共状态需要使用堆内存,参考以下代码;
std::promise<void> p; // 信道 // 通知方 p.set_value(); // 被通知方 p.get_future().wait();
条款四十:对并发使用std::atomic,对特种内存使用volatile
std::atomic的操作是原子的,它会对代码可以如何重新排序施加限制,用于多线程访问的数据,且不用互斥量,是并发软件的好工具。
volatile用于冗余读写操作不可以被优化掉的内存,是面对特种内存的好工具。
注意:两个可以混合一起用。