0%

Effective Modern C++读书笔记(六)

本文包含《Effective Modern C++》中第7、8两章内容,《Effective Modern C++》个人读书笔记此篇完结。

第七章 并发API

条款35: 优先考虑基于任务而非基于线程的程序设计

c++11标准中带来了std::thread和std::future,两个类型都是作为并发内容加入到11标准中的,thread是以往线程概念的类型封装,future是c++标准为了应对异步操作而做的新的概念封装,例如一个future对象可以通过std::async建立:

1
auto fut = std::async(doAsyncWork);

doAsyncWork的执行结果和执行异常可以通过fut这个future对象的get方法获取。
本条款的关注点在于相比thread来说,future方式其内部C++标准库实现的线程管理和调度相对较优,不会出现超线程上限或是资源超订( oversubscription)现象,而自行建立线程池进行管理thread较为麻烦,所以在没有特殊要求时可以优先考虑基于任务的future模式,当然如果需要自行管理线程或者需要更丰富的线程api可以优先考虑thread。

条款36: 如果异步是必要的,则指定std::launch::async

本条款关注点在于std::async的默认启动策略是std::launch::async | std::launch::defered的,即既不保证是另一线程执行也不保证会延迟执行,这将导致如果程序没有考虑到这一点会出问题,具体来说std::async的默认策略可以使用需要保证:

  • 任务不需要和执行get或wait的线程并行执行
  • 读写哪个线程的thread_local变量没什么问题
  • 可以保证会在std::async返回的future上调用get或wait,或者该任务可能永远不会执行也可以接受
  • 使用wait_for或wait_until编码时考虑了延迟状态

如果明确需要异步执行,则在std::async时需要指定std::launch::async,这里书上也写了一个函数模板用以指定std::launch::async:

1
2
3
4
5
6
7
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)...);
}

条款37: 使std::thread在所有路径最后皆不可联结

std::thread线程对象在可join的状态下被析构将执行std::terminate,这是由于c++11中的thread不是可打断线程(interruptible threads),不可打断则对于一个可能仍在运行的线程的析构则只有两种措施,要么join等待其执行完毕,要么detach分离线程,这两种方式可能都有问题。阻塞等待线程结束,违反直觉;detach分离线程,该线程依赖的外部对象如果已经析构而线程仍继续运行则会造成问题;所以标准库thread的做法是直接调用terminate。
一个std::thread不可join的状态包括:

  • 默认空构的thread对象
  • 被移走的thread对象
  • 已被join的thread对象
  • 已被detach的thread对象

对于使用std::thread的场景,需要保证程序所有可能运行路径中最后都不可join。
这里书上写了个调用join和detach取代析构的RAII例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class ThreadRAII {
public:
enum class DtorAction { join, detach };

ThreadRAII(std::thread&& t, DtorAction a)
: action(a), t(std::move(t)) {}

ThreadRAII(ThreadRAII&&) = default;

ThreadRAII& operator=(ThreadRAII&&) = default;

~ThreadRAII()
{
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}

std::thread& get() { return t; }

private:
DtorAction action;
std::thread t;
};

条款38: 对变化多端的线程句柄析构函数行为保持关注

std::future作为可能运行线程的线程句柄对象,其析构函数的行为和std::thread不同。future对象如果有关联的共享状态,其析构将减少对该状态的引用计数,如果当前future对象是最后一个关联该共享状态的,销毁该共享状态,然后特别的仅当以下规则成立时才会阻塞等待共享状态完成(共享状态关联线程执行完成):

  • 该future对象关联的是std::async创建的共享状态
  • std::async的启动策略是std::launch::async
  • 当前future对象是最后一个关联到该共享状态的对象

也就是说形如如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename T>
void test(T&& t)
{
auto this_id = std::this_thread::get_id();
sleep(2);
cout << "test thread:" << this_id << endl;
}

int main()
{
auto this_id = std::this_thread::get_id();
cout << "main thread:" << this_id << endl;

{
std::future<void> f = std::async(std::launch::async, test<int>, 1);
cout << "inner" << endl;
}
cout << "outer" << endl;

return 0;
}

“test thread:”的打印会在“inner”和“outer”之间,也就是future析构内隐式join了关联线程(与激进的thread析构terminate不同),这个特殊行为在当前C++标准(至今c++20)仍然适用。

条款39: 考虑针对一次性事物通信使用以void为模版型别实参的期值

本条款关注一次性并发通信问题,对于检测任务(detecting task)和反应任务(reacting task)场景,可以使用std::condition_variable:

1
2
3
4
5
6
7
8
9
10
// 检测任务
std::condition_variable cv;
std::mutex m;
bool flag(false);
///...
{
std::lock_guard<std::mutex> g(m) ;
flag = true;
}
cv.notify_one();
1
2
3
4
5
6
7
8
// 反应任务
//...
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [] { return flag; });
//...
}
//...

condition_variable的用法对于仅需一次性通信的事务用梅耶斯的说法是“不太优雅”,可以考虑使用promise和future进行异步通信:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::promise<void> p;
void react();
void detect()
{
std::thread t([]
{
p.get_future().wait();
react();
});
//...
p.set_value();
//...
t.join();
}

由于该一次性通信并不传递什么,所以使用std::promise<void>和std::future<void>,当有多个反应任务时可以使用std::shared_future<void>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::promise<void> p;
void detect()
{
auto sf = p.get_future().share();
std::vector<std::thread> vt;
for (int i = 0; i < threadsToRun; ++i) {
vt.emplace_back([sf]{
sf.wait();
react();
});
}
//...
p.set_value();
//...
for (auto& t : vt) {
t.join();
}
}

需要注意的是promise和future是一次性事务,传递一次后就无法使用,所以针对一次性的通信可以考虑使用promise和future。

条款四十: 对并发使用std::atomic,对特殊内存使用volatile

volatile与std::atomic在使用上需要区分其实际意义,volatile关键字标识该变量内存的特殊性,阻住编译器对其相关的冗余访问(redundant loads)和无用存储(dead stores),即形如:

1
2
3
4
5
int x;
auto y = x;
y = x;
x = 10;
x = 20;

由于x没有施加volatile关键字,编译器将对以上代码执行优化:

1
2
auto y = x;
x = 20;

如果将x声明为volatile int则编译器不会优化掉对x的两次读取赋值给y以及对x的两次赋值。
而atomic并不具备以上标识特殊内存的作用,其只是在并发编程中不使用互斥锁的一个工具。

第八章 微调

条款四十一: 针对可复制的形参,在移动成本低并且一定会被复制的前提下,考虑暗值传递

本条款讨论的问题是11标准以后形参的传递问题,当传递引用形参时我们可能需要重载左值引用、右值引用两个版本,或者使用万能引用模板,重载版本需要维护两个函数,万能引用的方法存在实例化多个版本函数、实现必须放入头文件问题。如果函数内必然会出现拷贝且参数移动成本低且不考虑继承带来的切片问题,可以考虑如下方式的按值传递:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
void addName(std::string newName)
{
names.push_back(std::move(newName));
}

private:
std::vector<std::string> names;
}

这里由于names.push_back必然会发生一次拷贝,所以相比传递左值引用(左值拷贝进names)或者右值引用(右值移动进names)作为形参,传值方式传递左值实参,实参拷贝进newName,移动入names,传值方式传递右值实参,移动入newName,移动入names,亦即以上传值方式只多了一次移动消耗。
需要注意的是如果在names.push_back前有其他分支使函数提前退出,那么这多余的一次拷贝动作就是不必要的开支,亦即标题中“总是被拷贝”的含义。
还需要注意的是对于如std::string的类型,其拷贝构造会牵涉到新内存申请旧内存释放,而其赋值拷贝可能只涉及拷贝,这也是考虑是否应该使用传值方式的因素之一。

条款四十二: 考虑置入而非插入

11标准中给部分容器增加了emplace亦即置入方法用于插入数据,如vector增加了emplace和emplace_back:

1
2
3
4
5
template< class... Args >
iterator emplace( const_iterator pos, Args&&... args );

template< class... Args >
void emplace_back( Args&&... args ); // 17标准后改为返回被插入元素的引用

与insert和push_back相比,emplace族方法为避免元素复制构造而生;insert和push_back传递元素参数时需要构造临时元素T,然后在函数内部在复制构造一次在容器内的元素,造成可能的不必要的性能消耗:

1
void push_back( const T& value );

emplace直接传递构造元素的参数,在emplace内部通过参数直接构造容器内元素。
这里主要讨论使用emplace时需要注意的几个问题,置入比插入更快的前提是满足以下三个条件:

  • 值是通过构造添加到容器,而非通过赋值。赋值意味着需要构造一个临时对象而后通过赋值操作符赋值给容器内原有元素,临时对象的构建则意味着置入不比插入有优势。
  • 传递的实参类型与容器内类型不同。如果传递的参数仍为T,则依然有临时对象问题。
  • 容器不拒绝重复项作为新值。重复项检测可能需要构建临时对象
    另一个需要注意的问题是资源管理问题,某些时候是需要有临时对象来管理临时内存的,例如智能指针,如果直接使用如下方式可能会造成内存泄漏问题(构造元素失败抛出异常,内存未回收):
1
ptrs.emplace_back(new Widget, killWidget);

最后需要注意的是与explicit构造互动的问题,emplace内部使用直接初始化(Dierct Initialization)完成容器内元素的构造,避免因类型具有explicit构造而无法执行调用问题,这可能会造成一些直观上的误区(相比push_back),如书上所说的regex容器:

1
2
3
std::vector<std::regex> regexes;
regexes.emplace_back(nullptr);
regexes.push_back(nullptr);

emplace_back的调用将通过编译,push_back将无法通过编译,原因是emplace_back内部显式构造regex,而push_back编译器需要将nullptr隐式转换成regex,而符合的函数签名只有如下,它是explicit的,所以其无法通过编译

1
explicit basic_regex ( const charT* str, flag_type flags = ECMAScript );