线程管理——线程管理基础

前言

 
每一个程序都至少存在一个线程:执行main函数的原始线程,其余线程与原始线程同时运行(并非同时启动),并在执行完其入口函数后退出。本节将从如何启动线程说起,大致地为读者介绍C++并发编程中的线程管理基础。


启动线程

 
线程在std::thread对象创建完毕后启动(thread对象被指派了入口函数)。

创建thread对象

在最理想的情况下,入口函数既不需要任何参数也没有返回值,用这种函数来创建std::thread对象的过程如下所示:

1
2
void do_some_work(); 
std::thread my_thread(do_some_work);

除此以外,std::thread也接受可调用类型构造,例如:
1
2
3
4
5
6
7
8
9
class 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(); });

等待式与分离式

 
在线程完成启动后,需要明确是否需要等待线程结束,或者令其在后台自主运行,具体而言,即明确对线程执行joindetach中的何种操作。如果在std::thread对象析构前仍未执行joindetach,则析构函数会调用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
13
struct 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
20
class 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
13
void 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
16
struct 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_statemy_thread仍在执行,从而导致未定义行为。

解决方案

针对此类问题,最好的做法是将数据复制至线程内,可调用对象在作为构造函数参数时会被复制,但其内部所含有的引用或指针却仍然指向外部共享数据。使用一个能够访问局部变量的函数去初始化std::thread对象并不可取,除非你可以保证该线程结束于局部变量析构之前。如果不能做出上述保证,则应当使用join而非detach。

总结

  1. 线程在std::thread对象创建完毕后启动。
  2. 线程需要明确执行joindetach,在执行join时,最好以RAII保证join的必然执行,而detach则需要关注是否存在空悬数据的可能,是则最好对数据执行拷贝操作或选择使用join