《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 相等,那它们就是同一个线程,或者都“没有线程”。如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有。