《C++ Concurrency In Action》part2 线程管理
1、线程管理的基础
1.1启动线程
使用C++线程库启动线程,可以归结为构造 std::thread 对象:
void do_some_work(); std::thread my_thread(do_some_work);
为了让编译器识别 std::thread 类,这个简单的例子也要包含 <thread> 头文件。如同大多数C++标准库一样, std::thread 可以用可调用(callable)类型构造,将带有函数调用符类型的实例传入 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
std::thread my_thread([]{ do_something(); do_something_else(); });
如果 std::thread 对象销毁之前还没有做出决定,程序就会终止( std::thread 的析构函数会调用 std::terminate() )。因此,即便是有异常存在,也需要确保线程能够正确的加入(joined)或分离(detached)。后面会介绍对应的方法来处理这两种情况。需要注意的是,必须在 std::thread 对象销毁之前做出决定——加入或分离线程之前。如果线程就已经结束,想再去分离它,线程可能会在 std::thread 对象销毁之后继续运行下去。
如果不等待线程,就必须保证线程结束之前,可访问的数据得有效性。这不是一个新问题——单线程代码中,对象销毁之后再去访问,也会产生未定义行为——不过,线程的生命周期增加了这个问题发生的几率。
这种情况很可能发生在线程还没结束,函数已经退出的时候,这时线程函数还持有函数局部变量的指针或引用。下面的程序中就展示了这样的一种情况。
#include <thread> void do_something(int& i) { ++i; } 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. 不等待线程结束 } // 3. 新线程可能还在运行 int main() { oops(); }
这个例子中,已经决定不等待线程结束(使用了detach()②),所以当oops()函数执行完成时③,新线程中的函数可能还在运行。如果线程还在运行,它就会去调用do_something(i)函数①,这时就会访问已经销毁的变量。
如同一个单线程程序——允许在函数完成后继续持有局部变量的指针或引用;当然,这种情况发生时,错误并不明显,会使多线程更容易出错。
处理这种情况的常规方法:
1)使线程函数的功能齐全,将数据复制到线程中,而非复制到共享数据中。如果使用一个可调用的对象作为线程函数,这个对象就会复制到线程中,而后原始对象就会立即销毁。但对于对象中包含的指针和引用还需谨慎,例如上例所示。使用一个能访问局部变量的函数去创建线程是一个糟糕的主意(除非十分确定线程会在函数完成前结束)。
2)此外,可以通过加入的方式来确保线程在函数完成前结束。
1.2、等待线程完成
如果需要等待线程,相关的 std::tread 实例需要使用join()。上例中, my_thread.detach() 替换为 my_thread.join() ,就可以确保局部变量在线程完成后,才被销毁。
在这种情况下,因为原始线程在其生命周期中并没有做什么事,使得用一个独立的线程去执行函数变得收益甚微,但在实际编程中,原始线程要么有自己的工作要做;要么会启动多个子线程来做一些有用的工作,并等待这些线程结束。
join()是简单粗暴的等待线程完成或不等待。当你需要对等待中的线程有更灵活的控制时,比如,看一下某个线程是否结束,或者只等待一段时间(超过时间就判定为超时)。想要做到这些,你需要使用其他机制来完成,比如条件变量和期待(futures)。
调用join()的行为,还清理了线程相关的存储部分,这样 std::thread 对象将不再与已经完成的线程有任何关联。这意味着,只能对一个线程使用一次join();一旦已经使用过join(), std::thread 对象就不能再次加入了,当对其使用joinable()时,将返回否(false)。
1.3 、特殊情况下的等待
避免应用被抛出的异常所终止,就需要作出一个决定。通常,当倾向于在无异常的情况下使用join()时,需要在异常处理过程中调用join(),从而避免生命周期的问题。
#include <thread> void do_something(int& i) { ++i; } struct func { int& i; func(int& i_):i(i_){} void operator()() { for(unsigned j=0;j<1000000;++j) { do_something(i); } } }; void do_something_in_current_thread() {} 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(); } int main() { f(); }
代码使用了 try/catch 块确保访问本地状态的线程退出后,函数才结束。当函数正常退出时,会执行到②处;当函数执行过程中抛出异常,程序会执行到①处。 try/catch 块能轻易的捕获轻量级错误,所以这种情况,并非放之四海而皆准。如需确保线程在函数之前结束——查看是否因为线程函数使用了局部变量的引用,以及其他原因——而后再确定一下程序可能会退出的途径,无论正常与否,可以提供一个简洁的机制,来做解决这个问题。
#include <thread> 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; }; void do_something(int& i) { ++i; } struct func { int& i; func(int& i_):i(i_){} void operator()() { for(unsigned j=0;j<1000000;++j) { do_something(i); } } }; void do_something_in_current_thread() {} void f() { int some_local_state; func my_func(some_local_state); std::thread t(my_func); thread_guard g(t); do_something_in_current_thread(); } //4 int main() { f(); }
当线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对象g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。
1.4 后台运行线程
通常称分离线程为守护线程(daemon threads),UNIX中守护线程是指,且没有任何用户接口,并在后台运行的线程。这种线程的特点就是长时间运行;线程的生命周期可能会从某一个应用起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。另一方面,分离线程的另一方面只能确定线程什么时候结束,"发后即忘"(fire andforget)的任务就使用到线程的这种方式。
#include <thread> #include <string> void open_document_and_display_gui(std::string const& filename) {} bool done_editing() { return true; } enum command_type{ open_new_document }; struct user_command { command_type type; user_command(): type(open_new_document) {} }; user_command get_user_input() { return user_command(); } std::string get_filename_from_user() { return "foo.doc"; } void process_user_input(user_command const& cmd) {} 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); } } } int main() { edit_document("bar.doc"); }
这个例子也展示了传参启动线程的方法:不仅可以向 std::thread 构造函数①传递函数名,还可以传递函数所需的参数(实参)。当然,也有其他方法完成这项功能,比如:使用一个带有数据成员的成员函数,代替一个需要传参的普通函数。
2 、向线程函数传递参数
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);
3 转移线程所有权
假设要写一个在后台启动线程的函数,想通过新线程返回的所有权去调用这个函数,而不是等待线程结束再去调用;或完全与之相反的想法:创建一个线程,并在函数中转移所有权,都必须要等待线程结束。总之,新线程的所有权都需要转移。void some_function(); void some_other_function(); std::thread t1(some_function); // 1 std::thread t2=std::move(t1); // 2 t1=std::thread(some_other_function); // 3 std::thread t3; // 4 t3=std::move(t2); // 5 t1=std::move(t3); // 6 赋值操作将使程序崩溃
当显式使用 std::move() 创建t2后②,t1的所有权就转移给了t2。之后,t1和执行线程已经没有关联了;执行some_function的函数现在与t2关联。
some_function的线程相关联。
最后一个移动操作,将执行some_function线程的所有权转移⑥给t1。这时,t1已经有了一个关联的线(执行some_other_function的线程),所以这里可以直接调用 std::terminate() 终止程序继续运行。终止操作将调用 std::thread 的析构函数,销毁所有对象(与C++中异常的处理方式很相似)。
#include <thread> void some_function() {} void some_other_function(int) {} std::thread f() { void some_function(); return std::thread(some_function); } std::thread g() { void some_other_function(int); std::thread t(some_other_function,42); return t; } int main() { std::thread t1=f(); t1.join(); std::thread t2=g(); t2.join(); }
当所有权可以在函数内部传递,就允许 std::thread 实例可作为参数进行传递,代码如下:
void f(std::thread t); void g() { void some_function(); f(std::thread(some_function)); std::thread t(some_function); f(std::move(t)); }
std::thread 支持移动的好处是可以创建thread_guard类的实例,并且拥有其线程的所有权。当thread_guard对象所持有的线程已经被引用,移动操作就可以避免很多不必要的麻烦;这意味着,当某个对象转移了线程的所有权后,它就不能对线程进行加入或分离。为了确保线程程序退出前完成,下面的代码里定义了scoped_thread类。现在,我们来
看一下这段代码:
#include <thread> #include <utility> class scoped_thread { std::thread t; public: explicit scoped_thread(std::thread t_):// 1 t(std::move(t_)) { if(!t.joinable())// 2 throw std::logic_error("No thread"); } ~scoped_thread() { t.join();// 3 } scoped_thread(scoped_thread const&)=delete; scoped_thread& operator=(scoped_thread const&)=delete; }; void do_something(int& i) { ++i; } struct func { int& i; func(int& i_):i(i_){} void operator()() { for(unsigned j=0;j<1000000;++j) { do_something(i); } } }; void do_something_in_current_thread() {} void f() { int some_local_state; scoped_thread t(std::thread(func(some_local_state)));// 4 do_something_in_current_thread(); }// 5 int main() { f(); }
这里新线程是直接传递到scoped_thread中④,而非创建一个独立的命名变量。当主线程到达f()函数的末尾时,scoped_thread对象将会销毁,然后加入③到的构造函数①创建的线程对象中去。而在thread_guard类中,就要在析构的时候检查线程是否"可加入"。这里把检查放在了构造函数中②,并且当线程不可加入时,抛出异常。std::thread 对象的容器,如果这个容器是移动敏感的(比如,标准中的 std::vector<> ),那么移动操作同样适用于这些容器。
#include <vector> #include <thread> #include <algorithm> #include <functional> void do_work(unsigned id) {} void f() { std::vector<std::thread> threads; for(unsigned i=0;i<20;++i) { threads.push_back(std::thread(do_work,i));// 产生线程 } std::for_each(threads.begin(),threads.end(), std::mem_fn(&std::thread::join));// 对每个线程调用join() } int main() { f(); }
我们经常需要线程去分割一个算法的总工作量,所以在算法结束的之前,所有的线程必须结束。清单2.7说明线程所做的工作都是独立的,并且结果仅会受到共享数据的影响。如果f()有返回值,这个返回值就依赖于线程得到的结果。在写入返回值之前,程序会检查使用共享数据的线程是否终止。
4 运行时决定线程数量
#include <thread> #include <numeric> #include <algorithm> #include <functional> #include <vector> #include <iostream> template<typename Iterator,typename T> struct accumulate_block { void operator()(Iterator first,Iterator last,T& result) { result=std::accumulate(first,last,result); } }; template<typename Iterator,typename T> T parallel_accumulate(Iterator first,Iterator last,T init) { unsigned long const length=std::distance(first,last); if(!length)// 1 return init; unsigned long const min_per_thread=25; unsigned long const max_threads= (length+min_per_thread-1)/min_per_thread;// 2 unsigned long const hardware_threads= std::thread::hardware_concurrency(); unsigned long const num_threads=// 3 std::min(hardware_threads!=0?hardware_threads:2,max_threads); unsigned long const block_size=length/num_threads;// 4 std::vector<T> results(num_threads); std::vector<std::thread> threads(num_threads-1);// 5 Iterator block_start=first; for(unsigned long i=0;i<(num_threads-1);++i) { Iterator block_end=block_start; std::advance(block_end,block_size);// 6 threads[i]=std::thread(// 7 accumulate_block<Iterator,T>(), block_start,block_end,std::ref(results[i])); block_start=block_end;// 8 } accumulate_block<Iterator,T>()(block_start,last,results[num_threads-1]);// 9 std::for_each(threads.begin(),threads.end(), std::mem_fn(&std::thread::join));// 10 return std::accumulate(results.begin(),results.end(),init);// 11 } int main() { std::vector<int> vi; for(int i=0;i<10;++i) { vi.push_back(10); } int sum=parallel_accumulate(vi.begin(),vi.end(),5); std::cout<<"sum="<<sum<<std::endl; }
如果输入的范围为空①,就会得到init的值。反之,如果范围内多于一个元素时,都需要用范围内元素的总数量除以线程(块)中最小任务数,从而确定启动线程的最大数量②,这样能避免无谓的计算资源的浪费。比如,一台32芯的机器上,只有5个数需要计算,却启动了32个线程。
结束这个例子之前,需要明确:T类型的加法运算不满足结合律(比如,对于float型或double型,在进行加法操作时,系统很可能会做截断操作),因为对范围中元素的分组,会导致parallel_accumulate得到的结果可能与std::accumulate 得到的结果不同。同样的,这里对迭代器的要求更加严格:必须都是向前迭代器(forward iterator),而 std::accumulate 可以在只传入迭代器(input iterators)的情况下工作。对于创建出results容器,需要保证T有默认构造函数。对于算法并行,通常都要这样的修改;不过,需要根据算法本身的特性,选择不同的并行方式。算法并行会在第8章有更加深入的讨论。需要注意的:因为不能直接从一个线程中返回一个值,所以需要传递results容器的引用到线程中去。另一个办法,通过地址来获取线程执行的结果;下下节,我们将使用期望(futures)完成这种方案。
当线程运行时,所有必要的信息都需要传入到线程中去,包括存储计算结果的位置。不过,并非总需如此:有时候这是识别线程的可行方案,可以传递一个标识数。不过,当需要标识的函数在调用栈的深层,同时其他线程也可调用该函数,那么标识数就会变的捉襟见肘。好消息是在设计C++的线程库时,就有预见了这种情况,在之后的实现中就
给每个线程附加了唯一标识符。
5、识别线程
std::thread::id 对象可以自由的拷贝和对比,因为标识符就可以复用。如果两个对象的 std::thread::id 相等,那它们就是同一个线程,或者都“没有线程”。如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有。