02 线程管理 | 《C++ Concurrency In Action》笔记

本系列笔记参考《C++ Concurrency in Action, 2nd Edition》及其中文翻译

线程管理基础

特殊情况下的等待

异常捕捉

当在线程运行后产生的异常,会在 join() 调用之前抛出,这样就会跳过 join()。避免应用被抛出的异常所终止。通常,在无异常的情况下使用 join() 时,需要在异常处理过程中调用 join(),从而避免生命周期的问题。

1
2
3
4
5
6
7
8
9
10
11
12
void f() {
func my_func();
std::thread t(my_func);
try {
do_something_in_current_thread();
}
catch(...) {
t.join();
throw;
}
t.join();
}

RAII

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class thread_guard {
public:
thread_guard(std::thread& t) : t_(t) {}
~thread_guard() {
if(t.joinable()) {
t.join();
}
}
thread_guard(thread_guard const&) = delete;
thread_guard& operator=(thread_guard const&) = delete;
private:
std::thread& t_;
};

void func() {
func my_func();
std::thread t(my_func);
thread_guard g(t);
do_something_in_current_thread();
}

后台运行线程

使用 detach() 会让线程在后台运行,这就意味着与主线程不能直接交互。如果线程分离,就不可能有 std::thread 对象能引用它。分离线程的确在后台运行,所以分离的线程不能汇入。不过 C++ 运行库保证,当线程退出时,相关资源的能够正确回收。

分离线程通常称为守护线程(daemon threads)。UNIX 中守护线程,是指没有任何显式的接口,并在后台运行的线程,这种线程的特点就是长时间运行。线程的生命周期可能会从应用的起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。另外,分离线程只能确定线程什么时候结束,发后即忘(fire and forget)的任务使用到就是分离线程。

注意,启动线程后在线程销毁前要对其调用 join() 或 detach(),否则 std::thread 的析构函数会调用std::terminate 来终止程序。

传递参数

直接传参

向可调用对象或函数传递参数很简单,只需要将这些参数作为 std::thread 构造函数的附加参数即可。需要注意的是,这些参数会拷贝至新线程的内存空间中,即使函数中的参数是引用的形式,拷贝操作也会执行。

1
2
3
4
5
6
7
void f(int i, std::string const& s);
void oops(int some_param) {
char buffer[1024];
sprintf(buffer, "%i", some_param);
std::thread t(f, 3, buffer);
t.detach();
}

在上面的代码中,buffer 是一个指针,指向局部变量,然后此局部变量通过 buffer 传递到新线程中。此时,函数 oops 可能会在 buffer 转换成 std::string 之前结束,从而导致未定义的行为。因为,无法保证隐式转换操作std::thread 构造函数的拷贝操作的顺序,有可能 std::thread 的构造函数拷贝的是转换前的变量(buffer 指针)。解决方案就是在传递到 std::thread 构造函数之前,就将字面值转化为 std::string:

1
std::thread t(f, 3, std::string(buffer));

相反的情形(期望传递一个非常量引用,但复制了整个对象)倒是不会出现,因为会出现编译错误,比如:

1
2
3
4
5
6
void update_data_for_widget(widget_id w, widget_data& data);
void oops_again(widget_id w) {
widget_data data;
std::thread t(update_data_for_widget, w, data);
t.join();
}

虽然 update_data_for_widget 的第二个参数期待传入一个引用,但 std::thread 的构造函数并不知晓,构造函数无视函数参数类型,盲目地拷贝已提供的变量。不过,内部代码会将拷贝的参数以右值的方式进行传递,这是为了那些只支持移动的类型,而后会尝试以右值为实参调用 update_data_for_widget。但因为函数期望的是一个非常量引用作为参数,所以会在编译时出错。问题的解决办法很简单:可以使用 std::ref 将参数转换成引用的形式:

1
std::thread t(update_data_for_widget, w, std::ref(data));

这样 update_data_for_widget 就会收到 data 的引用,而非 data 的拷贝副本,这样代码就能顺利的通过编译了。

传递函数指针

除了直接传参,还可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数:

1
2
3
4
5
6
class X {
public:
void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work, &my_x);

这段代码中,新线程将会调用 my_x.do_lengthy_work(),其中 my_x 的地址作为对象指针提供给函数。也可以为成员函数提供参数:std::thread 构造函数的第三个参数就是成员函数的第一个参数,以此类推:

1
2
3
4
5
6
7
class X {
public:
void do_lengthy_work(int);
};
X my_x;
int num = 0;
std::thread t(&X::do_lengthy_work, &my_x, num);

移动性

std::thread 的构造函数参数参数仅支持移动,不能拷贝。即在赋值的时候就发生了了所有权转移。

就像对象的所有权可以在多个 std::unique_ptr 实例中转移一样,线程的所有权可以在多个 std::thread 实例中转移,这依赖于 std::thread 实例的可移动且不可复制性。不可复制性表示在某一时间点,一个 std::thread 实例只能关联一个执行线程。可移动性使得开发者可以自己决定,哪个实例拥有线程实际执行的所有权。

转移所有权

不要通过赋新值给 std::thread对象的方式来”丢弃”一个线程。赋值时会终止源线程,源线程很可能还没有执行完,此时会 crash。

02 线程管理 | 《C++ Concurrency In Action》笔记

http://www.zh0ngtian.tech/posts/97e9933.html

作者

zhongtian

发布于

2020-12-21

更新于

2023-12-16

许可协议

评论