C++并发之线程管理
这章是C++并发的第一章内容,主要叙述线程管理、向线程传递参数、转移线程、决定线程的数量、表示线程等。
-
线程管理的基础
每个程序至少有一个线程:执行main()函数的线程,其余线程有其各自的入口函数。线程与原始线程(以main()为入口函数的线程)同时运行。如同main()函数执行完会退出一样,当线程执行完入口函数后,线程也会退出。在为一个线程创建了一个std::thread
对象后,需要等待这个线程结束;不过,线程需要先进行启动。
线程启动
我们可以构造一个std::thread对象,添加头文件<thread>
#include <thread>
void do_some_work(); std::thread my_thread(do_some_work);
std::thread
可以用可调用类型构造,将带有函数调用符类型的实例传入std::thread
类中,替换默认的构造函数:
class background_task { public: void operator()() const { do_something(); do_something_else(); } }; background_task f; std::thread my_thread(f);
代码中,提供的函数对象会复制到新线程的存储空间当中,函数对象的执行和调用都在线程的内存空间中进行。函数对象的副本应与原始函数对象保持一致,否则得到的结果会与我们的期望不同。
如果你传递了一个临时变量,而不是一个命名的变量;C++编译器会将其解析为函数声明,而不是类型对象的定义。例如:
std::thread my_thread(background_task());
这里相当与声明了一个名为my_thread的函数,这个函数带有一个参数(函数指针指向没有参数并返回background_task对象的函数),返回一个std::thread
对象的函数,而非启动了一个线程。
我们可以使用括号来避免这个问题:
std::thread my_thread((background_task())); // 1 std::thread my_thread{background_task()}; // 2
或使用lambda表达式:
std::thread my_thread([]{ do_something(); do_something_else(); });
启动了线程,你需要明确是要等待线程结束(join),还是让其自主运行(detach):
如果不等待线程,就必须保证线程结束之前,可访问的数据得有效性。
这种情况很可能发生在线程还没结束,函数已经退出的时候,这时线程函数还持有函数局部变量的指针或引用。下面的清单中就展示了这样的一种情况。
例1:
struct func { int& i; func(int& i_) : i(i_) {} void operator() () { for (unsigned j=0 ; j<1000000 ; ++j) { do_something(i); // 1 潜在访问隐患:悬空引用 } } }; void oops() { int some_local_state=0; func my_func(some_local_state); std::thread my_thread(my_func); my_thread.detach(); // 2 不等待线程结束 }
这个例子中,已经决定不等待线程结束(使用了detach() ② ),所以当oops()函数执行完成时③,新线程中的函数可能还在运行。如果线程还在运行,它就会去调用do_something(i)函数①,这时就会访问已经销毁的变量。如同一个单线程程序——允许在函数完成后继续持有局部变量的指针或引用;
运行顺序如下表:
主线程 | 新线程 |
---|---|
使用some_local_state构造my_func | |
开启新线程my_thread | |
启动 | |
调用func::operator() | |
将my_thread分离 | 执行func::operator();可能会在do_something中调用some_local_state的引用 |
销毁some_local_state | 持续运行 |
退出oops函数 | 持续执行func::operator();可能会在do_something中调用some_local_state的引用 —> 导致未定义行为 |
处理这种情况的常规方法:使线程函数的功能齐全,将数据复制到线程中,而非复制到共享数据中。如果使用一个可调用的对象作为线程函数,这个对象就会复制到线程中,而后原始对象就会立即销毁。但对于对象中包含的指针和引用还需谨慎。
线程等待:
如果需要等待线程,相关的std::thread
实例需要使用join()。将my_thread.detach()
替换为my_thread.join()
,就可以确保局部变量在线程完成后,才被销毁。在这种情况下,因为原始线程在其生命周期中并没有做什么事,使得用一个独立的线程去执行函数变得收益甚微,但在实际编程中,原始线程要么有自己的工作要做;要么会启动多个子线程来做一些有用的工作,并等待这些线程结束。
join()是简单粗暴的等待线程完成或不等待。当你需要对等待中的线程有更灵活的控制时,比如,看一下某个线程是否结束,或者只等待一段时间(超过时间就判定为超时)。想要做到这些,你需要使用其他机制来完成,比如条件变量和期待(futures)。调用join()的行为,还清理了线程相关的存储部分,这样std::thread
对象将不再与已经完成的线程有任何关联。这意味着,只能对一个线程使用一次join();一旦已经使用过join(),std::thread
对象就不能再次加入了,当对其使用joinable()时,将返回false。
注意:
如前所述,需要对一个还未销毁的std::thread
对象使用join()或detach()。如果想要分离一个线程,可以在线程启动后,直接使用detach()进行分离。如果打算等待对应线程,则需要细心挑选调用join()的位置。当在线程运行之后产生异常,在join()调用之前抛出,就意味着这次调用会被跳过。
避免应用被抛出的异常所终止,就需要作出一个决定。通常,当倾向于在无异常的情况下使用join()时,需要在异常处理过程中调用join(),从而避免生命周期的问题。下面的程序清单是一个例子。
struct func; // 定义在清单2.1中 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(); // 1 throw; } t.join(); // 2 }
代码使用了try/catch
块确保访问本地状态的线程退出后,函数才结束。当函数正常退出时,会执行到②处;当函数执行过程中抛出异常,程序会执行到①处。try/catch
块能轻易的捕获轻量级错误,所以这种情况,并非放之四海而皆准。如需确保线程在函数之前结束——查看是否因为线程函数使用了局部变量的引用,以及其他原因——而后再确定一下程序可能会退出的途径,无论正常与否,可以提供一个简洁的机制,来做解决这个问题。
一种方式是使用“资源获取即初始化方式”(RAII,Resource Acquisition Is Initialization),并且提供一个类,在析构函数中使用join(),如同下面清单中的代码。看它如何简化f()函数。
class thread_guard { std::thread& t; public: explicit thread_guard(std::thread& t_): t(t_) {} ~thread_guard() { if(t.joinable()) // 1 { t.join(); // 2 } } thread_guard(thread_guard const&)=delete; // 3 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(); } // 4
线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对象g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。
在thread_guard的析构函数的测试中,首先判断线程是否已加入①,如果没有会调用join()②进行加入。这很重要,因为join()只能对给定的对象调用一次,所以对给已加入的线程再次进行加入操作时,将会导致错误。
拷贝构造函数和拷贝赋值操作被标记为=delete
③,是为了不让编译器自动生成它们。直接对一个对象进行拷贝或赋值是危险的,因为这可能会弄丢已经加入的线程。通过删除声明,任何尝试给thread_guard对象赋值的操作都会引发一个编译错误。想要了解删除函数的更多知识,请参阅附录A的A.2节。
如果不想等待线程结束,可以分离_(_detaching)线程,从而避免异常安全(exception-safety)问题。不过,这就打破了线程与std::thread
对象的联系,即使线程仍然在后台运行着,分离操作也能确保std::terminate()
在std::thread
对象销毁才被调用。
后台运行线程:
使用detach()会让线程在后台运行,这就意味着主线程不能与之产生直接交互。也就是说,不会等待这个线程结束;如果线程分离,那么就不可能有std::thread
对象能引用它,分离线程的确在后台运行,所以分离线程不能被加入。不过C++运行库保证,当线程退出时,相关资源的能够正确回收,后台线程的归属和控制C++运行库都会处理。
通常称分离线程为守护线程(daemon threads),UNIX中守护线程是指,没有任何显式的用户接口,并在后台运行的线程。这种线程的特点就是长时间运行;线程的生命周期可能会从某一个应用起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。另一方面,分离线程的另一方面只能确定线程什么时候结束,发后即忘(fire and forget)的任务就使用到线程的这种方式。
调用std::thread
成员函数detach()来分离一个线程。之后,相应的std::thread
对象就与实际执行的线程无关了,并且这个线程也无法加入:
std::thread t(do_background_work); t.detach(); assert(!t.joinable());
为了从std::thread
对象中分离线程(前提是有可进行分离的线程),不能对没有执行线程的std::thread
对象使用detach(),也是join()的使用条件,并且要用同样的方式进行检查——当std::thread
对象使用t.joinable()返回的是true,就可以使用t.detach()。
试想如何能让一个文字处理应用同时编辑多个文档。无论是用户界面,还是在内部应用内部进行,都有很多的解决方法。虽然,这些窗口看起来是完全独立的,每个窗口都有自己独立的菜单选项,但他们却运行在同一个应用实例中。一种内部处理方式是,让每个文档处理窗口拥有自己的线程;每个线程运行同样的的代码,并隔离不同窗口处理的数据。如此这般,打开一个文档就要启动一个新线程。因为是对独立的文档进行操作,所以没有必要等待其他线程完成。因此,这里就可以让文档处理窗口运行在分离的线程上。
下面代码简要的展示了这种方法:
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) { std::string const new_name=get_filename_from_user(); std::thread t(edit_document,new_name); // 1 t.detach(); // 2 } else { process_user_input(cmd); } } }
如果用户选择打开一个新文档,需要启动一个新线程去打开新文档①,并分离线程②。与当前线程做出的操作一样,新线程只不过是打开另一个文件而已。所以,edit_document函数可以复用,通过传参的形式打开新的文件。
2. 向线程函数传递参数
向std::thread
构造函数中的可调用对象,或函数传递一个参数很简单。需要注意的是,默认参数要拷贝到线程独立内存中,即使参数是引用的形式,也可以在新线程中进行访问。再来看一个例子:
void f(int i, std::string const& s); std::thread t(f, 3, "hello");
码创建了一个调用f(3, “hello”)的线程。注意,函数f需要一个std::string
对象作为第二个参数,但这里使用的是字符串的字面值,也就是char const *
类型。之后,在线程的上下文中完成字面值向std::string
对象的转化。需要特别要注意,当指向动态变量的指针作为参数传递给线程的情况,代码如下:
void f(int i,std::string const& s); void oops(int some_param) { char buffer[1024]; // 1 sprintf(buffer, "%i",some_param); std::thread t(f,3,buffer); // 2 t.detach(); }
这种情况下,buffer①是一个指针变量,指向本地变量,然后本地变量通过buffer传递到新线程中②。并且,函数有很有可能会在字面值转化成std::string
对象之前崩溃(oops),从而导致一些未定义的行为。并且想要依赖隐式转换将字面值转换为函数期待的std::string
对象,但因std::thread
的构造函数会复制提供的变量,就只复制了没有转换成期望类型的字符串字面值。
解决方案就是在传递到std::thread
构造函数之前就将字面值转化为std::string
对象:
void f(int i,std::string const& s); void not_oops(int some_param) { char buffer[1024]; sprintf(buffer,"%i",some_param); std::thread t(f,3,std::string(buffer)); // 使用std::string,避免悬垂指针 t.detach(); }
还可能遇到相反的情况:期望传递一个非常量引用(但这不会被编译),但整个对象被复制了。你可能会尝试使用线程更新一个引用传递的数据结构,比如:
void update_data_for_widget(widget_id w,widget_data& data); // 1 void oops_again(widget_id w) { widget_data data; std::thread t(update_data_for_widget,w,data); // 2 display_status(); t.join(); process_widget_data(data); }
虽然update_data_for_widget①的第二个参数期待传入一个引用,但是std::thread
的构造函数②并不知晓;构造函数无视函数期待的参数类型,并盲目的拷贝已提供的变量。不过,在代码会将参数以右值的方式进行拷贝传递,这是为了照顾到那些只能进行移动的类型,而后会以右值为参数调用update_data_for_widget。因为函数期望的是一个非常量引用作为参数,而非一个右值作为参数,所以会在编译时出错。对于熟悉std::bind
的开发者来说,问题的解决办法是显而易见的:可以使用std::ref
将参数转换成引用的形式,从而可将线程的调用改为以下形式:
std::thread t(update_data_for_widget,w,std::ref(data));
在这之后,update_data_for_widget就会接收到一个data变量的引用,而非一个data变量拷贝的引用,这样代码就能顺利的通过编译。
如果你熟悉std::bind
,就应该不会对以上述传参的形式感到奇怪,因为std::thread
构造函数和std::bind
的操作都在标准库中定义好了,可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数:
class X { public: void do_lengthy_work(); }; X my_x; std::thread t(&X::do_lengthy_work,&my_x); // 1
这段代码中,新线程将my_x.do_lengthy_work()作为线程函数;my_x的地址①作为指针对象提供给函数。也可以为成员函数提供参数:std::thread
构造函数的第三个参数就是成员函数的第一个参数,以此类推:
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::unique_ptr
就是这样一种类型,这种类型为动态分配的对象提供内存自动管理机制, 同一时间内,只允许一个std::unique_ptr
实现指向一个给定对象,并且当这个实现销毁时,指向的对象也将被删除。移动构造函数(move constructor)和移动赋值操作符(move assignment operator)允许一个对象在多个std::unique_ptr
实现中传递, 使用”移动”转移原对象后,就会留下一个空指针(NULL)。移动操作可以将对象转换成可接受的类型,例如:函数参数或函数返回的类型。当原对象是一个临时变量时,自动进行移动操作,但当原对象是一个命名变量,那么转移的时候就需要使用std::move()
进行显示移动。下面的代码展示了std::move
的用法,展示了std::move
是如何转移一个动态对象到一个线程中去的:
void process_big_object(std::unique_ptr<big_object>); std::unique_ptr<big_object> p(new big_object); p->prepare_data(42); std::thread t(process_big_object,std::move(p));
std::thread
的构造函数中指定std::move(p)
,big_object对象的所有权就被首先转移到新创建线程的的内部存储中,之后传递给process_big_object函数。