前言
每一个程序都至少存在一个线程:执行main函数的原始线程,其余线程与原始线程同时运行(并非同时启动),并在执行完其入口函数后退出。本节将从如何启动线程说起,大致地为读者介绍C++并发编程中的线程管理基础。
启动线程
线程在std::thread
对象创建完毕后启动(thread对象被指派了入口函数)。
创建thread对象
在最理想的情况下,入口函数既不需要任何参数也没有返回值,用这种函数来创建std::thread
对象的过程如下所示:1
2void do_some_work();
std::thread my_thread(do_some_work);
除此以外,std::thread
也接受可调用类型构造,例如:1
2
3
4
5
6
7
8
9class background_task {
public:
void operator()() const {
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);
函数对象将会被拷贝至新线程自己的存储空间,因此需要保证副本与原本的执行结果具备一致性。
值得注意的是,当传递函数对象至std::thread
构造函数时,需要避免C++'s Most vexing parse
。具体而言,当你将一个临时变量传入构造函数时,编译器可能会将该语句解析为一个函数声明,如:
1 | std::thread my_thread(background_task()); |
该语句声明了一个名为my_thread的函数,其接受一个函数指针,并返回一个thread对象。
规避方法很简单,如同上文一般采用命名对象,或使用C++11新引入的初始化语法即可:1
std::thread my_thread{background_task()};
更多关于C++11初始化语法的信息,可以参阅Effective Mordern C++ Item 7。std::thread
亦可以使用lambda
表达式完成构造,如:
1 | std::thread my_thread([]{ do_something(); do_something_else(); }); |
等待式与分离式
在线程完成启动后,需要明确是否需要等待线程结束,或者令其在后台自主运行,具体而言,即明确对线程执行join
或detach
中的何种操作。如果在std::thread
对象析构前仍未执行join
或detach
,则析构函数会调用std::terminate
,强行终止程序。
等待式(join)
join
是简单粗暴地等待线程完成。值得注意的是,一个std::thread
只能join
一次,此后调用joinable()
时将返回false。
含有异常情况下的join
join in catch
如果入口函数具备noexpect
属性,则直接对std::thread
对象执行join()
即可。若当前入口函数可能抛出异常,并且在join()
语句执行前该异常被抛出,则join()
操作将会被跳过,因此必须在catch语句中对std::thread
对象执行join()
,即:1
2
3
4
5
6
7
8
9
10
11
12
13struct func;
void f() {
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
try {
do_something_in_current_thread();
} catch(...) {
t.join();
throw;
}
t.join();
}
如此一来,std::thread
始终可执行join()
操作。
RAII
另一种更为简洁的方法是使用RAII(Resource Acquisition Is Initialization),现以一个简单的class thread_guard
加以说明:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class thread_guard {
std::thread& t; // data member
public:
explicit 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;
};
struct func;
void f() {
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
thread_guard g(t);
do_something_in_current_thread();
}
需要注意的是,为了避免thread_guard
中的std::thread
丢失,我们手动禁止了所有的copy operation。当thread_guard
对象析构时,它将检查线程状态,并调用join()
。
分离式(detach)
使用detach()
会让线程在后台运行,这意味着主线程不能与之产生直接交互。C++运行库保证,当分离线程退出时,相关资源能够得到正确回收。
线程特点
通常我们称分离线程为守护线程(daemon threads)。在UNIX中,守护线程指没有任何显式用户接口,并在后台运行的线程。守护线程的一大特点即为长时间运行,线程的生命周期可能会从某一个应用起始到结束。守护线程常用于后台监视文件系统,或者执行缓存清理,亦或对数据结构进行优化,是一种典型的“发后即忘”(fire and forget)操作。
只能对存在运行函数的std::thread
对象执行deatch()
(join()
亦是如此),类似地,detach()
也只能对一个std::thread
对象执行一次,我们可以通过joinable()
的返回值true
来确认当前线程可执行分离操作。
使用场景
假设当前存在一个文字处理应用程序,该程序存在同时编辑多个文档的需求。显然,编辑多个文档需要打开多个窗口,这些窗口彼此独立且运行在同一个应用实例中。此时我们可以提出一种解决方案:令每一个文档处理窗口拥有自己独立的线程,这些线程入口程序均相同,只是所处理的数据不同而已。由于需要处理的文档彼此独立,因此线程之间不存在任何等待的需要,文档处理线程可以执行detach()
操作:1
2
3
4
5
6
7
8
9
10
11
12
13void edit_document(std::string const& filename) {
open_document_and_display_gui(filename);
while(!done_editing()) {
user_command cmd=get_user_input();
if(cmd.type==open_new_document) {
const std::stringnew_name=get_filename_from_user();
std::thread t(edit_document,new_name);
t.detach();
} else {
process_user_input(cmd);
}
}
}
空悬引用及指针
问题实例
如果明确某线程为守护线程,则必须明确在该线程运行完毕之前其使用的数据具备有效性。如果一个线程函数持有某个局部变量的引用或指针,则极有可能触发空悬,现有问题实例如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16struct func {
int& i;
func(int& i_) : i(i_) {}
void operator() () {
for (unsigned j=0 ; j<1000000 ; ++j) {
do_something(i);
}
}
};
void oops() {
int some_local_state=0;
func my_func(some_local_state);
std::thread my_thread(my_func);
my_thread.detach();
} // 函数结束后新线程可能还在运行
问题分析
在上述实例中,func
对象内含有局部变量的引用,在oops执行完毕后,局部变量some_local_state
被析构,而此时依赖some_local_state
的my_thread
仍在执行,从而导致未定义行为。
解决方案
针对此类问题,最好的做法是将数据复制至线程内,可调用对象在作为构造函数参数时会被复制,但其内部所含有的引用或指针却仍然指向外部共享数据。使用一个能够访问局部变量的函数去初始化std::thread
对象并不可取,除非你可以保证该线程结束于局部变量析构之前。如果不能做出上述保证,则应当使用join而非detach。
总结
- 线程在
std::thread
对象创建完毕后启动。 - 线程需要明确执行
join
或detach
,在执行join
时,最好以RAII保证join
的必然执行,而detach
则需要关注是否存在空悬数据的可能,是则最好对数据执行拷贝操作或选择使用join
。