第1章 你好,C++的并发世界!
开始入门
一个C++多线程程序是什么样子呢?通常是变量、类以及函数的组合。唯一的区别在于某些函数可以并发运行,所以需要确保共享数据在并发访问时是安全的。当然,为了并发地运行函数,必须使用特定的函数以及对象来管理各个线程。
一个非常简单的在单线程中运行的Hello World程序如下所示,当我们谈到多线程时,它可以作为一个基准。1
2
3
4
5
int main()
{
std::cout << "Hello World\n";
}
这个程序所做的就是将“Hello World”写进标准输出流。让我们将它与下面清单所示的简单的“Hello, Concurrent World”程序做个比较,它启动了一个独立的线程来显示这个信息。
1 |
|
第一个区别是增加了#include <thread>①
,标准C++库中对多线程支持的声明在新的头文件中:管理线程的函数和类在<thread>
中声明,而保护共享数据的函数和类在其他头文件中声明。
其次,打印信息的代码被移动到了一个独立的函数中②。因为每个线程都必须具有一个初始函数(initial function),新线程的执行从这里开始。对于应用程序来说,初始线程是main()
,但是对于其他线程,可以在std::thread
对象的构造函数中指定——本例中,被命名为t③
的std::thread
对象拥有新函数hello()
作为其初始函数。
下一个区别:与直接写入标准输出或是从main()
调用hello()
不同,该程序启动了一个全新的线程来实现,将线程数量一分为二——初始线程始于main()
,而新线程始于hello()
。
新的线程启动之后③,初始线程继续执行。如果它不等待新线程结束,它就将自顾自地继续运行到main()
的结束,从而结束程序——有可能发生在新线程运行之前。这就是为什么在④这里调用join()
的原因——详见第2章,这会导致调用线程(在main()
中)等待与std::thread
对象相关联的线程,即这个例子中的t。
线程管理
C++标准库中只需要管理std::thread关联的线程,无需把注意力放在其他方面。不过,标准库太灵活,所以管理起来不会太容易。
本章将从基本开始:启动一个线程,等待这个线程结束,或放在后台运行。再看看怎么给已经启动的线程函数传递参数,以及怎么将一个线程的所有权从当前std::thread对象移交给另一个。最后,再来确定线程数,以及识别特殊线程。
线程管理的基础
每个程序至少有一个线程:执行main()
函数的线程,其余线程有其各自的入口函数。线程与原始线程(以main()
为入口函数的线程)同时运行。如同main()
函数执行完会退出一样,当线程执行完入口函数后,线程也会退出。在为一个线程创建了一个std::thread
对象后,需要等待这个线程结束;不过,线程需要先进行启动。下面就来启动线程。
启动线程
最简单的情况下,任务也会很简单,通常是无参数无返回的函数。这种函数在其所属线程上运行,直到函数执行完毕,线程也就结束了。在一些极端情况下,线程运行时,任务中的函数对象需要通过某种通讯机制进行参数的传递,或者执行一系列独立操作;可以通过通讯机制传递信号,让线程停止。线程要做什么,以及什么时候启动,其实都无关紧要。总之,使用C++线程库启动线程,可以归结为构造std::thread
对象:1
2void do_some_work();
std::thread my_thread(do_some_work);
为了让编译器识别std::thread
类,这个简单的例子也要包含<thread>
头文件。如同大多数C++标准库一样,std::thread
可以用可调用类型构造,将带有函数调用符类型的实例传入std::thread
类中,替换默认的构造函数。1
2
3
4
5
6
7
8
9
10
11class background_task
{
public:
void operator()() const
{
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);
代码中,提供的函数对象会复制到新线程的存储空间当中,函数对象的执行和调用都在线程的内存空间中进行。函数对象的副本应与原始函数对象保持一致,否则得到的结果会与我们的期望不同。
有件事需要注意,当把函数对象传入到线程构造函数中时,需要避免“最令人头痛的语法解析”。如果你传递了一个临时变量,而不是一个命名的变量;C++编译器会将其解析为函数声明,而不是类型对象的定义。
例如:1
std::thread my_thread(background_task());
这里相当与声明了一个名为my_thread
的函数,这个函数带有一个参数(函数指针指向没有参数并返回background_task
对象的函数),返回一个std::thread
对象的函数,而非启动了一个线程。
使用在前面命名函数对象的方式,或使用多组括号①,或使用新统一的初始化语法②,可以避免这个问题。
如下所示:1
2std::thread my_thread((background_task())); // 1
std::thread my_thread{background_task()}; // 2
使用lambda表达式也能避免这个问题。lambda表达式是C++11的一个新特性,它允许使用一个可以捕获局部变量的局部函数。之前的例子可以改写为lambda表达式的类型:1
2
3
4std::thread my_thread([]{
do_something();
do_something_else();
});
启动了线程,你需要明确是要等待线程结束,还是让其自主运行。如果std::thread
对象销毁之前还没有做出决定,程序就会终止(std::thread
的析构函数会调用std::terminate()
)。因此,即便是有异常存在,也需要确保线程能够正确的加入(joined)或分离(detached)。需要注意的是,必须在std::thread
对象销毁之前做出决定,否则你的程序将会终止(std::thread
的析构函数会调用std::terminate()
,这时再去决定会触发相应异常)。
如果不等待线程,就必须保证线程结束之前,可访问的数据得有效性。这不是一个新问题——单线程代码中,对象销毁之后再去访问,也会产生未定义行为——不过,线程的生命周期增加了这个问题发生的几率。
这种情况很可能发生在线程还没结束,函数已经退出的时候,这时线程函数还持有函数局部变量的指针或引用。下面的清单中就展示了这样的一种情况。
1 | struct func |
这个例子中,已经决定不等待线程结束(使用了detach()②
),所以当oops()
函数执行完成时③,新线程中的函数可能还在运行。如果线程还在运行,它就会去调用do_something(i)
函数①,这时就会访问已经销毁的变量。如同一个单线程程序——允许在函数完成后继续持有局部变量的指针或引用;当然,这从来就不是一个好主意——这种情况发生时,错误并不明显,会使多线程更容易出错。
处理这种情况的常规方法:使线程函数的功能齐全,将数据复制到线程中,而非复制到共享数据中。如果使用一个可调用的对象作为线程函数,这个对象就会复制到线程中,而后原始对象就会立即销毁。但对于对象中包含的指针和引用还需谨慎。使用一个能访问局部变量的函数去创建线程是一个糟糕的主意(除非十分确定线程会在函数完成前结束)。此外,可以通过join()
函数来确保线程在函数完成前结束。
等待线程完成
如果需要等待线程,相关的std::thread
实例需要使用join()
。清单2.1中,将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()
,从而避免生命周期的问题。下面的程序清单是一个例子。
1 | struct func; // 定义在清单2.1中 |
清单2.2中的代码使用了try/catch块确保访问本地状态的线程退出后,函数才结束。当函数正常退出时,会执行到②处;当函数执行过程中抛出异常,程序会执行到①处。try/catch块能轻易的捕获轻量级错误,所以这种情况,并非放之四海而皆准。如需确保线程在函数之前结束——查看是否因为线程函数使用了局部变量的引用,以及其他原因——而后再确定一下程序可能会退出的途径,无论正常与否,可以提供一个简洁的机制,来做解决这个问题。
一种方式是使用“资源获取即初始化方式”(RAII,Resource Acquisition Is Initialization),并且提供一个类,在析构函数中使用join()
,如同下面清单中的代码。看它如何简化f()
函数。
1 | class thread_guard |
当线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard
对象g
是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread
抛出一个异常,这个销毁依旧会发生。
在thread_guard
的析构函数的测试中,首先判断线程是否已加入①,如果没有会调用join()
②进行加入。这很重要,因为join()
只能对给定的对象调用一次,所以对给已加入的线程再次进行加入操作时,将会导致错误。
拷贝构造函数和拷贝赋值操作被标记为=delete
③,是为了不让编译器自动生成它们。直接对一个对象进行拷贝或赋值是危险的,因为这可能会弄丢已经加入的线程。通过删除声明,任何尝试给thread_guard
对象赋值的操作都会引发一个编译错误。
如果不想等待线程结束,可以分离(detaching)线程,从而避免异常安全*(exception-safety)问题。不过,这就打破了线程与std::thread
对象的联系,即使线程仍然在后台运行着,分离操作也能确保std::terminate()
在std::thread
对象销毁才被调用。
后台运行线程
使用detach()
会让线程在后台运行,这就意味着主线程不能与之产生直接交互。也就是说,不会等待这个线程结束;如果线程分离,那么就不可能有std::thread
对象能引用它,分离线程的确在后台运行,所以分离线程不能被加入。不过C++运行库保证,当线程退出时,相关资源的能够正确回收,后台线程的归属和控制C++运行库都会处理。
通常称分离线程为守护线程(daemon threads),UNIX中守护线程是指,没有任何显式的用户接口,并在后台运行的线程。这种线程的特点就是长时间运行;线程的生命周期可能会从某一个应用起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。另一方面,分离线程的另一方面只能确定线程什么时候结束,发后即忘(fire and forget)的任务就使用到线程的这种方式。
如2.1.2节所示,调用std::thread
成员函数detach()
来分离一个线程。之后,相应的std::thread
对象就与实际执行的线程无关了,并且这个线程也无法加入:1
2
3std::thread t(do_background_work);
t.detach();
assert(!t.joinable());
为了从std::thread
对象中分离线程(前提是有可进行分离的线程),不能对没有执行线程的std::thread
对象使用detach()
,也是join()
的使用条件,并且要用同样的方式进行检查——当std::thread
对象使用t.joinable()
返回的是true,就可以使用t.detach()
。
向线程函数传递参数
清单2.4中,向std::thread
构造函数中的可调用对象,或函数传递一个参数很简单。需要注意的是,默认参数要拷贝到线程独立内存中,即使参数是引用的形式,也可以在新线程中进行访问。再来看一个例子:1
2void f(int i, std::string const& s);
std::thread t(f, 3, "hello");
代码创建了一个调用f(3, “hello”)
的线程。注意,函数f
需要一个std::string
对象作为第二个参数,但这里使用的是字符串的字面值,也就是char const *
类型。之后,在线程的上下文中完成字面值向std::string
对象的转化。需要特别要注意,当指向动态变量的指针作为参数传递给线程的情况,代码如下:1
2
3
4
5
6
7
8void 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
对象:1
2
3
4
5
6
7
8void 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();
}
还可能遇到相反的情况:期望传递一个引用,但整个对象被复制了。当线程更新一个引用传递的数据结构时,这种情况就可能发生,比如:1
2
3
4
5
6
7
8
9void 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); // 3
}
虽然update_data_for_widget
①的第二个参数期待传入一个引用,但是std::thread
的构造函数②并不知晓;构造函数无视函数期待的参数类型,并盲目的拷贝已提供的变量。当线程调用update_data_for_widget
函数时,传递给函数的参数是data
变量内部拷贝的引用,而非数据本身的引用。因此,当线程结束时,内部拷贝数据将会在数据更新阶段被销毁,且process_widget_data
将会接收到没有修改的data变量③。可以使用std::ref
将参数转换成引用的形式,从而可将线程的调用改为以下形式:1
std::thread t(update_data_for_widget,w,std::ref(data));
在这之后,update_data_for_widget
就会接收到一个data
变量的引用,而非一个data
变量拷贝的引用。
如果你熟悉std::bind
,就应该不会对以上述传参的形式感到奇怪,因为std::thread
构造函数和std::bind
的操作都在标准库中定义好了,可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数:1
2
3
4
5
6
7class 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
构造函数的第三个参数就是成员函数的第一个参数,以此类推(代码如下,译者自加)。1
2
3
4
5
6
7
8class 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
是如何转移一个动态对象到一个线程中去的:1
2
3
4void 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
函数。
标准线程库中和std::unique_ptr
在所属权上有相似语义类型的类有好几种,std::thread
为其中之一。虽然,std::thread
实例不像std::unique_ptr
那样能占有一个动态对象的所有权,但是它能占有其他资源:每个实例都负责管理一个执行线程。执行线程的所有权可以在多个std::thread
实例中互相转移,这是依赖于std::thread
实例的可移动且不可复制性。不可复制保性证了在同一时间点,一个std::thread
实例只能关联一个执行线程;可移动性使得程序员可以自己决定,哪个实例拥有实际执行线程的所有权。
转移线程所有权
假设要写一个在后台启动线程的函数,想通过新线程返回的所有权去调用这个函数,而不是等待线程结束再去调用;或完全与之相反的想法:创建一个线程,并在函数中转移所有权,都必须要等待线程结束。总之,新线程的所有权都需要转移。
这就是移动引入std::thread
的原因,C++标准库中有很多资源占有(resource-owning)类型,比如std::ifstream
,std::unique_ptr
还有std::thread
都是可移动,但不可拷贝。这就说明执行线程的所有权可以在std::thread
实例中移动,下面将展示一个例子。例子中,创建了两个执行线程,并且在std::thread
实例之间(t1,t2和t3)转移所有权:1
2
3
4
5
6
7
8void 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关联。
然后,与一个临时std::thread
对象相关的线程启动了③。为什么不显式调用std::move()
转移所有权呢?因为,所有者是一个临时对象——移动操作将会隐式的调用。
t3使用默认构造方式创建④,与任何执行线程都没有关联。调用std::move()
将与t2关联线程的所有权转移到t3中⑤。因为t2是一个命名对象,需要显式的调用std::move()
。移动操作⑤完成后,t1与执行some_other_function
的线程相关联,t2与任何线程都无关联,t3与执行some_function
的线程相关联。
最后一个移动操作,将some_function
线程的所有权转移⑥给t1。不过,t1已经有了一个关联的线程(执行some_other_function的线程),所以这里系统直接调用std::terminate()
终止程序继续运行。这样做(不抛出异常,std::terminate()
是noexcept函数)是为了保证与std::thread
的析构函数的行为一致。2.1.1节中,需要在线程对象被析构前,显式的等待线程完成,或者分离它;进行赋值时也需要满足这些条件。
std::thread
支持移动,就意味着线程的所有权可以在函数外进行转移,就如下面程序一样。
1 | std::thread f() |
当所有权可以在函数内部传递,就允许std::thread
实例可作为参数进行传递,代码如下:1
2
3
4
5
6
7
8void 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
类。现在,我们来看一下这段代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class 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;
};
struct func; // 定义在清单2.1中
void f()
{
int some_local_state;
scoped_thread t(std::thread(func(some_local_state))); // 4
do_something_in_current_thread();
} // 5
与清单2.3相似,不过这里新线程是直接传递到scoped_thread中④,而非创建一个独立的命名变量。当主线程到达f()
函数的末尾时,scoped_thread对象将会销毁,然后加入③到的构造函数①创建的线程对象中去。而在清单2.3中的thread_guard类,就要在析构的时候检查线程是否”可加入”。这里把检查放在了构造函数中②,并且当线程不可加入时,抛出异常。
std::thread
对象的容器,如果这个容器是移动敏感的(比如,标准中的std::vector<>
),那么移动操作同样适用于这些容器。了解这些后,就可以写出类似清单2.7中的代码,代码量产了一些线程,并且等待它们结束。
1 | void do_work(unsigned id); |
我们经常需要线程去分割一个算法的总工作量,所以在算法结束的之前,所有的线程必须结束。清单2.7说明线程所做的工作都是独立的,并且结果仅会受到共享数据的影响。如果f()有返回值,这个返回值就依赖于线程得到的结果。在写入返回值之前,程序会检查使用共享数据的线程是否终止。
将std::thread
放入std::vector
是向线程自动化管理迈出的第一步:并非为这些线程创建独立的变量,并且将他们直接加入,可以把它们当做一个组。创建一组线程(数量在运行时确定),可使得这一步迈的更大,而非像清单2.7那样创建固定数量的线程。
运行时决定线程数量
std::thread::hardware_concurrency()
在新版C++标准库中是一个很有用的函数。这个函数将返回能同时并发在一个程序中的线程数量。例如,多核系统中,返回值可以是CPU核芯的数量。返回值也仅仅是一个提示,当系统信息无法获取时,函数也会返回0。但是,这也无法掩盖这个函数对启动线程数量的帮助。
清单2.8实现了一个并行版的std::accumulate
。代码中将整体工作拆分成小任务交给每个线程去做,其中设置最小任务数,是为了避免产生太多的线程。程序可能会在操作数量为0的时候抛出异常。比如,std::thread
构造函数无法启动一个执行线程,就会抛出一个异常。
1 | template<typename Iterator,typename T> |
函数看起来很长,但不复杂。如果输入的范围为空①,就会得到init的值。反之,如果范围内多于一个元素时,都需要用范围内元素的总数量除以线程(块)中最小任务数,从而确定启动线程的最大数量②,这样能避免无谓的计算资源的浪费。比如,一台32芯的机器上,只有5个数需要计算,却启动了32个线程。
计算量的最大值和硬件支持线程数中,较小的值为启动线程的数量③。因为上下文频繁的切换会降低线程的性能,所以你肯定不想启动的线程数多于硬件支持的线程数量。当std::thread::hardware_concurrency()
返回0,你可以选择一个合适的数作为你的选择;在本例中,我选择了”2”。你也不想在一台单核机器上启动太多的线程,因为这样反而会降低性能,有可能最终让你放弃使用并发。
每个线程中处理的元素数量,是范围中元素的总量除以线程的个数得出的④。对于分配是否得当,我们会在后面讨论。
现在,确定了线程个数,通过创建一个std::vector<T>
容器存放中间结果,并为线程创建一个std::vector<std::thread>
容器⑤。这里需要注意的是,启动的线程数必须比num_threads
少1个,因为在启动之前已经有了一个线程(主线程)。
使用简单的循环来启动线程:block_end
迭代器指向当前块的末尾⑥,并启动一个新线程为当前块累加结果⑦。当迭代器指向当前块的末尾时,启动下一个块⑧。
启动所有线程后,⑨中的线程会处理最终块的结果。对于分配不均,因为知道最终块是哪一个,那么这个块中有多少个元素就无所谓了。
当累加最终块的结果后,可以等待std::for_each
⑩创建线程的完成,之后使用std::accumulate
将所有结果进行累加⑪。
结束这个例子之前,需要明确:T类型的加法运算不满足结合律(比如,对于float型或double型,在进行加法操作时,系统很可能会做截断操作),因为对范围中元素的分组,会导致parallel_accumulate
得到的结果可能与std::accumulate
得到的结果不同。同样的,这里对迭代器的要求更加严格:必须都是向前迭代器,而std::accumulate
可以在只传入迭代器的情况下工作。对于创建出results
容器,需要保证T有默认构造函数。对于算法并行,通常都要这样的修改;不过,需要根据算法本身的特性,选择不同的并行方式。需要注意的:因为不能直接从一个线程中返回一个值,所以需要传递results容器的引用到线程中去。
当线程运行时,所有必要的信息都需要传入到线程中去,包括存储计算结果的位置。不过,并非总需如此:有时候这是识别线程的可行方案,可以传递一个标识数,例如清单2.7中的i。不过,当需要标识的函数在调用栈的深层,同时其他线程也可调用该函数,那么标识数就会变的捉襟见肘。好消息是在设计C++的线程库时,就有预见了这种情况,在之后的实现中就给每个线程附加了唯一标识符。
识别线程
线程标识类型是std::thread::id
,可以通过两种方式进行检索。第一种,可以通过调用std::thread
对象的成员函数get_id()
来直接获取。如果std::thread
对象没有与任何执行线程相关联,get_id()
将返回std::thread::type
默认构造值,这个值表示“没有线程”。第二种,当前线程中调用std::this_thread::get_id()
(这个函数定义在<thread>
头文件中)也可以获得线程标识。
std::thread::id
对象可以自由的拷贝和对比,因为标识符就可以复用。如果两个对象的std::thread::id
相等,那它们就是同一个线程,或者都“没有线程”。如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有。
线程库不会限制你去检查线程标识是否一样,std::thread::id
类型对象提供相当丰富的对比操作;比如,提供为不同的值进行排序。这意味着允许程序员将其当做为容器的键值,做排序,或做其他方式的比较。按默认顺序比较不同值的std::thread::id
,所以这个行为可预见的:当a<b
,b<c
时,得a<c
,等等。标准库也提供std::hash<std::thread::id>
容器,所以std::thread::id
也可以作为无序容器的键值。
std::thread::id
实例常用作检测线程是否需要进行一些操作,比如:当用线程来分割一项工作,主线程可能要做一些与其他线程不同的工作。这种情况下,启动其他线程前,它可以将自己的线程ID通过std::this_thread::get_id()
得到,并进行存储。就是算法核心部分(所有线程都一样的),每个线程都要检查一下,其拥有的线程ID是否与初始线程的ID相同。1
2
3
4
5
6
7
8
9std::thread::id master_thread;
void some_core_part_of_algorithm()
{
if(std::this_thread::get_id()==master_thread)
{
do_master_thread_work();
}
do_common_work();
}
另外,当前线程的std::thread::id
将存储到一个数据结构中。之后在这个结构体中对当前线程的ID与存储的线程ID做对比,来决定操作是被“允许”,还是“需要”(permitted/required)。
同样,作为线程和本地存储不适配的替代方案,线程ID在容器中可作为键值。例如,容器可以存储其掌控下每个线程的信息,或在多个线程中互传信息。
std::thread::id
可以作为一个线程的通用标识符,当标识符只与语义相关(比如,数组的索引)时,就需要这个方案了。也可以使用输出流(std::cout
)来记录一个std::thread::id
对象的值。1
std::cout<<std::this_thread::get_id();
具体的输出结果是严格依赖于具体实现的,C++标准的唯一要求就是要保证ID比较结果相等的线程,必须有相同的输出。
线程间共享数据
共享数据带来的问题
线程间潜在问题就是修改共享数据,致使不变量遭到破坏。当不做些事来确保在这个过程中不会有其他线程进行访问的话,可能就有线程访问到刚刚删除一边的节点;这样的话,线程就读取到要删除节点的数据(因为只有一边的连接被修改,如图3.1(b)),所以不变量就被破坏。破坏不变量的后果是多样,当其他线程按从左往右的顺序来访问列表时,它将跳过被删除的节点。在一方面,如有第二个线程尝试删除图中右边的节点,那么可能会让数据结构产生永久性的损坏,使程序崩溃。无论结果如何,都是并行代码常见错误:条件竞争。
条件竞争
并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。
恶性条件竞争通常发生于完成对多于一个的数据块的修改时。因为操作要访问两个独立的数据块,独立的指令将会对数据块将进行修改,并且其中一个线程可能正在进行时,另一个线程就对数据块进行了访问。当系统负载增加时,随着执行数量的增加,执行序列的问题复现的概率也在增加,这样的问题只可能会出现在负载比较大的情况下。
避免恶性条件竞争
这里提供一些方法来解决恶性条件竞争,最简单的办法就是对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。
另一个选择是对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程。
另一种处理条件竞争的方式是,使用事务的方式去处理数据结构的更新。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”。
使用互斥量保护共享数据
当访问共享数据前,使用互斥量将相关数据锁住,再当访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程能看到共享数据,而不破坏不变量。
C++中使用互斥量
C++中通过实例化std::mutex
创建互斥量,通过调用成员函数lock()
进行上锁,unlock()
进行解锁。C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard
,其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。std::mutex
和std::lock_guard
都在<mutex>
头文件中声明。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::list<int> some_list; // 1
std::mutex some_mutex; // 2
void add_to_list(int new_value)
{
std::lock_guard<std::mutex> guard(some_mutex); // 3
some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
std::lock_guard<std::mutex> guard(some_mutex); // 4
return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end();
}
add_to_list()
③和list_contains()
④函数中使用std::lock_guard<std::mutex>
,使得这两个函数中对数据的访问是互斥的:list_contains()
不可能看到正在被add_to_list()
修改的列表。
互斥量和要保护的数据,在类中都需要定义为private成员,这会让访问数据的代码变的清晰,并且容易看出在什么时候对互斥量上锁。当所有成员函数都会在调用时对数据上锁,结束时对数据解锁,那么就保证了数据访问时不变量不被破坏。
精心组织代码来保护共享数据
使用互斥量来保护数据,并不是仅仅在每一个成员函数中都加入一个std::lock_guard
对象那么简单。在确保成员函数不会传出指针或引用的同时,检查成员函数是否通过指针或引用的方式来调用也是很重要的(尤其是这个操作不在你的控制下时)。函数可能没在互斥量保护的区域内,存储着指针或者引用,这样就很危险。更危险的是:将保护数据作为一个运行时参数,如同下面清单中所示那样。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32class some_data
{
int a;
std::string b;
public:
void do_something();
};
class data_wrapper
{
private:
some_data data;
std::mutex m;
public:
template<typename Function>
void process_data(Function func)
{
std::lock_guard<std::mutex> l(m);
func(data); // 1 传递“保护”数据给用户函数
}
};
some_data* unprotected;
void malicious_function(some_data& protected_data)
{
unprotected=&protected_data;
}
data_wrapper x;
void foo()
{
x.process_data(malicious_function); // 2 传递一个恶意函数
unprotected->do_something(); // 3 在无保护的情况下访问保护数据
}
例子中process_data看起来没有任何问题,std::lock_guard
对数据做了很好的保护,但调用用户提供的函数func
①,就意味着foo能够绕过保护机制将函数malicious_function
传递进去②,在没有锁定互斥量的情况下调用do_something()
。
这段代码的问题在于根本没有保护,只是将所有可访问的数据结构代码标记为互斥。函数foo()
中调用unprotected->do_something()
的代码未能被标记为互斥。这种情况下,C++线程库无法提供任何帮助,只能由程序员来使用正确的互斥锁来保护数据。
发现接口内在的条件竞争
尽管链表的个别操作是安全的,但不意味着你就能走出困境;即使在一个很简单的接口中,依旧可能遇到条件竞争。例如,构建一个类似于std::stack
结构的栈,除了构造函数和swap()
以外,需要对std::stack
提供五个操作:push()
一个新元素进栈,pop()
一个元素出栈,top()
查看栈顶元素,empty()
判断栈是否是空栈,size()
了解栈中有多少个元素。即使修改了top()
,使其返回一个拷贝而非引用,对内部数据使用一个互斥量进行保护,不过这个接口仍存在条件竞争。这个问题不仅存在于基于互斥量实现的接口中,在无锁实现的接口中,条件竞争依旧会产生。这是接口的问题,与其实现方式无关。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19template<typename T,typename Container=std::deque<T> >
class stack
{
public:
explicit stack(const Container&);
explicit stack(Container&& = Container());
template <class Alloc> explicit stack(const Alloc&);
template <class Alloc> stack(const Container&, const Alloc&);
template <class Alloc> stack(Container&&, const Alloc&);
template <class Alloc> stack(stack&&, const Alloc&);
bool empty() const;
size_t size() const;
T& top();
T const& top() const;
void push(T const&);
void push(T&&);
void pop();
void swap(stack&&);
};
虽然empty()
和size()
可能在被调用并返回时是正确的,但其的结果是不可靠的;当它们返回后,其他线程就可以自由地访问栈,并且可能push()
多个新元素到栈中,也可能pop()
一些已在栈中的元素。这样的话,之前从empty()
和size()
得到的结果就有问题了。
特别地,当栈实例是非共享的,如果栈非空,使用empty()
检查再调用top()
访问栈顶部的元素是安全的。如下代码所示:1
2
3
4
5
6stack<int> s;
if (! s.empty()){ // 1
int const value = s.top(); // 2
s.pop(); // 3
do_something(value);
}
对于共享的栈对象,这样的调用顺序就不再安全了,因为在调用empty()①和调用top()②之间,可能有来自另一个线程的pop()调用并删除了最后一个元素。这是一个经典的条件竞争,使用互斥量对栈内部数据进行保护,但依旧不能阻止条件竞争的发生,这就是接口固有的问题。
当仔细的观察过之前的代码段,就会发现另一个潜在的条件竞争在调用top()②和pop()③之间。假设两个线程运行着前面的代码,并且都引用同一个栈对象s。这并非罕见的情况,当为性能而使用线程时,多个线程在不同的数据上执行相同的操作是很平常的,并且共享同一个栈可以将工作分摊给它们。假设,一开始栈中只有两个元素,这时任一线程上的empty()和top()都存在竞争,只需要考虑可能的执行顺序即可。
当栈被一个内部互斥量所保护时,只有一个线程可以调用栈的成员函数,所以调用可以很好地交错,并且do_something()
是可以并发运行的。
当线程运行时,调用两次top(),栈没被修改,所以每个线程能得到同样的值。不仅是这样,在调用top()函数调用的过程中(两次),pop()函数都没有被调用。这样,在其中一个值再读取的时候,虽然不会出现“写后读”的情况,但其值已被处理了两次。这种条件竞争,比未定义的empty()/top()竞争更加严重;虽然其结果依赖于do_something()的结果,但因为看起来没有任何错误,就会让这个Bug很难定位。
这就需要接口设计上有较大的改动,提议之一就是使用同一互斥量来保护top()和pop()。
pop()操作分为两部分:先获取顶部元素(top()),然后从栈中移除(pop())。这样,在不能安全的将元素拷贝出去的情况下,栈中的这个数据还依旧存在,没有丢失。这样的分割却制造了本想避免或消除的条件竞争。幸运的是,我们还有的别的选项,但是使用这些选项是要付出代价的。
选项1: 传入一个引用。第一个选项是将变量的引用作为参数,传入pop()函数中获取想要的“弹出值”:1
2std::vector<int> result;
some_stack.pop(result);
大多数情况下,这种方式还不错,但有明显的缺点:需要构造出一个栈中类型的实例,用于接收目标值。
选项2:无异常抛出的拷贝构造函数或移动构造函数。对于有返回值的pop()函数来说,只有“异常安全”方面的担忧。很多类型都有拷贝构造函数,它们不会抛出异常,并且随着新标准中对“右值引用”的支持,很多类型都将会有一个移动构造函数,即使他们和拷贝构造函数做着相同的事情,它也不会抛出异常。
选项3:返回指向弹出值的指针。指针的优势是自由拷贝,并且不会产生异常。对于选择这个方案的接口,使用std::shared_ptr
是个不错的选择;不仅能避免内存泄露(因为当对象中指针销毁时,对象也会被销毁),而且标准库能够完全控制内存分配方案,也就不需要new和delete操作。
一个接口没有条件竞争的堆栈类定义,它实现了选项1和选项3:重载了pop()
,使用一个局部引用去存储弹出值,并返回一个std::shared_ptr<>
对象。它有一个简单的接口,只有两个函数:push()
和pop()
;
1 |
|
削减接口可以获得最大程度的安全,甚至限制对栈的一些操作。栈是不能直接赋值的,因为赋值操作已经删除了①,并且这里没有swap()
函数。栈可以拷贝的,假设栈中的元素可以拷贝。当栈为空时,pop()
函数会抛出一个empty_stack异常,所以在empty()
函数被调用后,其他部件还能正常工作。如选项3描述的那样,使用std::shared_ptr
可以避免内存分配管理的问题,并避免多次使用new和delete操作。
下面的代码将展示一个简单的实现——封装std::stack<>的线程安全堆栈。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
struct empty_stack: std::exception
{
const char* what() const throw() {
return "empty stack!";
};
};
template<typename T>
class threadsafe_stack
{
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack()
: data(std::stack<T>()){}
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data = other.data; // 1 在构造函数体中的执行拷贝
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(new_value);
}
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack(); // 在调用pop前,检查栈是否为空
std::shared_ptr<T> const res(std::make_shared<T>(data.top())); // 在修改堆栈前,分配出返回值
data.pop();
return res;
}
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();
value=data.top();
data.pop();
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
使用多个互斥量保护所有的数据,细粒度锁也有问题。如前所述,当增大互斥量覆盖数据的粒度时,只需要锁住一个互斥量。但是,这种方案并非放之四海皆准,比如:互斥量正在保护一个独立类的实例;这种情况下,锁的状态的下一个阶段,不是离开锁定区域将锁定区域还给用户,就是有独立的互斥量去保护这个类的全部实例。当然,这两种方式都不理想。
一个给定操作需要两个或两个以上的互斥量时,另一个潜在的问题将出现:死锁。与条件竞争完全相反——不同的两个线程会互相等待,从而什么都没做。
死锁:问题描述及解决方案
避免死锁的一般建议,就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁。不过,选择一个固定的顺序(例如,实例提供的第一互斥量作为第一个参数,提供的第二个互斥量为第二个参数),可能会适得其反:在参数交换了之后,两个线程试图在相同的两个实例间进行数据交换时,程序又死锁了!
很幸运,C++标准库有办法解决这个问题,std::lock
——可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 这里的std::lock()需要包含<mutex>头文件
class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);
class X
{
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd):some_detail(sd){}
friend void swap(X& lhs, X& rhs)
{
if(&lhs==&rhs)
return;
std::lock(lhs.m,rhs.m); // 1
std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock); // 2
std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock); // 3
swap(lhs.some_detail,rhs.some_detail);
}
};
首先,检查参数是否是不同的实例,因为操作试图获取std::mutex
对象上的锁,所以当其被获取时,结果很难预料。然后,调用std::lock()
①锁住两个互斥量,并且两个std:lock_guard
实例已经创建好②③。提供std::adopt_lock
参数除了表示std::lock_guard
对象可获取锁之外,还将锁交由std::lock_guard
对象管理,而不需要std::lock_guard
对象再去构建新的锁。
这样,就能保证在大多数情况下,函数退出时互斥量能被正确的解锁(保护操作可能会抛出一个异常),也允许使用一个简单的“return”作为返回。还有,需要注意的是,当使用std::lock
去锁lhs.m
或rhs.m
时,可能会抛出异常;这种情况下,异常会传播到std::lock
之外。当std::lock
成功的获取一个互斥量上的锁,并且当其尝试从另一个互斥量上再获取锁时,就会有异常抛出,第一个锁也会随着异常的产生而自动释放,所以std::lock
要么将两个锁都锁住,要不一个都不锁。
虽然std::lock
可以在这情况下(获取两个以上的锁)避免死锁,但它没办法帮助你获取其中一个锁。
避免死锁的进阶指导
避免嵌套锁
一个线程已获得一个锁时,再别去获取第二个。即使互斥锁造成死锁的最常见原因,也可能会在其他方面受到死锁的困扰(比如:线程间的互相等待)。当你需要获取多个锁,使用一个std::lock
来做这件事(对获取锁的操作上锁),避免产生死锁。
避免在持有锁时调用用户提供的代码
你在持有锁的情况下,调用用户提供的代码;如果用户代码要获取一个锁,就会违反第一个指导意见,并造成死锁(有时,这是无法避免的)。
使用固定顺序获取锁
当硬性条件要求你获取两个以上(包括两个)的锁,并且不能使用std::lock
单独操作来获取它们;那么最好在每个线程上,用固定的顺序获取它们获取它们(锁)。
使用锁的层次结构
虽然,这对于定义锁的顺序,的确是一个特殊的情况,但锁的层次的意义在于提供对运行时约定是否被坚持的检查。这个建议需要对你的应用进行分层,并且识别在给定层上所有可上锁的互斥量。当代码试图对一个互斥量上锁,在该层锁已被低层持有时,上锁是不允许的。你可以在运行时对其进行检查,通过分配层数到每个互斥量上,以及记录被每个线程上锁的互斥量。下面的代码列表中将展示两个线程如何使用分层互斥。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30hierarchical_mutex high_level_mutex(10000); // 1
hierarchical_mutex low_level_mutex(5000); // 2
int do_low_level_stuff();
int low_level_func()
{
std::lock_guard<hierarchical_mutex> lk(low_level_mutex); // 3
return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func()
{
std::lock_guard<hierarchical_mutex> lk(high_level_mutex); // 4
high_level_stuff(low_level_func()); // 5
}
void thread_a() // 6
{
high_level_func();
}
hierarchical_mutex other_mutex(100); // 7
void do_other_stuff();
void other_stuff()
{
high_level_func(); // 8
do_other_stuff();
}
void thread_b() // 9
{
std::lock_guard<hierarchical_mutex> lk(other_mutex); // 10
other_stuff();
}
thread_a()
⑥遵守规则,所以它运行的没问题。另一方面,thread_b()
⑨无视规则,因此在运行的时候肯定会失败。thread_a()
调用high_level_func()
,让high_level_mutex
④上锁(其层级值为10000①),为了获取high_level_stuff()
的参数对互斥量上锁,之后调用low_level_func()
⑤。low_level_func()
会对low_level_mutex
上锁,这就没有问题了,因为这个互斥量有一个低层值5000②。
thread_b()
运行就不会顺利了。首先,它锁住了other_mutex
⑩,这个互斥量的层级值只有100⑦。这就意味着,超低层级的数据已被保护。当other_stuff()
调用high_level_func()
⑧时,就违反了层级结构:high_level_func()
试图获取high_level_mutex
,这个互斥量的层级值是10000,要比当前层级值100大很多。因此hierarchical_mutex
将会产生一个错误,可能会是抛出一个异常,或直接终止程序。在层级互斥量上产生死锁,是不可能的,因为互斥量本身会严格遵循约定顺序,进行上锁。这也意味,当多个互斥量在是在同一级上时,不能同时持有多个锁,所以“手递手”锁的方案需要每个互斥量在一条链上,并且每个互斥量都比其前一个有更低的层级值,这在某些情况下无法实现。
例子也展示了另一点,std::lock_guard<>
模板与用户定义的互斥量类型一起使用。虽然hierarchical_mutex
不是C++标准的一部分,但是它写起来很容易。尽管它是一个用户定义类型,它可以用于std::lock_guard<>
模板中,因为它的实现有三个成员函数为了满足互斥量操作:lock()
, unlock()
和try_lock()
。虽然你还没见过try_lock()
怎么使用,但是其使用起来很简单:当互斥量上的锁被一个线程持有,它将返回false,而不是等待调用的线程,直到能够获取互斥量上的锁为止。在std::lock()
的内部实现中,try_lock()
会作为避免死锁算法的一部分。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45class hierarchical_mutex
{
std::mutex internal_mutex;
unsigned long const hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value; // 1
void check_for_hierarchy_violation()
{
if(this_thread_hierarchy_value <= hierarchy_value) // 2
{
throw std::logic_error(“mutex hierarchy violated”);
}
}
void update_hierarchy_value()
{
previous_hierarchy_value=this_thread_hierarchy_value; // 3
this_thread_hierarchy_value=hierarchy_value;
}
public:
explicit hierarchical_mutex(unsigned long value):
hierarchy_value(value),
previous_hierarchy_value(0)
{}
void lock()
{
check_for_hierarchy_violation();
internal_mutex.lock(); // 4
update_hierarchy_value(); // 5
}
void unlock()
{
this_thread_hierarchy_value=previous_hierarchy_value; // 6
internal_mutex.unlock();
}
bool try_lock()
{
check_for_hierarchy_violation();
if(!internal_mutex.try_lock()) // 7
return false;
update_hierarchy_value();
return true;
}
};
thread_local unsigned long
hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX); // 8
这里重点是使用了thread_local的值来代表当前线程的层级值:this_thread_hierarchy_value
①。它被初始化为最大值⑧,所以最初所有线程都能被锁住。因为其声明中有thread_local
,所以每个线程都有其拷贝副本,这样线程中变量状态完全独立,当从另一个线程进行读取时,变量的状态也完全独立。
所以,第一次线程锁住一个hierarchical_mutex
时,this_thread_hierarchy_value
的值是ULONG_MAX
。由于其本身的性质,这个值会大于其他任何值,所以会通过check_for_hierarchy_vilation()
②的检查。在这种检查方式下,lock()
代表内部互斥锁已被锁住④。一旦成功锁住,你可以更新层级值了⑤。
当你现在锁住另一个hierarchical_mutex
时,还持有第一个锁,this_thread_hierarchy_value
的值将会显示第一个互斥量的层级值。第二个互斥量的层级值必须小于已经持有互斥量检查函数②才能通过。
现在,最重要的是为当前线程存储之前的层级值,所以你可以调用unlock()
⑥对层级值进行保存;否则,就锁不住任何互斥量(第二个互斥量的层级数高于第一个互斥量),即使线程没有持有任何锁。因为保存了之前的层级值,只有当持有internal_mutex
③,且在解锁内部互斥量⑥之前存储它的层级值,才能安全的将hierarchical_mutex
自身进行存储。这是因为hierarchical_mutex
被内部互斥量的锁所保护着。
try_lock()
与lock()
的功能相似,除了在调用internal_mutex
的try_lock()
⑦失败时,不能持有对应锁,所以不必更新层级值,并直接返回false。
std::unique_lock——灵活的锁
std::unqiue_lock
使用更为自由的不变量,这样std::unique_lock
实例不会总与互斥量的数据类型相关,使用起来要比std:lock_guard
更加灵活。首先,可将std::adopt_lock
作为第二个参数传入构造函数,对互斥量进行管理;也可以将std::defer_lock
作为第二个参数传递进去,表明互斥量应保持解锁状态。这样,就可以被std::unique_lock
对象(不是互斥量)的lock()
函数的所获取,或传递std::unique_lock
对象到std::lock()
中。保证灵活性要付出代价,这个代价就是允许std::unique_lock
实例不带互斥量:信息已被存储,且已被更新。
1 | class some_big_object; |
因为std::unique_lock
支持lock()
, try_lock()
和unlock()
成员函数,所以能将std::unique_lock
对象传递到std::lock()
②。这些同名的成员函数在低层做着实际的工作,并且仅更新std::unique_lock
实例中的标志,来确定该实例是否拥有特定的互斥量,这个标志是为了确保unlock()
在析构函数中被正确调用。如果实例拥有互斥量,那么析构函数必须调用unlock()
;但当实例中没有互斥量时,析构函数就不能去调用unlock()
。这个标志可以通过owns_lock()
成员变量进行查询。
可能如你期望的那样,这个标志被存储在某个地方。因此,std::unique_lock
对象的体积通常要比std::lock_guard
对象大,当使用std::unique_lock
替代std::lock_guard
,因为会对标志进行适当的更新或检查,就会做些轻微的性能惩罚。当std::lock_guard
已经能够满足你的需求,那么还是建议你继续使用它。当需要更加灵活的锁时,最好选择std::unique_lock
,因为它更适合于你的任务。
不同域中互斥量所有权的传递
std::unique_lock
实例没有与自身相关的互斥量,一个互斥量的所有权可以通过移动操作,在不同的实例中进行传递。某些情况下,这种转移是自动发生的,例如:当函数返回一个实例;另些情况下,需要显式的调用std::move()
来执行移动操作。从本质上来说,需要依赖于源值是否是左值——一个实际的值或是引用——或一个右值——一个临时类型。当源值是一个右值,为了避免转移所有权过程出错,就必须显式移动成左值。std::unique_lock
是可移动,但不可赋值的类型。
一种使用可能是允许一个函数去锁住一个互斥量,并且将所有权移到调用者上,所以调用者可以在这个锁保护的范围内执行额外的动作。
下面的程序片段展示了:函数get_lock()
锁住了互斥量,然后准备数据,返回锁的调用函数:1
2
3
4
5
6
7
8
9
10
11
12std::unique_lock<std::mutex> get_lock()
{
extern std::mutex some_mutex;
std::unique_lock<std::mutex> lk(some_mutex);
prepare_data();
return lk; // 1
}
void process_data()
{
std::unique_lock<std::mutex> lk(get_lock()); // 2
do_something();
}
lk
在函数中被声明为自动变量,它不需要调用std::move()
,可以直接返回①(编译器负责调用移动构造函数)。process_data()
函数直接转移std::unique_lock
实例的所有权②,调用do_something()
可使用的正确数据(数据没有受到其他线程的修改)。
通常这种模式会用于已锁的互斥量,其依赖于当前程序的状态,或依赖于传入返回类型为std::unique_lock
的函数(或以参数返回)。这样的用法不会直接返回锁,不过网关类的一个数据成员可用来确认已经对保护数据的访问权限进行上锁。这种情况下,所有的访问都必须通过网关类:当你想要访问数据,需要获取网关类的实例(如同前面的例子,通过调用get_lock()
之类函数)来获取锁。之后你就可以通过网关类的成员函数对数据进行访问。当完成访问,可以销毁这个网关类对象,将锁进行释放,让别的线程来访问保护数据。这样的一个网关类可能是可移动的(所以他可以从一个函数进行返回),在这种情况下锁对象的数据必须是可移动的。
std::unique_lock
的灵活性同样也允许实例在销毁之前放弃其拥有的锁。可以使用unlock()
来做这件事,如同一个互斥量:std::unique_lock
的成员函数提供类似于锁定和解锁互斥量的功能。std::unique_lock
实例在销毁前释放锁的能力,当锁没有必要在持有的时候,可以在特定的代码分支对其进行选择性的释放。这对于应用性能来说很重要,因为持有锁的时间增加会导致性能下降,其他线程会等待这个锁的释放,避免超越操作。
锁的粒度
一个细粒度锁(a fine-grained lock)能够保护较小的数据量,一个粗粒度锁(a coarse-grained lock)能够保护较多的数据量。
如果很多线程正在等待同一个资源,当有线程持有锁的时间过长,这就会增加等待的时间。在可能的情况下,锁住互斥量的同时只能对共享数据进行访问;试图对锁外数据进行处理。
std::unique_lock
在这种情况下工作正常,在调用unlock()
时,代码不需要再访问共享数据;而后当再次需要对共享数据进行访问时,就可以再调用lock()
了。下面代码就是这样的一种情况:1
2
3
4
5
6
7
8
9void get_and_process_data()
{
std::unique_lock<std::mutex> my_lock(the_mutex);
some_class data_to_process=get_next_data_chunk();
my_lock.unlock(); // 1 不要让锁住的互斥量越过process()函数的调用
result_type result=process(data_to_process);
my_lock.lock(); // 2 为了写入数据,对互斥量再次上锁
write_result(data_to_process,result);
}
不需要让锁住的互斥量越过对process()
函数的调用,所以可以在函数调用①前对互斥量手动解锁,并且在之后对其再次上锁②。
这能表示只有一个互斥量保护整个数据结构时的情况,不仅可能会有更多对锁的竞争,也会增加锁持锁的时间。较多的操作步骤需要获取同一个互斥量上的锁,所以持有锁的时间会更长。成本上的双重打击也算是为向细粒度锁转移提供了双重激励和可能。
如同上面的例子,锁不仅是能锁住合适粒度的数据,还要控制锁的持有时间,以及什么操作在执行的同时能够拥有锁。一般情况下,执行必要的操作时,尽可能将持有锁的时间缩减到最小。这也就意味有一些浪费时间的操作,比如:获取另外一个锁(即使你知道这不会造成死锁),或等待输入/输出操作完成时没有必要持有一个锁(除非绝对需要)。
保护共享数据的替代设施
互斥量是最通用的机制,但其并非保护共享数据的唯一方式。这里有很多替代方式可以在特定情况下,提供更加合适的保护。
一个特别极端(但十分常见)的情况就是,共享数据在并发访问和初始化时(都需要保护),但是之后需要进行隐式同步。这可能是因为数据作为只读方式创建,所以没有同步问题;或者因为必要的保护作为对数据操作的一部分,所以隐式的执行。任何情况下,数据初始化后锁住一个互斥量,纯粹是为了保护其初始化过程(这是没有必要的),并且这会给性能带来不必要的冲击。出于以上的原因,C++标准提供了一种纯粹保护共享数据初始化过程的机制。
保护共享数据的初始化过程
假设你与一个共享源,构建代价很昂贵,可能它会打开一个数据库连接或分配出很多的内存。
延迟初始化(Lazy initialization)在单线程代码很常见——每一个操作都需要先对源进行检查,为了了解数据是否被初始化,然后在其使用前决定,数据是否需要初始化:1
2
3
4
5
6
7
8
9std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); // 1
}
resource_ptr->do_something();
}
当共享数据对于并发访问是安全的,①是转为多线程代码时,需要保护的,但是下面天真的转换会使得线程资源产生不必要的序列化。这是因为每个线程必须等待互斥量,为了确定数据源已经初始化了。
1 | std::shared_ptr<some_resource> resource_ptr; |
这段代码相当常见了,也足够表现出没必要的线程化问题,很多人能想出更好的一些的办法来做这件事,包括声名狼藉的双重检查锁模式:
1 | void undefined_behaviour_with_double_checked_locking() |
指针第一次读取数据不需要获取锁①,并且只有在指针为NULL时才需要获取锁。然后,当获取锁之后,指针会被再次检查一遍② (这就是双重检查的部分),避免另一的线程在第一次检查后再做初始化,并且让当前线程获取锁。
这个模式为什么声名狼藉呢?因为这里有潜在的条件竞争,未被锁保护的读取操作①没有与其他线程里被锁保护的写入操作③进行同步。因此就会产生条件竞争,这个条件竞争不仅覆盖指针本身,还会影响到其指向的对象;即使一个线程知道另一个线程完成对指针进行写入,它可能没有看到新创建的some_resource实例,然后调用do_something()④后,得到不正确的结果。这个例子是在一种典型的条件竞争——数据竞争,C++标准中这就会被指定为“未定义行为”。这种竞争肯定是可以避免的。
C++标准委员会也认为条件竞争的处理很重要,所以C++标准库提供了std::once_flag
和std::call_once
来处理这种情况。比起锁住互斥量,并显式的检查指针,每个线程只需要使用std::call_once
,在std::call_once
的结束时,就能安全的知道指针已经被其他的线程初始化了。使用std::call_once
比显式使用互斥量消耗的资源更少,特别是当初始化完成后。在这种情况下,初始化通过调用函数完成,同样这样操作使用类中的函数操作符来实现同样很简单。如同大多数在标准库中的函数一样,或作为函数被调用,或作为参数被传递,std::call_once
可以和任何函数或可调用对象一起使用。1
2
3
4
5
6
7
8
9
10
11std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; // 1
void init_resource()
{
resource_ptr.reset(new some_resource);
}
void foo()
{
std::call_once(resource_flag,init_resource); // 可以完整的进行一次初始化
resource_ptr->do_something();
}
在这个例子中,std::once_flag
①和初始化好的数据都是命名空间区域的对象,但是std::call_once()
可仅作为延迟初始化的类型成员,如同下面的例子一样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25class X
{
private:
connection_info connection_details;
connection_handle connection;
std::once_flag connection_init_flag;
void open_connection()
{
connection=connection_manager.open(connection_details);
}
public:
X(connection_info const& connection_details_):
connection_details(connection_details_)
{}
void send_data(data_packet const& data) // 1
{
std::call_once(connection_init_flag,&X::open_connection,this); // 2
connection.send_data(data);
}
data_packet receive_data() // 3
{
std::call_once(connection_init_flag,&X::open_connection,this); // 2
return connection.receive_data();
}
};
例子中第一个调用send_data()
①或receive_data()
③的线程完成初始化过程。使用成员函数open_connection()
去初始化数据,也需要将this指针传进去。和其在在标准库中的函数一样,其接受可调用对象,比如std::thread
的构造函数和std::bind()
,通过向std::call_once
()②传递一个额外的参数来完成这个操作。
值得注意的是,std::mutex
和std::once_flag
的实例就不能拷贝和移动,所以当你使用它们作为类成员函数,如果你需要用到他们,你就得显示定义这些特殊的成员函数。
还有一种情形的初始化过程中潜存着条件竞争:其中一个局部变量被声明为static类型。这种变量的在声明后就已经完成初始化;对于多线程调用的函数,这就意味着这里有条件竞争——抢着去定义这个变量。在C++11标准中,初始化及定义完全在一个线程中发生,并且没有其他线程可在初始化完成前对其进行处理,条件竞争终止于初始化阶段,这样比在之后再去处理好的多。在只需要一个全局实例情况下,这里提供一个std::call_once
的替代方案1
2
3
4
5
6class my_class;
my_class& get_my_class_instance()
{
static my_class instance; // 线程安全的初始化过程
return instance;
}
多线程可以安全的调用get_my_class_instance()
①函数,不用为数据竞争而担心。
对于很少有更新的数据结构来说,只在初始化时保护数据。在大多数情况下,这种数据结构是只读的,并且多线程对其并发的读取也是很愉快的,不过一旦数据结构需要更新,就会产生竞争。
保护很少更新的数据结构
比起使用std::mutex
实例进行同步,不如使用boost::shared_mutex
来做同步。对于更新操作,可以使用std::lock_guard<boost::shared_mutex>
和std::unique_lock<boost::shared_mutex>
上锁。作为std::mutex
的替代方案,这就能保证更新线程的独占访问。因为其他线程不需要去修改数据结构,所以其可以使用boost::shared_lock<boost::shared_mutex>
获取访问权。这与使用std::unique_lock
一样,除非多线程要在同时获取同一个boost::shared_mutex
上有共享锁。唯一的限制:当任一线程拥有一个共享锁时,这个线程就会尝试获取一个独占锁,直到其他线程放弃他们的锁;同样的,当任一线程拥有一个独占锁时,其他线程就无法获得共享锁或独占锁,直到第一个线程放弃其拥有的锁。
如同之前描述的那样,下面的代码清单展示了一个简单的DNS缓存,使用std::map持有缓存数据,使用boost::shared_mutex进行保护。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class dns_entry;
class dns_cache
{
std::map<std::string,dns_entry> entries;
mutable boost::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const& domain) const
{
boost::shared_lock<boost::shared_mutex> lk(entry_mutex); // 1
std::map<std::string,dns_entry>::const_iterator const it=
entries.find(domain);
return (it==entries.end())?dns_entry():it->second;
}
void update_or_add_entry(std::string const& domain,
dns_entry const& dns_details)
{
std::lock_guard<boost::shared_mutex> lk(entry_mutex); // 2
entries[domain]=dns_details;
}
};
find_entry()
使用boost::shared_lock<>
来保护共享和只读权限①;这就使得多线程可以同时调用find_entry()
,且不会出错。另一方面,update_or_add_entry()
使用std::lock_guard<>
实例,当表格需要更新时②,为其提供独占访问权限;update_or_add_entry()
函数调用时,独占锁会阻止其他线程对数据结构进行修改,并且阻止线程调用find_entry()
。
嵌套锁
当一个线程已经获取一个std::mutex
时(已经上锁),并对其再次上锁,这个操作就是错误的,并且继续尝试这样做的话,就会产生未定义行为。然而,在某些情况下,一个线程尝试获取同一个互斥量多次,而没有对其进行一次释放是可以的。之所以可以,是因为C++标准库提供了std::recursive_mutex
类。其功能与std::mutex
类似,除了你可以从同一线程的单个实例上获取多个锁。互斥量锁住其他线程前,你必须释放你拥有的所有锁,所以当你调用lock()
三次时,你也必须调用unlock()
三次。正确使用std::lock_guard<std::recursive_mutex>
和std::unique_lock<std::recursice_mutex>
可以帮你处理这些问题。
同步并发操作
等待一个事件或其他条件
当一个线程等待另一个线程完成任务时,它可以持续的检查共享数据标志(用于做保护工作的互斥量),直到另一线程完成工作时对这个标志进行重设。不过,就是一种浪费:线程消耗宝贵的执行时间持续的检查对应标志,并且当互斥量被等待线程上锁后,其他线程就没有办法获取锁,这样线程就会持续等待。
第二个选择是在等待线程在检查间隙,使用std::this_thread::sleep_for()
进行周期性的间歇:1
2
3
4
5
6
7
8
9
10
11
12bool flag;
std::mutex m;
void wait_for_flag()
{
std::unique_lock<std::mutex> lk(m);
while(!flag)
{
lk.unlock(); // 1 解锁互斥量
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠100ms
lk.lock(); // 3 再锁互斥量
}
}
这个循环中,在休眠前②,函数对互斥量进行解锁①,并且在休眠结束后再对互斥量进行上锁,所以另外的线程就有机会获取锁并设置标识。
第三个选择(也是优先的选择)是,使用C++标准库提供的工具去等待事件的发生。通过另一线程触发等待事件的机制是最基本的唤醒方式,这种机制就称为“条件变量”。从概念上来说,一个条件变量会与多个事件或其他条件相关,并且一个或多个线程会等待条件的达成。当某些线程被终止时,为了唤醒等待线程(允许等待线程继续执行)终止的线程将会向等待着的线程广播“条件达成”的信息。
等待条件达成
C++标准库对条件变量有两套实现:std::condition_variable
和std::condition_variable_any
。这两个实现都包含在<condition_variable>
头文件的声明中。两者都需要与一个互斥量一起才能工作(互斥量是为了同步);前者仅限于与std::mutex
一起工作,而后者可以和任何满足最低标准的互斥量一起工作,从而加上了_any
的后缀。因为std::condition_variable_any
更加通用,这就可能从体积、性能,以及系统资源的使用方面产生额外的开销,所以std::condition_variable
一般作为首选的类型,当对灵活性有硬性要求时,我们才会去考虑std::condition_variable_any
。
所以,如何使用std::condition_variable
去处理之前提到的情况——当有数据需要处理时,如何唤醒休眠中的线程对其进行处理?以下清单展示了一种使用条件变量做唤醒的方式。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28std::mutex mut;
std::queue<data_chunk> data_queue; // 1
std::condition_variable data_cond;
void data_preparation_thread()
{
while(more_data_to_prepare())
{
data_chunk const data=prepare_data();
std::lock_guard<std::mutex> lk(mut);
data_queue.push(data); // 2
data_cond.notify_one(); // 3
}
}
void data_processing_thread()
{
while(true)
{
std::unique_lock<std::mutex> lk(mut); // 4
data_cond.wait(
lk,[]{return !data_queue.empty();}); // 5
data_chunk data=data_queue.front();
data_queue.pop();
lk.unlock(); // 6
process(data);
if(is_last_chunk(data))
break;
}
}
首先,你拥有一个用来在两个线程之间传递数据的队列①。当数据准备好时,使用std::lock_guard
对队列上锁,将准备好的数据压入队列中②,之后线程会对队列中的数据上锁。然后调用std::condition_variable
的notify_one()
成员函数,对等待的线程(如果有等待线程)进行通知③。
在另外一侧,你有一个正在处理数据的线程,这个线程首先对互斥量上锁,但在这里std::unique_lock
要比std::lock_guard
④更加合适——且听我细细道来。线程之后会调用std::condition_variable
的成员函数wait()
,传递一个锁和一个lambda函数表达式(作为等待的条件⑤)。
wait()
会去检查这些条件,如果条件不满足,wait()
函数将解锁互斥量,并且将这个线程置于阻塞或等待状态。当准备数据的线程调用notify_one()
通知条件变量时,处理数据的线程从睡眠状态中苏醒,重新获取互斥锁,并且对条件再次检查,在条件满足的情况下,从wait()
返回并继续持有锁。当条件不满足时,线程将对互斥量解锁,并且重新开始等待。
在调用wait()
的过程中,一个条件变量可能会去检查给定条件若干次;然而,它总是在互斥量被锁定时这样做,当且仅当提供测试条件的函数返回true时,它就会立即返回。当等待线程重新获取互斥量并检查条件时,如果它并非直接响应另一个线程的通知,这就是所谓的伪唤醒(spurious wakeup)。
使用条件变量构建线程安全队列
当使用队列在多个线程中传递数据时,接收线程通常需要等待数据的压入。这里我们提供pop()函数的两个变种:try_pop()
和wait_and_pop()
。try_pop()
尝试从队列中弹出数据,总会直接返回(当有失败时),即使没有值可检索;wait_and_pop()
,将会等待有值可检索的时候才返回。当你使用之前栈的方式来实现你的队列,你实现的队列接口就可能会是下面这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
class threadsafe_queue
{
public:
threadsafe_queue();
threadsafe_queue(const threadsafe_queue&);
threadsafe_queue& operator=(
const threadsafe_queue&) = delete; // 不允许简单的赋值
void push(T new_value);
bool try_pop(T& value); // 1
std::shared_ptr<T> try_pop(); // 2
void wait_and_pop(T& value);
std::shared_ptr<T> wait_and_pop();
bool empty() const;
};
就像之前对栈做的那样,在这里你将很多构造函数剪掉了,并且禁止了对队列的简单赋值。和之前一样,你也需要提供两个版本的try_pop()
和wait_for_pop()
。第一个重载的try_pop()
①在引用变量中存储着检索值,所以它可以用来返回队列中值的状态;当检索到一个变量时,他将返回true,否则将返回false。第二个重载②就不能做这样了,因为它是用来直接返回检索值的。当没有值可检索时,这个函数可以返回NULL指针。
1 |
|
线程队列的实例中包含有互斥量和条件变量,所以独立的变量就不需要了①,并且调用push()
也不需要外部同步②。当然,wait_and_pop()
还要兼顾条件变量的等待③。
1 |
|
empty()
是一个const成员函数,并且传入拷贝构造函数的other形参是一个const引用;因为其他线程可能有这个类型的非const引用对象,并调用变种成员函数,所以这里有必要对互斥量上锁。如果锁住互斥量是一个可变操作,那么这个互斥量对象就会标记为可变的①,之后他就可以在empty()和拷贝构造函数中上锁了。
条件变量在多个线程等待同一个事件时,也是很有用的。当线程用来分解工作负载,并且只有一个线程可以对通知做出反应;运行多个数据实例——处理线程(processing thread)。当新的数据准备完成,调用notify_one()
将会触发一个正在执行wait()
的线程,去检查条件和wait()
函数的返回状态(因为你仅是向data_queue添加一个数据项)。 这里不保证线程一定会被通知到,即使只有一个等待线程被通知时,所有处线程也有可能都在处理数据。
另一种可能是,很多线程等待同一事件,对于通知他们都需要做出回应。这会发生在共享数据正在初始化的时候,当处理线程可以使用同一数据时,就要等待数据被初始化,或等待共享数据的更新,比如,定期重新初始化(periodic reinitialization)。在这些情况下,准备线程准备数据数据时,就会通过条件变量调用notify_all()
成员函数,而非直接调用notify_one()
函数。
当等待线程只等待一次,当条件为true时,它就不会再等待条件变量了,所以一个条件变量可能并非同步机制的最好选择。尤其是,条件在等待一组可用的数据块时。在这样的情况下,期望(future)就是一个适合的选择。
使用期望等待一次性事件
当一个线程需要等待一个特定的一次性事件时,在某种程度上来说它就需要知道这个事件在未来的表现形式。之后,这个线程会周期性的检查事件是否触发;在检查期间也会执行其他任务。另外,在等待任务期间它可以先执行另外一些任务,直到对应的任务触发,而后等待期望的状态会变为就绪(ready)。一个“期望”可能是数据相关的。
在C++标准库中,有两种“期望”,使用两种类型模板实现,声明在头文件中: 唯一期望(unique futures)(std::future<>
)和共享期望(shared futures)(std::shared_future<>
)。这是仿照std::unique_ptr
和std::shared_ptr
。std::future
的实例只能与一个指定事件相关联,而std::shared_future
的实例就能关联多个事件。后者的实现中,所有实例会在同时变为就绪状态,并且他们可以访问与事件相关的任何数据。这种数据关联与模板有关,比如std::unique_ptr
和std::shared_ptr
的模板参数就是相关联的数据类型。在与数据无关的地方,可以使用std::future<void>
与std::shared_future<void>
的特化模板。虽然,我希望用于线程间的通讯,但是“期望”对象本身并不提供同步访问。当多个线程需要访问一个独立“期望”对象时,他们必须使用互斥量或类似同步机制对访问进行保护。
带返回值的后台任务
当任务的结果你不着急要时,你可以使用std::async
启动一个异步任务。与std::thread
对象等待的方式不同,std::async
会返回一个std::future
对象,这个对象持有最终计算出来的结果。当你需要这个值时,你只需要调用这个对象的get()
成员函数;并且会阻塞线程直到“期望”状态为就绪为止;之后,返回计算结果。下面清单中代码就是一个简单的例子。
1 |
|
与std::thread
做的方式一样,std::async
允许你通过添加额外的调用参数,向函数传递额外的参数。当第一个参数是一个指向成员函数的指针,第二个参数提供有这个函数成员类的具体对象,剩余的参数可作为成员函数的参数传入。否则,第二个和随后的参数将作为函数的参数,或作为指定可调用对象的第一个参数。就如std::thread
,当参数为右值(rvalues)时,拷贝操作将使用移动的方式转移原始数据。这就允许使用“只移动”类型作为函数对象和参数。来看一下下面的程序清单:
1 |
|
在默认情况下,“期望”是否进行等待取决于std::async
是否启动一个线程,或是否有任务正在进行同步。你也可以在函数调用之前,向std::async
传递一个额外参数。这个参数的类型是std::launch
,还可以是std::launch::defered
,用来表明函数调用被延迟到wait()
或get()
函数调用时才执行,std::launch::async
表明函数必须在其所在的独立线程上执行,std::launch::deferred | std::launch::async
表明实现可以选择这两种方式的一种。最后一个选项是默认的。当函数调用被延迟,它可能不会在运行了。如下所示:1
2
3
4
5
6
7auto f6=std::async(std::launch::async,Y(),1.2); // 在新线程上执行
auto f7=std::async(std::launch::deferred,baz,std::ref(x)); // 在wait()或get()调用时执行
auto f8=std::async(
std::launch::deferred | std::launch::async,
baz,std::ref(x)); // 实现选择执行方式
auto f9=std::async(baz,std::ref(x));
f7.wait(); // 调用延迟函数
任务与期望
std::packaged_task<>
对一个函数或可调用对象,绑定一个期望。当std::packaged_task<>
对象被调用,它就会调用相关函数或可调用对象,将期望状态置为就绪,返回值也会被存储为相关数据。这可以用在构建线程池的结构单元,或用于其他任务的管理,比如在任务所在线程上运行任务,或将它们顺序的运行在一个特殊的后台线程上。当一个粒度较大的操作可以被分解为独立的子任务时,其中每个子任务就可以包含在一个std::packaged_task<>
实例中,之后这个实例将传递到任务调度器或线程池中。对任务的细节进行抽象,调度器仅处理std::packaged_task<>
实例,而非处理单独的函数。
std::packaged_task<>
的模板参数是一个函数签名,比如void()
就是一个没有参数也没有返回值的函数,或int(std::string&, double*)
就是有一个非const引用的std::string和一个指向double类型的指针,并且返回类型是int。当你构造出一个std::packaged_task<>
实例时,你必须传入一个函数或可调用对象,这个函数或可调用的对象需要能接收指定的参数和返回可转换为指定返回类型的值。类型可以不完全匹配;你可以用一个int类型的参数和返回一个float类型的函数,来构建std::packaged_task<double(double)>
的实例,因为在这里,类型可以隐式转换。
指定函数签名的返回类型可以用来标识,从get_future()
返回的std::future<>
的类型,不过函数签名的参数列表,可用来指定“打包任务”的函数调用操作符。例如,模板偏特化std::packaged_task<std::string(std::vector<char>*,int)>
将在下面的代码清单中使用。
1 | template<> |
这里的std::packaged_task
对象是一个可调用对象,并且它可以包含在一个std::function
对象中,传递到std::thread
对象中,就可作为线程函数;传递另一个函数中,就作为可调用对象,或可以直接进行调用。当std::packaged_task
作为一个函数调用时,可为函数调用操作符提供所需的参数,并且返回值作为异步结果存储在std::future
,可通过get_future()
获取。你可以把一个任务包含入std::packaged_task
,并且在检索期望之前,需要将std::packaged_task
对象传入,以便调用时能及时的找到。
当你需要异步任务的返回值时,你可以等待期望的状态变为“就绪”。下面的代码就是这么个情况。
很多图形架构需要特定的线程去更新界面,所以当一个线程需要界面的更新时,它需要发出一条信息给正确的线程,让特定的线程来做界面更新。std::packaged_task
提供了完成这种功能的一种方法,且不需要发送一条自定义信息给图形界面相关线程。下面来看看代码。
1 |
|
这段代码十分简单:图形界面线程①循环直到收到一条关闭图形界面的信息后关闭②,进行轮询界面消息处理③,例如用户点击,和执行在队列中的任务。当队列中没有任务④,它将再次循环;除非,他能在队列中提取出一个任务⑤,然后释放队列上的锁,并且执行任务⑥。这里,“期望”与任务相关,当任务执行完成时,其状态会被置为“就绪”状态。
将一个任务传入队列,也很简单:提供的函数⑦可以提供一个打包好的任务,可以通过这个任务⑧调用get_future()
成员函数获取“期望”对象,并且在任务被推入列表⑨之前,“期望”将返回调用函数⑩。当需要知道线程执行完任务时,向图形界面线程发布消息的代码,会等待“期望”改变状态;否则,则会丢弃这个“期望”。
这个例子使用std::packaged_task<void()>
创建任务,其包含了一个无参数无返回值的函数或可调用对象(如果当这个调用有返回值时,返回值会被丢弃)。这可能是最简单的任务,如你之前所见,std::packaged_task
也可以用于一些复杂的情况——通过指定一个不同的函数签名作为模板参数,你不仅可以改变其返回类型(因此该类型的数据会存在期望相关的状态中),而且也可以改变函数操作符的参数类型。这个例子可以简单的扩展成允许任务运行在图形界面线程上,且接受传参,还有通过std::future
返回值,而不仅仅是完成一个指标。
这些任务能作为一个简单的函数调用来表达吗?还有,这些任务的结果能从很多地方得到吗?这些情况可以使用第三种方法创建“期望”来解决:使用std::promise对值进行显示设置。
使用std::promises
std::promise<T>
提供设定值的方式(类型为T),这个类型会和后面看到的std::future<T>
对象相关联。一对std::promise/std::future
会为这种方式提供一个可行的机制;在期望上可以阻塞等待线程,同时,提供数据的线程可以使用组合中的“承诺”来对相关值进行设置,以及将“期望”的状态置为“就绪”。
可以通过get_future()
成员函数来获取与一个给定的std::promise
相关的std::future
对象,就像是与std::packaged_task
相关。当“承诺”的值已经设置完毕(使用set_value()
成员函数),对应“期望”的状态变为“就绪”,并且可用于检索已存储的值。当你在设置值之前销毁std::promise
,将会存储一个异常。
在这个例子中,你可以使用一对std::promise<bool>/std::future<bool>
找出一块传出成功的数据块;与“期望”相关值只是一个简单的“成功/失败”标识。对于传入包,与“期望”相关的数据就是数据包的有效负载。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void process_connections(connection_set& connections)
{
while(!done(connections)) // 1
{
for(connection_iterator connection=connections.begin(),end=connections.end(); // 2
connection!=end;
++connection)
{
if(connection->has_incoming_data()) // 3
{
data_packet data=connection->incoming();
std::promise<payload_type>& p=
connection->get_promise(data.id); // 4
p.set_value(data.payload);
}
if(connection->has_outgoing_data()) // 5
{
outgoing_packet data=
connection->top_of_outgoing_queue();
connection->send(data.payload);
data.promise.set_value(true); // 6
}
}
}
}
函数process_connections()
中,直到done()
返回true①为止。每一次循环,程序都会依次的检查每一个连接②,检索是否有数据③或正在发送已入队的传出数据⑤。这里假设输入数据包是具有ID和有效负载的(有实际的数在其中)。一个ID映射到一个std::promise
,并且值是设置在包的有效负载中的。对于传出包,包是从传出队列中进行检索的,实际上从接口直接发送出去。当发送完成,与传出数据相关的“承诺”将置为true,来表明传输成功⑥。这是否能映射到实际网络协议上,取决于网络所用协议;这里的“承诺/期望”组合方式可能会在特殊的情况下无法工作,但是它与一些操作系统支持的异步输入/输出结构类似。
为“期望”存储“异常”
看完下面短小的代码段,思考一下,当你传递-1到square_root()
中时,它将抛出一个异常,并且这个异常将会被调用者看到:1
2
3
4
5
6
7
8double square_root(double x)
{
if(x<0)
{
throw std::out_of_range(“x<0”);
}
return sqrt(x);
}
假设调用square_root()
函数不是当前线程,1
double y=square_root(-1);
你将这样的调用改为异步调用:1
2std::future<double> f=std::async(square_root,-1);
double y=f.get();
如果行为是完全相同的时候,其结果是理想的;在任何情况下,y获得函数调用的结果,当线程调用f.get()
时,就能再看到异常了,即使在一个单线程例子中。
好吧,事实的确如此:函数作为std::async
的一部分时,当在调用时抛出一个异常,那么这个异常就会存储到“期望”的结果数据中,之后“期望”的状态被置为“就绪”,之后调用get()
会抛出这个存储的异常。(注意:标准级别没有指定重新抛出的这个异常是原始的异常对象,还是一个拷贝;不同的编译器和库将会在这方面做出不同的选择)。当你将函数打包入std::packaged_task
任务包中后,在这个任务被调用时,同样的事情也会发生;当打包函数抛出一个异常,这个异常将被存储在“期望”的结果中,准备在调用get()
再次抛出。
当然,通过函数的显式调用,std::promise
也能提供同样的功能。当你希望存入的是一个异常而非一个数值时,你就需要调用set_exception()
成员函数,而非set_value()
。这通常是用在一个catch块中,并作为算法的一部分,为了捕获异常,使用异常填充“承诺”:1
2
3
4
5
6
7
8
9extern std::promise<double> some_promise;
try
{
some_promise.set_value(calculate_value());
}
catch(...)
{
some_promise.set_exception(std::current_exception());
}
这里使用了std::current_exception()
来检索抛出的异常;可用std::copy_exception()
作为一个替换方案,std::copy_exception()
会直接存储一个新的异常而不抛出:1
some_promise.set_exception(std::copy_exception(std::logic_error("foo ")));
这就比使用try/catch块更加清晰,当异常类型是已知的,它就应该优先被使用;不是因为代码实现简单,而是它给编译器提供了极大的代码优化空间。
另一种向“期望”中存储异常的方式是,在没有调用“承诺”上的任何设置函数前,或正在调用包装好的任务时,销毁与std::promise
或std::packaged_task
相关的“期望”对象。在这任何情况下,当“期望”的状态还不是“就绪”时,调用std::promise
或std::packaged_task
的析构函数,将会存储一个与std::future_errc::broken_promise
错误状态相关的std::future_error
异常;通过创建一个“期望”,你可以构造一个“承诺”为其提供值或异常;你可以通过销毁值和异常源,去违背“承诺”。在这种情况下,编译器没有在“期望”中存储任何东西,等待线程可能会永远的等下去。
直到现在,所有例子都在用std::future
。不过,std::future
也有局限性,在很多线程在等待的时候,只有一个线程能获取等待结果。当多个线程需要等待相同的事件的结果,你就需要使用std::shared_future
来替代std::future
了。
多个线程的等待
虽然std::future
可以处理所有在线程间数据转移的必要同步,但是调用某一特殊std::future
对象的成员函数,就会让这个线程的数据和其他线程的数据不同步。当多线程在没有额外同步的情况下,访问一个独立的std::future
对象时,就会有数据竞争和未定义的行为。这是因为:std::future
模型独享同步结果的所有权,并且通过调用get()
函数,一次性的获取数据,这就让并发访问变的毫无意义——只有一个线程可以获取结果值,因为在第一次调用get()
后,就没有值可以再获取了。
如果你的并行代码没有办法让多个线程等待同一个事件,先别太失落;std::shared_future
可以来帮你解决。因为std::future
是只移动的,所以其所有权可以在不同的实例中互相传递,但是只有一个实例可以获得特定的同步结果;而std::shared_future
实例是可拷贝的,所以多个对象可以引用同一关联“期望”的结果。
在每一个std::shared_future
的独立对象上成员函数调用返回的结果还是不同步的,所以为了在多个线程访问一个独立对象时,避免数据竞争,必须使用锁来对访问进行保护。优先使用的办法:为了替代只有一个拷贝对象的情况,可以让每个线程都拥有自己对应的拷贝对象。这样,当每个线程都通过自己拥有的std::shared_future
对象获取结果,那么多个线程访问共享同步结果就是安全的。
有可能会使用std::shared_future
的地方,例如,实现类似于复杂的电子表格的并行执行;每一个单元格有单一的终值,这个终值可能是有其他单元格中的数据通过公式计算得到的。公式计算得到的结果依赖于其他单元格,然后可以使用一个std::shared_future
对象引用第一个单元格的数据。当每个单元格内的所有公式并行执行后,这些任务会以期望的方式完成工作;不过,当其中有计算需要依赖其他单元格的值,那么它就会被阻塞,直到依赖单元格的数据准备就绪。这将让系统在最大程度上使用可用的硬件并发。
std::shared_future
的实例同步std::future
实例的状态。当std::future
对象没有与其他对象共享同步状态所有权,那么所有权必须使用std::move
将所有权传递到std::shared_future
,其默认构造函数如下:1
2
3
4
5
6std::promise<int> p;
std::future<int> f(p.get_future());
assert(f.valid()); // 1 "期望" f 是合法的
std::shared_future<int> sf(std::move(f));
assert(!f.valid()); // 2 "期望" f 现在是不合法的
assert(sf.valid()); // 3 sf 现在是合法的
这里,“期望”f
开始是合法的①,因为它引用的是“承诺”p
的同步状态,但是在转移sf
的状态后,f
就不合法了②,而sf
就是合法的了③。
如其他可移动对象一样,转移所有权是对右值的隐式操作,所以你可以通过std::promise
对象的成员函数get_future()
的返回值,直接构造一个std::shared_future
对象,例如:1
2std::promise<std::string> p;
std::shared_future<std::string> sf(p.get_future()); // 1 隐式转移所有权
这里转移所有权是隐式的;用一个右值构造std::shared_future<>
,得到std::future<std::string>
类型的实例①。
std::future
的这种特性,可促进std::shared_future
的使用,容器可以自动的对类型进行推断,从而初始化这个类型的变量。std::future
有一个share()
成员函数,可用来创建新的std::shared_future
,并且可以直接转移“期望”的所有权。这样也就能保存很多类型,并且使得代码易于修改:1
2
3std::promise< std::map< SomeIndexType, SomeDataType, SomeComparator,
SomeAllocator>::iterator> p;
auto sf=p.get_future().share();
在这个例子中,sf
的类型推到为std::shared_future<std::map<SomeIndexType, SomeDataType, SomeComparator, SomeAllocator>::iterator>
,一口气还真的很难念完。当比较器或分配器有所改动,你只需要对“承诺”的类型进行修改即可;“期望”的类型会自动更新,与“承诺”的修改进行匹配。
有时候你需要限定等待一个事件的时间,不论是因为你在时间上有硬性规定(一段指定的代码需要在某段时间内完成),还是因为在事件没有很快的触发时,有其他必要的工作需要特定线程来完成。为了处理这种情况,很多等待函数具有用于指定超时的变量。
限定等待时间
之前介绍过的所有阻塞调用,将会阻塞一段不确定的时间,将线程挂起直到等待的事件发生。在很多情况下,这样的方式很不错,但是在其他一些情况下,你就需要限制一下线程等待的时间了。
介绍两种可能是你希望指定的超时方式:一种是“时延”的超时方式,另一种是“绝对”超时方式。第一种方式,需要指定一段时间;第二种方式,就是指定一个时间点。多数等待函数提供变量,对两种超时方式进行处理。处理持续时间的变量以“_for”作为后缀,处理绝对时间的变量以”_until”作为后缀。
所以,当std::condition_variable
的两个成员函数wait_for()
和wait_until()
成员函数分别有两个负载,这两个负载都与wait()
成员函数的负载相关——其中一个负载只是等待信号触发,或时间超期,亦或是一个虚假的唤醒,并且醒来时,会检查锁提供的谓词,并且只有在检查为true时才会返回(这时条件变量的条件达成),或直接而超时。
时钟
对于C++标准库来说,时钟就是时间信息源。特别是,时钟是一个类,提供了四种不同的信息:
- 现在时间
- 时间类型
- 时钟节拍
- 通过时钟节拍的分布,判断时钟是否稳定
时钟的当前时间可以通过调用静态成员函数now()
从时钟类中获取;例如,std::chrono::system_clock::now()
是将返回系统时钟的当前时间。特定的时间点类型可以通过time_point
的数据typedef成员来指定,所以some_clock::now()
的类型就是some_clock::time_point
。
时钟节拍被指定为1/x(x在不同硬件上有不同的值)秒,这是由时间周期所决定——一个时钟一秒有25个节拍,因此一个周期为std::ratio<1, 25>
,当一个时钟的时钟节拍每2.5秒一次,周期就可以表示为std::ratio<5, 2>
。当时钟节拍直到运行时都无法知晓,可以使用一个给定的应用程序运行多次,周期可以用执行的平均时间求出,其中最短的时间可能就是时钟节拍。这就不保证在给定应用中观察到的节拍周期与指定的时钟周期相匹配。
当时钟节拍均匀分布(无论是否与周期匹配),并且不可调整,这种时钟就称为稳定时钟。当is_steady
静态数据成员为true时,表明这个时钟就是稳定的,否则,就是不稳定的。通常情况下,std::chrono::system_clock
是不稳定的,因为时钟是可调的,即是这种是完全自动适应本地账户的调节。这种调节可能造成的是,首次调用now()
返回的时间要早于上次调用now()
所返回的时间,这就违反了节拍频率的均匀分布。稳定闹钟对于超时的计算很重要,所以C++标准库提供一个稳定时钟std::chrono::steady_clock
。C++标准库提供的其他时钟可表示为std::chrono::system_clock
,它代表了系统时钟的“实际时间”,并且提供了函数可将时间点转化为time_t
类型的值;std::chrono::high_resolution_clock
可能是标准库中提供的具有最小节拍周期的时钟。它实际上是typedef的另一种时钟,这些时钟和其他与时间相关的工具,都被定义在库头文件中。
时延
时延是时间部分最简单的;std::chrono::duration<>
函数模板能够对时延进行处理。第一个模板参数是一个类型表示(比如,int,long或double),第二个模板参数是制定部分,表示每一个单元所用秒数。例如,当几分钟的时间要存在short类型中时,可以写成std::chrono::duration<short, std::ratio<60, 1>>
,因为60秒是才是1分钟,所以第二个参数写成std::ratio<60, 1>
。另一方面,当需要将毫秒级计数存在double类型中时,可以写成std::chrono::duration<double, std::ratio<1, 1000>>
,因为1秒等于1000毫秒。
标准库在std::chrono
命名空间内,为延时变量提供一系列预定义类型:nanoseconds[纳秒] , microseconds[微秒] , milliseconds[毫秒] , seconds[秒] , minutes[分]和hours[时]。比如,你要在一个合适的单元表示一段超过500年的时延,预定义类型可充分利用了大整型,来表示所要表示的时间类型。
显式转换可以由std::chrono::duration_cast<>
来完成。1
2
3std::chrono::milliseconds ms(54802);
std::chrono::seconds s=
std::chrono::duration_cast<std::chrono::seconds>(ms);
这里的结果就是截断的,而不是进行了舍入,所以s最后的值将为54。
基于时延的等待可由std::chrono::duration<>
来完成。例如,你等待一个“期望”状态变为就绪已经35毫秒:1
2
3std::future<int> f=std::async(some_task);
if(f.wait_for(std::chrono::milliseconds(35))==std::future_status::ready)
do_something_with(f.get());
等待函数会返回一个状态值,来表示等待是超时,还是继续等待。在这种情况下,你可以等待一个“期望”,所以当函数等待超时时,会返回std::future_status::timeout
;当“期望”状态改变,函数会返回std::future_status::ready
;当“期望”的任务延迟了,函数会返回std::future_status::deferred
。
时间点
时钟的时间点可以用std::chrono::time_point<>
的类型模板实例来表示,实例的第一个参数用来指定所要使用的时钟,第二个函数参数用来表示时间的计量单位(特化的std::chrono::duration<>
)。一个时间点的值就是时间的长度(在指定时间的倍数内),例如,指定“unix时间戳”(epoch)为一个时间点。时钟可能共享一个时间戳,或具有独立的时间戳。当两个时钟共享一个时间戳时,其中一个time_point
类型可以与另一个时钟类型中的time_point
相关联。可以通过对指定time_point
类型使用time_since_epoch()
来获取时间戳。
例如,你可能指定了一个时间点std::chrono::time_point<std::chrono::system_clock, std::chrono::minutes>
。
你可以通过std::chrono::time_point<>
实例来加/减时延,来获得一个新的时间点,所以std::chrono::hight_resolution_clock::now() + std::chrono::nanoseconds(500)
将得到500纳秒后的时间。当你知道一块代码的最大时延时,这对于计算绝对时间的超时是一个好消息,当等待时间内,等待函数进行多次调用;或,非等待函数且占用了等待函数时延中的时间。
你也可以减去一个时间点(二者需要共享同一个时钟)。结果是两个时间点的时间差。这对于代码块的计时是很有用的,例如:1
2
3
4
5
6auto start=std::chrono::high_resolution_clock::now();
do_something();
auto stop=std::chrono::high_resolution_clock::now();
std::cout<<”do_something() took “
<<std::chrono::duration<double,std::chrono::seconds>(stop-start).count()
<<” seconds”<<std::endl;
std::chrono::time_point<>
实例的时钟参数可不仅是能够指定unix时间戳的。当你想一个等待函数(绝对时间超时的方式)传递时间点时,时间点的时钟参数就被用来测量时间。当时钟变更时,会产生严重的后果,因为等待轨迹随着时钟的改变而改变,并且知道调用时钟的now()
成员函数时,才能返回一个超过超时时间的值。当时钟向前调整,这就有可能减小等待时间的总长度(与稳定时钟的测量相比);当时钟向后调整,就有可能增加等待时间的总长度。
如你期望的那样,后缀为_unitl
的(等待函数的)变量会使用时间点。通常是使用某些时钟的::now()
作为偏移,虽然时间点与系统时钟有关,可以使用std::chrono::system_clock::to_time_point()
静态成员函数,在用户可视时间点上进行调度操作。例如,当你有一个对多等待500毫秒的,且与条件变量相关的事件,你可以参考如下代码:
1 |
|
这种方式是我们推荐的,当你没有什么事情可以等待时,可在一定时限中等待条件变量。在这种方式中,循环的整体长度是有限的。当使用条件变量(且无事可待)时,你就需要使用循环,这是为了处理假唤醒。当你在循环中使用wait_for()
时,你可能在等待了足够长的时间后结束等待(在假唤醒之前),且下一次等待又开始了。这可能重复很多次,使得等待时间无边无际。
到此,有关时间点超时的基本知识你已经了解了。现在,让我们来了解一下如何在函数中使用超时。
具有超时功能的函数
使用超时的最简单方式就是,对一个特定线程添加一个延迟处理;当这个线程无所事事时,就不会占用可供其他线程处理的时间。
两个处理函数分别是std::this_thread::sleep_for()
和std::this_thread::sleep_until()
。他们的工作就像一个简单的闹钟:当线程因为指定时延而进入睡眠时,可使用sleep_for()
唤醒;或因指定时间点睡眠的,可使用sleep_until
唤醒。sleep_until()
允许在某个特定时间点将调度线程唤醒。
当然,休眠只是超时处理的一种形式;你已经看到了,超时可以配合条件变量和“期望”一起使用。超时甚至可以在尝试获取一个互斥锁时使用。std::mutex
和std::recursive_mutex
都不支持超时锁,但是std::timed_mutex
和std::recursive_timed_mutex
支持。这两种类型也有try_lock_for()
和try_lock_until()
成员函数,可以在一段时期内尝试,或在指定时间点前获取互斥锁。
C++内存模型和原子类型操作
C++中的原子操作和原子类型
原子操作是不可分割的操作。在系统的所有线程中,你是不可能观察到原子操作完成了一半这种情况的;它要么就是做了,要么就是没做,只有这两种可能。
另一方面,非原子操作可能会被另一个线程观察到只完成一半。如果这个操作是一个存储操作,那么其他线程看到的值,可能既不是存储前的值,也不是存储的值,而是别的什么值。
在C++中,多数时候你需要一个原子类型来得到原子的操作,我们来看一下这些类型。
标准原子类型
标准原子类型定义在头文件<atomic>
中。这些类型上的所有操作都是原子的,在语言定义中只有这些类型的操作是原子的,不过你可以用互斥锁来模拟原子操作。实际上,标准原子类型自己的实现就可能是这样模拟出来的:它们(几乎)都有一个is_lock_free()
成员函数,这个函数让用户可以查询某原子类型的操作是直接用的原子指令(x.is_lock_free()
返回true),还是编译器和库内部用了一个锁(x.is_lock_free()
返回false)。
只用std::atomic_flag
类型不提供is_lock_free()
成员函数。这个类型是一个简单的布尔标志,并且在这种类型上的操作都需要是无锁的;当你有一个简单无锁的布尔标志时,你可以使用其实现一个简单的锁,并且实现其他基础的原子类型。
剩下的原子类型都可以通过特化std::atomic<>
类型模板而访问到,并且拥有更多的功能,但可能不都是无锁的。在最流行的平台上,期望原子变量都是无锁的内置类型(例如std::atomic<int>
和std::atomic<void*>
),但这没有必要。
除了直接使用std::atomic<>
类型模板外,你可以使用在表中所示的原子类型集。
原子类型 | 相关特化类 |
---|---|
atomic_bool | std::atomic |
atomic_char | std::atomic |
atomic_schar | std::atomic |
atomic_uchar | std::atomic |
atomic_int | std::atomic |
atomic_uint | std::atomic |
atomic_short | std::atomic |
atomic_ushort | std::atomic |
atomic_long | std::atomic |
atomic_ulong | std::atomic |
atomic_llong | std::atomic |
atomic_ullong | std::atomic |
atomic_char16_t | std::atomic |
atomic_char32_t | std::atomic |
atomic_wchar_t | std::atomic |
C++标准库不仅提供基本原子类型,还定义了与原子类型对应的非原子类型,就如同标准库中的std::size_t
。如表所示这些类型:
原子类型定义 | 标准库中相关类型定义 |
---|---|
atomic_int_least8_t | int_least8_t |
atomic_uint_least8_t | uint_least8_t |
atomic_int_least16_t | int_least16_t |
atomic_uint_least16_t | uint_least16_t |
atomic_int_least32_t | int_least32_t |
atomic_uint_least32_t | uint_least32_t |
atomic_int_least64_t | int_least64_t |
atomic_uint_least64_t | uint_least64_t |
atomic_int_fast8_t | int_fast8_t |
atomic_uint_fast8_t | uint_fast8_t |
atomic_int_fast16_t | int_fast16_t |
atomic_uint_fast16_t | uint_fast16_t |
atomic_int_fast32_t | int_fast32_t |
atomic_uint_fast32_t | uint_fast32_t |
atomic_int_fast64_t | int_fast64_t |
atomic_uint_fast64_t | uint_fast64_t |
atomic_intptr_t | intptr_t |
atomic_uintptr_t | uintptr_t |
atomic_size_t | size_t |
atomic_ptrdiff_t | ptrdiff_t |
atomic_intmax_t | intmax_t |
atomic_uintmax_t | uintmax_t |
对于标准类型进行typedef T
,相关的原子类型就在原来的类型名前加上atomic_
的前缀:atomic_T
。除了signed
类型的缩写是s
,unsigned
的缩写是u
,和long long
的缩写是llong
之外,这种方式也同样适用于内置类型。对于std::atomic<T>
模板,使用对应的T类型去特化模板的方式,要好于使用别名的方式。
通常,标准原子类型是不能拷贝和赋值,他们没有拷贝构造函数和拷贝赋值操作。但是,因为可以隐式转化成对应的内置类型,所以这些类型依旧支持赋值,可以使用load()
和store()
成员函数,exchange()
、compare_exchange_weak()
和compare_exchange_strong()
。它们都支持复合赋值符:+=, -=, *=, |= 等等。并且使用整型和指针的特化类型还支持 ++ 和 —。当然,这些操作也有功能相同的成员函数所对应:fetch_add()
,fetch_or()
等等。赋值操作和成员函数的返回值要么是被存储的值(赋值操作),要么是操作前的值(命名函数)。这就能避免赋值操作符返回引用。为了获取存储在引用的的值,代码需要执行单独的读操作,从而允许另一个线程在赋值和读取进行的同时修改这个值,这也就为条件竞争打开了大门。
std::atomic<>
类模板不仅仅一套特化的类型,其作为一个原发模板也可以使用用户定义类型创建对应的原子变量。因为,它是一个通用类模板,操作被限制为load()
,store()
(赋值和转换为用户类型),exchange()
,compare_exchange_weak()
和compare_exchange_strong()
。
每种函数类型的操作都有一个可选内存排序参数,这个参数可以用来指定所需存储的顺序。
- Store操作,可选如下顺序:memory_order_relaxed, memory_order_release, memory_order_seq_cst。
- Load操作,可选如下顺序:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_seq_cst。
- Read-modify-write(读-改-写)操作,可选如下顺序:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst。
所有操作的默认顺序都是memory_order_seq_cst。
现在,让我们来看一下每个标准原子类型进行的操作,就从std::atomic_flag
开始吧。
std::atomic_flag的相关操作
std::atomic_flag
是最简单的标准原子类型,它表示了一个布尔标志。这个类型的对象可以在两个状态间切换:设置和清除。它就是那么的简单,只作为一个构建块存在。我从未期待这个类型被使用,除非在十分特别的情况下。
std::atomic_flag
类型的对象必须被ATOMIC_FLAG_INIT
初始化。初始化标志位是“清除”状态。这里没得选择;这个标志总是初始化为“清除”:1
std::atomic_flag f = ATOMIC_FLAG_INIT;
这适用于任何对象的声明,并且可在任意范围内。它是唯一需要以如此特殊的方式初始化的原子类型,但它也是唯一保证无锁的类型。如果std::atomic_flag
是静态存储的,那么就的保证其是静态初始化的,也就意味着没有初始化顺序问题;在首次使用时,其都需要初始化。
当你的标志对象已初始化,那么你只能做三件事情:销毁,清除或设置(查询之前的值)。这些事情对应的函数分别是:clear()
成员函数,和test_and_set()
成员函数。clear()
和test_and_set()
成员函数可以指定好内存顺序。clear()
是一个存储操作,所以不能有memory_order_acquire
或memory_order_acq_rel
语义,但是test_and_set()
是一个“读-改-写”操作,所有可以应用于任何内存顺序标签。每一个原子操作,默认的内存顺序都是memory_order_seq_cst
。例如:1
2f.clear(std::memory_order_release); // 1
bool x=f.test_and_set(); // 2
这里,调用clear()
①明确要求,使用释放语义清除标志,当调用test_and_set()
②使用默认内存顺序设置表示,并且检索旧值。
你不能拷贝构造另一个std::atomic_flag
对象;并且,你不能将一个对象赋予另一个std::atomic_flag
对象。这并不是std::atomic_flag
特有的,而是所有原子类型共有的。一个原子类型的所有操作都是原子的,因赋值和拷贝调用了两个对象,这就就破坏了操作的原子性。在这样的情况下,拷贝构造和拷贝赋值都会将第一个对象的值进行读取,然后再写入另外一个。对于两个独立的对象,这里就有两个独立的操作了,合并这两个操作必定是不原子的。因此,操作就不被允许。
有限的特性集使得std::atomic_flag
非常适合于作自旋互斥锁。初始化标志是“清除”,并且互斥量处于解锁状态。为了锁上互斥量,循环运行test_and_set()
直到旧值为false,就意味着这个线程已经被设置为true了。解锁互斥量是一件很简单的事情,将标志清除即可。实现如下面的程序清单所示:
1 | class spinlock_mutex |
这样的互斥量是最最基本的,但是它已经足够std::lock_guard<>
使用了。其本质就是在lock()
中等待,所以这里几乎不可能有竞争的存在,并且可以确保互斥。当我们看到内存顺序语义时,你将会看到它们是如何对一个互斥锁保证必要的强制顺序的。
由于std::atomic_flag
局限性太强,因为它没有非修改查询操作,它甚至不能像普通的布尔标志那样使用。所以,你最好使用std::atomic<bool>
。
std::atomic的相关操作
最基本的原子整型类型就是std::atomic<bool>
。如你所料,它有着比std::atomic_flag
更加齐全的布尔标志特性。虽然它依旧不能拷贝构造和拷贝赋值,但是你可以使用一个非原子的bool类型构造它,所以它可以被初始化为true或false,并且你也可以从一个非原子bool变量赋值给std::atomic<bool>
的实例:1
2std::atomic<bool> b(true);
b=false;
另一件需要注意的事情时,非原子bool类型的赋值操作不同于通常的操作(转换成对应类型的引用,再赋给对应的对象):它返回一个bool值来代替指定对象。这是在原子类型中,另一种常见的模式:赋值操作通过返回值(返回相关的非原子类型)完成,而非返回引用。如果一个原子变量的引用被返回了,任何依赖与这个赋值结果的代码都需要显式加载这个值。通过使用返回非原子值进行赋值的方式,你可以避免这些多余的加载过程,并且得到的值就是实际存储的值。
虽然有内存顺序语义指定,但是使用store()
去写入(true或false)还是好于std::atomic_flag
中限制性很强的clear()
。同样的,test_and_set()
函数也可以被更加通用的exchange()
成员函数所替换,exchange()
成员函数允许你使用你新选的值替换已存储的值,并且自动的检索原始值。std::atomic<bool>
也支持对值的普通(不可修改)查找,其会将对象隐式的转换为一个普通的bool值,或显示的调用load()
来完成。如你预期,store()
是一个存储操作,而load()
是一个加载操作。exchange()
是一个“读-改-写”操作:1
2
3
4std::atomic<bool> b;
bool x=b.load(std::memory_order_acquire);
b.store(true);
x=b.exchange(false, std::memory_order_acq_rel);
std::atomic<bool>
提供的exchange()
,不仅仅是一个“读-改-写”的操作;它还介绍了一种新的存储方式:当当前值与预期值一致时,存储新值的操作。
这是一种新型操作,叫做“比较/交换”,它的形式表现为compare_exchange_weak()
和compare_exchange_strong()
成员函数。“比较/交换”操作是原子类型编程的基石;它比较原子变量的当前值和一个期望值,当两值相等时,存储提供值。当两值不等,期望值就会被更新为原子变量中的值。“比较/交换”函数值是一个bool变量,当返回true时执行存储操作,当false则更新期望值。
对于compare_exchange_weak()
函数,当原始值与预期值一致时,存储也可能会不成功;在这个例子中变量的值不会发生改变,并且compare_exchange_weak()
的返回是false。这可能发生在缺少独立“比较-交换”指令的机器上,当处理器不能保证这个操作能够自动的完成——可能是因为线程的操作将指令队列从中间关闭,并且另一个线程安排的指令将会被操作系统所替换(这里线程数多于处理器数量)。这被称为“伪失败”(spurious failure),因为造成这种情况的原因是时间,而不是变量值。
因为compare_exchange_weak()
可以“伪失败”,所以这里通常使用一个循环:1
2
3bool expected=false;
extern atomic<bool> b; // 设置些什么
while(!b.compare_exchange_weak(expected,true) && !expected);
在这个例子中,循环中expected
的值始终是false,表示compare_exchange_weak()
会莫名的失败。
另一方面,如果实际值与期望值不符,compare_exchange_strong()
就能保证值返回false。这就能消除对循环的需要,就可以知道是否成功的改变了一个变量,或已让另一个线程完成。
如果你想要改变变量值,且无论初始值是什么(可能是根据当前值更新了的值),更新后的期望值将会变更有用;经历每次循环的时候,期望值都会重新加载,所以当没有其他线程同时修改期望时,循环中对compare_exchange_weak()
或compare_exchange_strong()
的调用都会在下一次(第二次)成功。如果值的计算很容易存储,那么使用compare_exchange_weak()
能更好的避免一个双重循环的执行,即使compare_exchange_weak()
可能会“伪失败”(因此compare_exchange_strong()
包含一个循环)。另一方面,如果值计算的存储本身是耗时的,那么当期望值不变时,使用compare_exchange_strong()
可以避免对值的重复计算。对于std::atomic<bool>
这些都不重要——毕竟只可能有两种值——但是对于其他的原子类型就有较大的影响了。
“比较/交换”函数很少对两个拥有内存顺序的参数进行操作,这就就允许内存顺序语义在成功和失败的例子中有所不同;其可能是对memory_order_acq_rel语义的一次成功调用,而对memory_order_relaxed语义的一次失败的调动。一次失败的“比较/交换”将不会进行存储,所以“比较/交换”操作不能拥有memeory_order_release或memory_order_acq_rel语义。因此,这里不保证提供的这些值能作为失败的顺序。你也不能提供比成功顺序更加严格的失败内存顺序;当你需要memory_order_acquire或memory_order_seq_cst作为失败语序,那必须要如同“指定它们是成功语序”那样去做。
如果你没有指定失败的语序,那就假设和成功的顺序是一样的,除了release部分的顺序:memory_order_release变成memory_order_relaxed,并且memoyr_order_acq_rel变成memory_order_acquire。如果你都不指定,他们默认顺序将为memory_order_seq_cst,这个顺序提供了对成功和失败的全排序。下面对compare_exchange_weak()的两次调用是等价的:1
2
3
4std::atomic<bool> b;
bool expected;
b.compare_exchange_weak(expected,true,memory_order_acq_rel,memory_order_acquire);
b.compare_exchange_weak(expected,true,memory_order_acq_rel);
std::atomic<bool>
和std::atomic_flag
的不同之处在于,std::atomic<bool>
不是无锁的;为了保证操作的原子性,其实现中需要一个内置的互斥量。当处于特殊情况时,你可以使用is_lock_free()
成员函数,去检查std::atomic<bool>
上的操作是否无锁。这是另一个,除了std::atomic_flag
之外,所有原子类型都拥有的特征。
第二简单的原子类型就是特化原子指针——std::atomic<T*>
,接下来就看看它是如何工作的吧。
std::atomic:指针运算
原子指针类型,可以使用内置类型或自定义类型T,通过特化std::atomic<T*>
进行定义,就如同使用bool类型定义std::atomic<bool>
类型一样。虽然接口几乎一致,但是它的操作是对于相关的类型的指针,而非bool值本身。就像std::atomic<bool>
,虽然它既不能拷贝构造,也不能拷贝赋值,但是他可以通过合适的类型指针进行构造和赋值。如同成员函数is_lock_free()
一样,std::atomic<T*>
也有load()
, store()
, exchange()
, compare_exchange_weak()
和compare_exchage_strong()
成员函数,与std::atomic<bool>
的语义相同,获取与返回的类型都是T*
,而不是bool。
std::atomic<T*>
为指针运算提供新的操作。基本操作有fetch_add()
和fetch_sub()
提供,它们在存储地址上做原子加法和减法,为+=, -=, ++和—提供简易的封装。对于内置类型的操作,如你所预期:如果x是std::atomic<Foo*>
类型的数组的首地址,然后x+=3
让其偏移到第四个元素的地址,并且返回一个普通的Foo*
类型值,这个指针值是指向数组中第四个元素。fetch_add()
和fetch_sub()
的返回值略有不同(所以x.ftech_add(3)
让x指向第四个元素,并且函数返回指向第一个元素的地址)。这种操作也被称为“交换-相加”,并且这是一个原子的“读-改-写”操作,如同exchange()
和compare_exchange_weak()/compare_exchange_strong()
一样。正像其他操作那样,返回值是一个普通的T*
值,而非是std::atomic<T*>
对象的引用,所以调用代码可以基于之前的值进行操作:1
2
3
4
5
6
7
8
9class Foo{};
Foo some_array[5];
std::atomic<Foo*> p(some_array);
Foo* x=p.fetch_add(2); // p加2,并返回原始值
assert(x==some_array);
assert(p.load()==&some_array[2]);
x=(p-=1); // p减1,并返回原始值
assert(x==&some_array[1]);
assert(p.load()==&some_array[1]);
函数也允许内存顺序语义作为给定函数的参数:1
p.fetch_add(3,std::memory_order_release);
因为fetch_add()
和fetch_sub()
都是“读-改-写”操作,它们可以拥有任意的内存顺序标签,以及加入到一个释放序列中。指定的语序不可能是操作符的形式,因为没办法提供必要的信息:这些形式都具有memory_order_seq_cst语义。
标准的原子整型的相关操作
如同普通的操作集合一样(load()
, store()
,exchange()
,compare_exchange_weak()
,和compare_exchange_strong()
),在std::atomic<int>
和std::atomic<unsigned long long>
也是有一套完整的操作可以供使用:fetch_add()
,fetch_sub()
,fetch_and()
,fetch_or()
,fetch_xor()
,还有复合赋值方式((+=, -=, &=, |=和^=),以及++和—(++x, x++, —x和x—)。虽然对于普通的整型来说,这些复合赋值方式还不完全,但也十分接近完整了:只有除法、乘法和移位操作不在其中。因为,整型原子值通常用来作计数器,或者是掩码,所以以上操作的缺失显得不是那么重要;如果需要,额外的操作可以将compare_exchange_weak()
放入循环中完成。
对于std::atomic<T*>
类型紧密相关的两个函数就是fetch_add()
和fetch_sub()
;函数原子化操作,并且返回旧值,而符合赋值运算会返回新值。前缀加减和后缀加减与普通用法一样:++x对变量进行自加,并且返回新值;而x++对变量自加,返回旧值。正如你预期的那样,在这两个例子中,结果都是相关整型的一个值。
我们已经看过所有基本原子类型;剩下的就是std::atomic<>
类型模板,而非其特化类型。那么接下来让我们来了解一下std::atomic<>
类型模板。
std::atomic<>主要类的模板
主模板的存在,在除了标准原子类型之外,允许用户使用自定义类型创建一个原子变量。不是任何自定义类型都可以使用std::atomic<>
的:需要满足一定的标准才行。为了使用std::atomic<UDT>
(UDT是用户定义类型),这个类型必须有拷贝赋值运算符。这就意味着这个类型不能有任何虚函数或虚基类,以及必须使用编译器创建的拷贝赋值操作。不仅仅是这些,自定义类型中所有的基类和非静态数据成员也都需要支持拷贝赋值操作。这(基本上)就允许编译器使用memcpy()
,或赋值操作的等价操作,因为它们的实现中没有用户代码。
最后,这个类型必须是“位可比的”(bitwise equality comparable)。这与对赋值的要求差不多;你不仅需要确定,一个UDT类型对象可以使用memcpy()
进行拷贝,还要确定其对象可以使用memcmp()
对位进行比较。之所以要求这么多,是为了保证“比较/交换”操作能正常的工作。
以上严格的限制都是依据第3章中的一个建议:不要将锁定区域内的数据,以引用或指针的形式,作为参数传递给用户提供的函数。通常情况下,编译器不会为std::atomic<UDT>
类型生成无锁代码,所以它将对所有操作使用一个内部锁。如果用户提供的拷贝赋值或比较操作被允许,那么这就需要传递保护数据的引用作为一个参数,这就有悖于指导意见了。当原子操作需要时,运行库也可自由的使用单锁,并且运行库允许用户提供函数持有锁,这样就有可能产生死锁(或因为做一个比较操作,而阻塞了其他的线程)。最终,因为这些限制可以让编译器将用户定义的类型看作为一组原始字节,所以编译器可以对std::atomic<UDT>
直接使用原子指令(因此实例化一个特殊无锁结构)。
注意,虽然使用std::atomic<float>
或std::atomic<double>
(内置浮点类型满足使用memcpy和memcmp的标准),但是它们在compare_exchange_strong
函数中的表现可能会令人惊讶。当存储的值与当前值相等时,这个操作也可能失败,可能因为旧值是一个不同的表达式。这就不是对浮点数的原子计算操作了。在使用compare_exchange_strong
函数的过程中,你可能会遇到相同的结果,如果你使用std::atomic<>
特化一个用户自定义类型,且这个类型定义了比较操作,而这个比较操作与memcmp又有不同——操作可能会失败,因为两个相等的值用有不同的表达式。
如果你的UDT类型的大小如同(或小于)一个int或void*
类型时,大多数平台将会对std::atomic<UDT>
使用原子指令。有些平台可能会对用户自定义类型(两倍于int或void*
的大小)特化的std::atmic<>
使用原子指令。这些平台通常支持所谓的“双字节比较和交换”(double-word-compare-and-swap,DWCAS)指令,这个指令与compare_exchange_xxx
相关联着。这种指令的支持,对于写无锁代码是有很大的帮助。
以上的限制也意味着有些事情你不能做,比如,创建一个std::atomic<std::vector<int>>
类型。这里不能使用包含有计数器,标志指针和简单数组的类型,作为特化类型。虽然这不会导致任何问题,但是,越是复杂的数据结构,就有越多的操作要去做,而非只有赋值和比较。如果这种情况发生了,你最好使用std::mutex
保证数据能被必要的操作所保护。
当使用用户定义类型T进行实例化时,std::atomic<T>
的可用接口就只有: load()
,store()
,exchange()
,compare_exchange_weak()
,compare_exchange_strong()
和赋值操作,以及向类型T转换的操作。表5.3列举了每一个原子类型所能使用的操作。
原子操作的释放函数
直到现在,我都还没有去描述成员函数对原子类型操作的形式。但是,在不同的原子类型中也有等价的非成员函数存在。大多数非成员函数的命名与对应成员函数有关,但是需要atomic_
作为前缀(比如,std::atomic_load()
)。这些函数都会被不同的原子类型所重载。在指定一个内存序列标签时,他们会分成两种:一种没有标签,另一种将_explicit
作为后缀,并且需要一个额外的参数,或将内存顺序作为标签,亦或只有标签(例如,std::atomic_store(&atomic_var,new_value)
与std::atomic_store_explicit(&atomic_var,new_value,std::memory_order_release)
。不过,原子对象被成员函数隐式引用,所有释放函数都持有一个指向原子对象的指针(作为第一个参数)。
例如,std::atomic_is_lock_free()
只有一种类型(虽然会被其他类型所重载),并且对于同一个对象a
,std::atomic_is_lock_free(&a)
返回值与a.is_lock_free()
相同。同样的,std::atomic_load(&a)
和a.load()
的作用一样,但需要注意的是,与a.load(std::memory_order_acquire)
等价的操作是std::atomic_load_explicit(&a, std::memory_order_acquire)
。
释放函数的设计是为了要与C语言兼容,在C中只能使用指针,而不能使用引用。例如,compare_exchange_weak()
和compare_exchange_strong()
成员函数的第一个参数(期望值)是一个引用,而std::atomic_compare_exchange_weak()
(第一个参数是指向对象的指针)的第二个参数是一个指针。std::atomic_compare_exchange_weak_explicit()
也需要指定成功和失败的内存序列,而“比较/交换”成员函数都有一个单内存序列形式(默认是std::memory_order_seq_cst
),重载函数可以分别获取成功和失败内存序列。
对std::atomic_flag
的操作是“反潮流”的,在那些操作中它们“标志”的名称为:std::atomic_flag_test_and_set()
和std::atomic_flag_clear()
,但是以_explicit
为后缀的额外操作也能够指定内存顺序:std::atomic_flag_test_and_set_explicit()
和std::atomic_flag_clear_explicit()
。
C++标准库也对在一个原子类型中的std::shared_ptr<>
智能指针类型提供释放函数。这打破了“只有原子类型,才能提供原子操作”的原则,这里std::shared_ptr<>
肯定不是原子类型。但是,C++标准委员会感觉对此提供额外的函数是很重要的。可使用的原子操作有:load, store, exchange和compare/exchange,这些操作重载了标准原子类型的操作,并且获取一个std::shared_ptr<>*
作为第一个参数:1
2
3
4
5
6
7
8
9
10
11std::shared_ptr<my_data> p;
void process_global_data()
{
std::shared_ptr<my_data> local=std::atomic_load(&p);
process_data(local);
}
void update_global_data()
{
std::shared_ptr<my_data> local(new my_data);
std::atomic_store(&p,local);
}
作为和原子操作一同使用的其他类型,也提供“_explicit”变量,允许你指定所需的内存顺序,并且std::atomic_is_lock_free()
函数可以用来确定实现是否使用锁,来保证原子性。
如之前的描述,标准原子类型不仅仅是为了避免数据竞争所造成的未定义操作,它们还允许用户对不同线程上的操作进行强制排序。这种强制排序是数据保护和同步操作的基础,例如,std::mutex和std::future<>。所以,让我继续了解本章的真实意义:内存模型在并发方面的细节,如何使用原子操作同步数据和强制排序。
同步操作和强制排序
假设你有两个线程,一个向数据结构中填充数据,另一个读取数据结构中的数据。为了避免恶性条件竞争,第一个线程设置一个标志,用来表明数据已经准备就绪,并且第二个线程在这个标志设置前不能读取数据。下面的程序清单就是这样的情况。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::vector<int> data;
std::atomic<bool> data_ready(false);
void reader_thread()
{
while(!data_ready.load()) // 1
{
std::this_thread::sleep(std::milliseconds(1));
}
std::cout<<"The answer="<<data[0]<<"\m"; // 2
}
void writer_thread()
{
data.push_back(42); // 3
data_ready=true; // 4
}
先把等待数据的低效循环①放在一边。你已经知道,当非原子读②和写③对同一数据结构进行无序访问时,将会导致未定义行为的发生,因此这个循环就是确保访问循序被严格的遵守的。
强制访问顺序是由对std::atomic<bool>
类型的data_ready
变量进行操作完成的;这些操作通过先行发生(happens-before)和同步发生(synchronizes-with)确定必要的顺序。写入数据③的操作,在写入data_ready
标志④的操作前发生,并且读取标志①发生在读取数据②之前。当data_ready
①为true,写操作就会与读操作同步,建立一个“先行发生”关系。因为“先行发生”是可传递的,所以写入数据③先行于写入标志④,这两个行为又先行于读取标志的操作①,之前的操作都先行于读取数据②,这样你就拥有了强制顺序:写入数据先行于读取数据,其他没问题了。
所有事情看起来非常直观:对一个值来说,写操作必然先于读操作!在默认它们都是原子操作的时候,这无疑是正确的(这就是原子操作为默认属性的原因),不过这里需要详细说明:原子操作对于排序要求,也有其他的选项,会在稍后进行详述。
同步发生
“同步发生”只能在原子类型之间进行操作。例如对一个数据结构进行操作(对互斥量上锁),如果数据结构包含有原子类型,并且操作内部执行了一定的原子操作,那么这些操作就是同步发生关系。从根本上说,这种关系只能来源于对原子类型的操作。
“同步发生”的基本想法是:在变量x进行适当标记的原子写操作W,同步与对x进行适当标记的原子读操作,读取的是W操作写入的内容;或是在W之后,同一线程上的原子写操作对x写入的值;亦或是任意线程对x的一系列原子读-改-写操作(例如,fetch_add()
或compare_exchange_weak()
)。这里,第一个线程读取到的值是W操作写入的。
先将“适当的标记”放在一边,因为所有对原子类型的操作,默认都是适当标记的。这实际上就是:如果线程A存储了一个值,并且线程B读取了这个值,线程A的存储操作与线程B的载入操作就是同步发生的关系。
先行发生
“先行发生”关系是一个程序中,基本构建块的操作顺序;它指定了某个操作去影响另一个操作。对于单线程来说,就简单了:当一个操作排在另一个之后,那么这个操作就是先行执行的。这意味着,如果源码中操作A发生在操作B之前,那么A就先行于B发生。如果操作在同时发生,因为操作间无序执行,通常情况下,它们就没有先行关系了。这就是另一种排序未被指定的情况。
原子操作的内存顺序
这里有六个内存序列选项可应用于对原子类型的操作:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, 以及memory_order_seq_cst。除非你为特定的操作指定一个序列选项,要不内存序列选项对于所有原子类型默认都是memory_order_seq_cst。虽然有六个选项,但是它们仅代表三种内存模型:排序一致序列(sequentially consistent),获取-释放序列(memory_order_consume, memory_order_acquire, memory_order_release和memory_order_acq_rel),和自由序列(memory_order_relaxed)。
这些不同的内存序列模型,在不同的CPU架构下,功耗是不一样的。例如,基于处理器架构的可视化精细操作的系统,比起其他系统,添加的同步指令可被排序一致序列使用(在获取-释放序列和自由序列之前),或被获取-释放序列调用(在自由序列之前)。如果这些系统有多个处理器,这些额外添加的同步指令可能会消耗大量的时间,从而降低系统整体的性能。另一方面,CPU使用的是x86或x86-64架构(例如,使用Intel或AMD处理器的台式电脑),使用这种架构的CPU不需要任何对获取-释放序列添加额外的指令(没有保证原子性的必要了),并且,即使是排序一致序列,对于加载操作也不需要任何特殊的处理,不过在进行存储时,有点额外的消耗。
不同种类的内存序列模型,允许专家利用其提升与更细粒度排序相关操作的性能。当默认使用排序一致序列(相较于其他序列,它是最简单的)时,对于在那些不大重要的情况下是有利的。
选择使用哪个模型,或为了了解与序列相关的代码,为什么选择不同的内存模型,是需要了解一个重要的前提,那就是不同模型是如何影响程序的行为。让我们来看一下选择每个操作序列和同步相关的结果。
默认序列命名为排序一致,是因为程序中的行为从任意角度去看,序列顺序都保持一致。如果原子类型实例上的所有操作都是序列一致的,那么一个多线程程序的行为,就以某种特殊的排序执行,好像单线程那样。这是目前来看,最容易理解的内存序列,这也就是将其设置为默认的原因:所有线程都必须了解,不同的操作也遵守相同的顺序。因为其简单的行为,可以使用原子变量进行编写。通过不同的线程,你可以写出所有序列上可能的操作,这样就可以消除那些不一致,以及验证你代码的行为是否与预期相符。这也就意味着,所有操作都不能重排序;如果你的代码,在一个线程中,将一个操作放在另一个操作前面,那么这个顺序就必须让其他所有的线程所了解。
从同步的角度看,对于同一变量,排序一致的存储操作同步相关于同步一致的载入操作。这就提供了一种对两个(以上)线程操作的排序约束,但是排序一致的功能要比排序约束大的多。所以,对于使用排序一致原子操作的系统上的任一排序一致的原子操作,都会在对值进行存储以后,再进行加载。这种约束不是线程在自由内存序列中使用原子操作;这些线程依旧可以知道,操作以不同顺序排列,所以你必须使用排序一致操作,去保证在多线的情况下有加速的效果。
不过,简单是要付出代价的。在一个多核若排序的机器上,它会加强对性能的惩罚,因为整个序列中的操作都必须在多个处理器上保持一致,可能需要对处理器间的同步操作进行扩展(代价很昂贵!)。即便如此,一些处理器架构(比如通用x86和x86-64架构)就提供了相对廉价的序列一致,所以你需要考虑使用序列一致对性能的影响,这就需要你去查阅你目标处理器的架构文档,进行更多的了解。
以下清单展示了序列一致的行为,对于x和y的加载和存储都显示标注为memory_order_seq_cst,不过在这段代码中,标签可能会忽略,因为其是默认项。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
std::atomic<bool> x,y;
std::atomic<int> z;
void write_x()
{
x.store(true,std::memory_order_seq_cst); // 1
}
void write_y()
{
y.store(true,std::memory_order_seq_cst); // 2
}
void read_x_then_y()
{
while(!x.load(std::memory_order_seq_cst));
if(y.load(std::memory_order_seq_cst)) // 3
++z;
}
void read_y_then_x()
{
while(!y.load(std::memory_order_seq_cst));
if(x.load(std::memory_order_seq_cst)) // 4
++z;
}
int main()
{
x=false;
y=false;
z=0;
std::thread a(write_x);
std::thread b(write_y);
std::thread c(read_x_then_y);
std::thread d(read_y_then_x);
a.join();
b.join();
c.join();
d.join();
assert(z.load()!=0); // 5
}
assert⑤语句是永远不会触发的,因为不是存储x的操作①发生,就是存储y的操作②发生。如果在read_x_then_y中加载y③返回false,那是因为存储x的操作肯定发生在存储y的操作之前,那么在这种情况下在read_y_then_x中加载x④必定会返回true,因为while循环能保证在某一时刻y是true。因为memory_order_seq_cst的语义需要一个单全序将所有操作都标记为memory_order_seq_cst,这就暗示着“加载y并返回false③”与“存储y①”的操作,有一个确定的顺序。只有一个全序时,如果一个线程看到x==true,随后又看到y==false,这就意味着在总序列中存储x的操作发生在存储y的操作之前。
释放队列与同步
通过其他线程,即使有(有序的)多个“读-改-写”操作(所有操作都已经做了适当的标记)在存储和加载操作之间,你依旧可以获取原子变量存储与加载的同步关系。现在,我已经讨论所有可能使用到的内存序列“标签”,我在这里可以做一个简单的概述。当存储操作被标记为memory_order_release,memory_order_acq_rel或memory_order_seq_cst,加载被标记为memory_order_consum,memory_order_acquire或memory_order_sqy_cst,并且操作链上的每一加载操作都会读取之前操作写入的值,因此链上的操作构成了一个释放序列(release sequence),并且初始化存储同步(对应memory_order_acquire或memory_order_seq_cst)或是前序依赖(对应memory_order_consume)的最终加载。操作链上的任何原子“读-改-写”操作可以拥有任意个存储序列(甚至是memory_order_relaxed)。
为了了解这些操作意味着什么,以及其重要性,考虑一个atomic用作对一个共享队列的元素进行计数:
1 |
|
一种处理方式是让线程产生数据,并存储到一个共享缓存中,而后调用count.store(number_of_items, memory_order_release)
①让其他线程知道数据是可用的。线程群消耗着队列中的元素,之后可能调用count.fetch_sub(1, memory_order_acquire)
②向队列索取一个元素,不过在这之前,需要对共享缓存进行完整的读取④。一旦count归零,那么队列中就没有更多的元素了,当元素耗尽时线程必须等待③。
当有一个消费者线程时还好,fetch_sub()
是一个带有memory_order_acquire的读取操作,并且存储操作是带有memory_order_release语义,所以这里存储与加载同步,线程是可以从缓存中读取元素的。当有两个读取线程时,第二个fetch_sub()
操作将看到被第一个线程修改的值,且没有值通过store写入其中。先不管释放序列的规则,这里第二个线程与第一个线程不存在先行关系,并且其对共享缓存中值的读取也不安全,除非第一个fetch_sub()
是带有memory_order_release语义的,这个语义为两个消费者线程间建立了不必要的同步。无论是释放序列的规则,还是带有memory_order_release语义的fetch_sub操作,第二个消费者看到的是一个空的queue_data,无法从其获取任何数据,并且这里还会产生条件竞争。幸运的是,第一个fetch_sub()
对释放顺序做了一些事情,所以store()
能同步与第二个fetch_sub()
操作。这里,两个消费者线程间不需要同步关系。
操作链中可以有任意数量的链接,但是提供的都是“读-改-写”操作,比如fetch_sub(),store(),每一个都会与使用memory_order_acquire语义的操作进行同步。在这里例子中,所有链接都是一样的,并且都是获取操作,但它们可由不同内存序列语义组成的操作混合。(译者:也就是不是单纯的获取操作)
虽然,大多数同步关系,是对原子变量的操作应用了内存序列,但这里依旧有必要额外介绍一个对排序的约束——栅栏(fences)。
栅栏
如果原子操作库缺少了栅栏,那么这个库就是不完整的。栅栏操作会对内存序列进行约束,使其无法对任何数据进行修改,典型的做法是与使用memory_order_relaxed约束序的原子操作一起使用。栅栏属于全局操作,执行栅栏操作可以影响到在线程中的其他原子操作。因为这类操作就像画了一条任何代码都无法跨越的线一样,所以栅栏操作通常也被称为内存栅栏(memory barriers)。不过,栅栏操作就会限制这种自由,并且会介绍之前没有介绍到的“先行”和“同步”关系。
我们给在不同线程上的两个原子操作中添加一个栅栏,代码如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
std::atomic<bool> x,y;
std::atomic<int> z;
void write_x_then_y()
{
x.store(true,std::memory_order_relaxed); // 1
std::atomic_thread_fence(std::memory_order_release); // 2
y.store(true,std::memory_order_relaxed); // 3
}
void read_y_then_x()
{
while(!y.load(std::memory_order_relaxed)); // 4
std::atomic_thread_fence(std::memory_order_acquire); // 5
if(x.load(std::memory_order_relaxed)) // 6
++z;
}
int main()
{
x=false;
y=false;
z=0;
std::thread a(write_x_then_y);
std::thread b(read_y_then_x);
a.join();
b.join();
assert(z.load()!=0); // 7
}
释放栅栏②与获取栅栏⑤同步,这是因为加载y的操作④读取的是在③处存储的值。所以,在①处存储x先行于⑥处加载x,最后x读取出来必为true,并且断言不会被触发⑦。原先不带栅栏的存储和加载x都是无序的,并且断言是可能会触发的。需要注意的是,这两个栅栏都是必要的:你需要在一个线程中进行释放,然后在另一个线程中进行获取,这样才能构建出同步关系。
在这个例子中,如果存储y的操作③标记为memory_order_release,而非memory_order_relaxed的话,释放栅栏②也会对这个操作产生影响。同样的,当加载y的操作④标记为memory_order_acquire时,获取栅栏⑤也会对之产生影响。使用栅栏的一般想法是:当一个获取操作能看到释放栅栏操作后的存储结果,那么这个栅栏就与获取操作同步;并且,当加载操作在获取栅栏操作前,看到一个释放操作的结果,那么这个释放操作同步于获取栅栏。当然,你也可以使用双边栅栏操作,举一个简单的例子,当一个加载操作在获取栅栏前,看到一个值有存储操作写入,且这个存储操作发生在释放栅栏后,那么释放栅栏与获取栅栏是同步的。
虽然,栅栏同步依赖于读取/写入的操作发生于栅栏之前/后,但是这里有一点很重要:同步点,就是栅栏本身。当你执行write_x_then_y,并且在栅栏操作之后对x进行写入,就像下面的代码一样。这里,触发断言的条件就不保证一定为true了,尽管写入x的操作在写入y的操作之前发生。1
2
3
4
5
6void write_x_then_y()
{
std::atomic_thread_fence(std::memory_order_release);
x.store(true,std::memory_order_relaxed);
y.store(true,std::memory_order_relaxed);
}
这里里的两个操作,就不会被栅栏分开,并且也不再有序。只有当栅栏出现在存储x和存储y操作之间,这个顺序是硬性的。当然,栅栏是否存在不会影响任何拥有先行关系的执行序列,这种情况是因为一些其他原子操作。
基于锁的并发数据结构设计
基于锁的并发数据结构
基于锁的并发数据结构设计,需要确保访问线程持有锁的时间最短。对于只有一个互斥量的数据结构来说,这十分困难。需要保证数据不被锁之外的操作所访问到,并且还要保证不会在固有结构上产生条件竞争(如第3章所述)。当你使用多个互斥量来保护数据结构中不同的区域时,问题会暴露的更加明显,当操作需要获取多个互斥锁时,就有可能产生死锁。所以,在设计时,使用多个互斥量时需要格外小心。
栈是一个十分简单的数据结构,它只使用了一个互斥量。但是,这个结构是线程安全的吗?它离真正的并发访问又有多远呢?
线程安全栈——使用锁
我们先把第3章中线程安全的栈拿过来看看:
1 |
|
首先,互斥量m能保证基本的线程安全,那就是对每个成员函数进行加锁保护。这就保证在同一时间内,只有一个线程可以访问到数据,所以能够保证,数据结构的“不变量”被破坏时,不会被其他线程看到。
其次,在empty()
和pop()
成员函数之间会存在潜在的竞争,不过代码会在pop()
函数上锁时,显式的查询栈是否为空,所以这里的竞争是非恶性的。pop()
通过对弹出值的直接返回,就可避免std::stack<>
中top()
和pop()
两成员函数之间的潜在竞争。
再次,这个类中也有一些异常源。对互斥量上锁可能会抛出异常,因为上锁操作是每个成员函数所做的第一个操作,所以这是极其罕见的。因无数据修改,所以其是安全的。因解锁一个互斥量是不会失败的,所以段代码很安全,并且使用std::lock_guard<>
也能保证互斥量上锁的状态。
对data.push()
①的调用可能会抛出一个异常,不是拷贝/移动数据值时,就是内存不足的时候。不管是哪种,std::stack<>
都能保证其实安全的,所以这里也没有问题。
在第一个重载pop()
中,代码可能会抛出一个empty_stack
的异常②,不过数据没有被修改,所以其是安全的。对于res的创建③,也可能会抛出一个异常,这有两方面的原因:对std::make_shared
的调用,可能无法分配出足够的内存去创建新的对象,并且内部数据需要对新对象进行引用;或者,在拷贝或移动构造到新分配的内存中返回时抛出异常。两种情况下,c++运行库和标准库能确保这里不会出现内存泄露,并且新创建的对象(如果有的话)都能被正确销毁。因为没有对栈进行任何修改,所以这里也不会有问题。当调用data.pop()
④时,其能确保不抛出异常,并且返回结果,所以这个重载pop()
函数“异常-安全”。
第二个重载pop()
类似,除了在拷贝赋值或移动赋值的时候会抛出异常⑤,当构造一个新对象和一个std::shared_ptr实例时都不会抛出异常。同样,在调用data.pop()
⑥(这个成员函数保证不会抛出异常)之前,依旧没有对数据结构进行修改,所以这个函数也为“异常-安全”。
最后,empty()
也不会修改任何数据,所以也是“异常-安全”函数。
当调用持有一个锁的用户代码时,这里有两个地方可能会产生死锁:进行拷贝构造或移动构造(①,③)和在对数据项进行拷贝赋值或移动赋值操作⑤的时候;还有一个潜在死锁的地方在于用户定义的操作符new。当这些函数,无论是以直接调用栈的成员函数的方式,还是在成员函数进行操作时,对已经插入或删除的数据进行操作的方式,对锁进行获取,都可能造成死锁。不过,用户要对栈负责,当栈未对一个数据进行拷贝或分配时,用户就不能想当然的将其添加到栈中。
所有成员函数都使用st::lack_guard<>来保护数据,所以栈的成员函数能有“线程安全”的表现。当然,构造与析构函数不是“线程安全”的,不过这也不成问题,因为对实例的构造与析构只能有一次。调用一个不完全构造对象或是已销毁对象的成员函数,无论在那种编程方式下,都不可取。所以,用户就要保证在栈对象完成构建前,其他线程无法对其进行访问;并且,一定要保证在栈对象销毁后,所有线程都要停止对其进行访问。
即使在多线程情况下,并发的调用成员函数是安全的(因为使用锁),也要保证在单线程的情况下,数据结构做出正确反应。序列化线程会隐性的限制程序性能,这就是栈争议声最大的地方:当一个线程在等待锁时,它就会无所事事。同样的,对于栈来说,等待添加元素也是没有意义的,所以当一个线程需要等待时,其会定期检查empty()或pop(),以及对empty_stack异常进行关注。这样的现实会限制栈的实现的方式,在线程等待的时候,会浪费宝贵的资源去检查数据,或是要求用户写写外部等待和提示代码(例如,使用条件变量),这就使内部锁失去存在的意义——这就意味着资源的浪费。
无锁并发数据结构设计
定义和意义
使用互斥量、条件变量,以及“期望”来同步阻塞数据的算法和数据结构。应用调用库函数,将会挂起一个执行线程,直到其他线程完成某个特定的动作。库函数将调用阻塞操作来对线程进行阻塞,在阻塞移除前,线程无法继续自己的任务。通常,操作系统会完全挂起一个阻塞线程(并将其时间片交给其他线程),直到其被其他线程“解阻塞”;“解阻塞”的方式很多,比如解锁一个互斥锁、通知条件变量达成,或让“期望”就绪。
不使用阻塞库的数据结构和算法,被称为无阻塞结构。不过,无阻塞的数据结构并非都是无锁的,那么就让我们见识一下各种各样的无阻塞数据结构吧!
非阻塞数据结构
在第5章中,我们使用std::atomic_flag
实现了一个简单的自旋锁。一起回顾一下这段代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class spinlock_mutex
{
std::atomic_flag flag;
public:
spinlock_mutex():
flag(ATOMIC_FLAG_INIT)
{}
void lock()
{
while(flag.test_and_set(std::memory_order_acquire));
}
void unlock()
{
flag.clear(std::memory_order_release);
}
};
这段代码没有调用任何阻塞函数,lock()
只是让循环持续调用test_and_set()
,并返回false。这就是为什么取名为“自旋锁”的原因——代码“自旋”于循环当中。所以,这里没有阻塞调用,任意代码使用互斥量来保护共享数据都是非阻塞的。不过,自旋锁并不是无锁结构。这里用了一个锁,并且一次能锁住一个线程。让我们来看一下无锁结构的具体定义,这将有助于你判断哪些类型的数据结构是无锁的。
无锁数据结构
作为无锁结构,就意味着线程可以并发的访问这个数据结构。线程不能做相同的操作;一个无锁队列可能允许一个线程进行压入数据,另一个线程弹出数据,当有两个线程同时尝试添加元素时,这个数据结构将被破坏。不仅如此,当其中一个访问线程被调度器中途挂起时,其他线程必须能够继续完成自己的工作,而无需等待挂起线程。
具有“比较/交换”操作的数据结构,通常在“比较/交换”实现中都有一个循环。使用“比较/交换”操作的原因:当有其他线程同时对指定数据的修改时,代码将尝试恢复数据。当其他线程被挂起时,“比较/交换”操作执行成功,那么这样的代码就是无锁的。当执行失败时,就需要一个自旋锁了,且这个结构就是“非阻塞-有锁”的结构。
无锁算法中的循环会让一些线程处于“饥饿”状态。如有线程在“错误”时间执行,那么第一个线程将会不停得尝试自己所要完成的操作(其他程序继续执行)。“无锁-无等待”数据结构,就为了避免这种问题存在的。
无等待数据结构
无等待数据结构就是:首先,是无锁数据结构;并且,每个线程都能在有限的步数内完成操作,暂且不管其他线程是如何工作的。由于会和别的线程产生冲突,所以算法可以进行无数次尝试,因此并不是无等待的。
正确实现一个无锁的结构是十分困难的。因为,要保证每一个线程都能在有限步骤里完成操作,就需要保证每一个操作可以被一次性执行完成;当有线程执行某个操作时,不会让其他线程的操作失败。这就会让算法中所使用到的操作变的相当复杂。
考虑到获取无锁或无等待的数据结构所有权都很困难,那么就有理由来写一个数据结构了;需要保证的是,所要得获益要大于实现成本。那么,就先来找一下实现成本和所得获益的平衡点吧!
无锁数据结构的利与弊
使用无锁结构的主要原因:将并发最大化。使用基于锁的容器,会让线程阻塞或等待;互斥锁削弱了结构的并发性。在无锁数据结构中,某些线程可以逐步执行。在无等待数据结构中,无论其他线程当时在做什么,每一个线程都可以转发进度。这种理想的方式实现起来很难。结构太简单,反而不容易写,因为其就是一个自旋锁。
使用无锁数据结构的第二个原因就是鲁棒性。当一个线程在获取一个锁时被杀死,那么数据结构将被永久性的破坏。不过,当线程在无锁数据结构上执行操作,在执行到一半死亡时,数据结构上的数据没有丢失(除了线程本身的数据),其他线程依旧可以正常执行。
另一方面,当不能限制访问数据结构的线程数量时,就需要注意不变量的状态,或选择替代品来保持不变量的状态。同时,还需要注意操作的顺序约束。为了避免未定义行为,及相关的数据竞争,就必须使用原子操作对修改操作进行限制。不过,仅使用原子操作时不够的;需要确定被其他线程看到的修改,是遵循正确的顺序。
因为,没有任何锁(有可能存在活锁),死锁问题不会困扰无锁数据结构。活锁的产生是,两个线程同时尝试修改数据结构,但每个线程所做的修改操作都会让另一个线程重启,所以两个线程就会陷入循环,多次的尝试完成自己的操作。试想有两个人要过独木桥,当两个人从两头向中间走的时候,他们会在中间碰到,然后不得不再走回出发的地方,再次尝试过独木桥。这里,要打破僵局,除非有人先到独木桥的另一端(或是商量好了,或是走的快,或纯粹是运气),要不这个循环将一直重复下去。不过活锁的存在时间并不久,因为其依赖于线程调度。所以其只是对性能有所消耗,而不是一个长期的问题;但这个问题仍需要关注。根据定义,无等待的代码不会被活锁所困扰,因其操作执行步骤是有上限的。换个角度,无等待的算法要比等待算法的复杂度高,且即使没有其他线程访问数据结构,也可能需要更多步骤来完成对应操作。
这就是“无锁-无等待”代码的缺点:虽然提高了并发访问的能力,减少了单个线程的等待时间,但是其可能会将整体性能拉低。首先,原子操作的无锁代码要慢于无原子操作的代码,原子操作就相当于无锁数据结构中的锁。不仅如此,硬件必须通过同一个原子变量对线程间的数据进行同步。在第8章,你将看到与“乒乓”缓存相关的原子变量(多个线程访问同时进行访问),将会成为一个明显的性能瓶颈。在提交代码之前,无论是基于锁的数据结构,还是无锁的数据结构,对性能的检查是很重要的(最坏的等待时间,平均等待时间,整体执行时间,或者其他指标)。
无锁数据结构的例子
为了演示一些在设计无锁数据结构中所使用到的技术,我们将看到一些无锁实现的简单数据结构。这里不仅要在每个例子中描述一个有用的数据结构实现,还将使用这些例子的某些特别之处来阐述对于无锁数据结构的设计。
如之前所提到的,无锁结构依赖与原子操作和内存序及相关保证,以确保多线程以正确的顺序访问数据结构。最初,所有原子操作默认使用的是memory_order_seq_cst内存序;因为简单,所以使用(所有memory_order_seq_cst都遵循一种顺序)。不过,在后面的例子中,我们将会降低内存序的要求,使用memory_order_acquire, memory_order_release, 甚至memory_order_relaxed。虽然这个例子中没有直接的使用锁,但需要注意的是对std::atomic_flag的使用。一些平台上的无锁结构实现,使用了内部锁。
写一个无锁的线程安全栈
栈的要求很简单:查询顺序是添加顺序的逆序——先入后出(LIFO)。所以,要确保一个值安全的添加入栈就十分重要,因为很可能在添加后,马上被其他线程索引,同时确保只有一个线程能索引到给定值也是很重要。最简单的栈就是链表,head指针指向第一个节点(可能是下一个被索引到的节点),并且每个节点依次指向下一个节点。
在这样的情况下,添加一个节点相对来说很简单:
- 创建一个新节点。
- 将当新节点的next指针指向当前的head节点。
- 让head节点指向新节点。
至关重要的是,当有两个线程同时添加节点的时候,在第2步和第3步的时候会产生条件竞争:一个线程可能在修改head的值时,另一个线程正在执行第2步,并且在第3步中对head进行更新。
OK,那如何应对讨厌的条件竞争呢?答案就是:在第3步的时候使用一个原子“比较/交换”操作,来保证当步骤2对head进行读取时,不会对head进行修改。当有修改时,可以循环“比较/交换”操作。下面的代码就展示了,不用锁来实现线程安全的push()函数。
1 | template<typename T> |
上面代码近乎能匹配之前所说的三个步骤:创建一个新节点②,设置新节点的next指针指向当前head③,并设置head指针指向新节点④。node结构用其自身的构造函数来进行数据填充①,必须保证节点在构造完成后随时能被弹出。之后需要使用compare_exchange_weak()来保证在被存储到new_node->next的head指针和之前的一样③。代码的亮点是使用“比较/交换”操作:当其返回false时,因为比较失败(例如,head被其他线程锁修改),new_node->next作为操作的第一个参数,将会更新head。循环中不需要每次都重新加载head指针,因为编译器会帮你完成这件事。同样,因为循环可能直接就失败了,所以这里使用compare_exchange_weak要好于使用compare_exchange_strong。
所以,这里暂时不需要pop()操作,可以先快速检查一下push()的实现是否有违指导意见。这里唯一一个能抛出异常的地方就构造新node的时候①,不过其会自行处理,且链表中的内容没有被修改,所以这里是安全的。因为在构建数据的时候,是将其作为node的一部分作为存储的,并且使用compare_exchange_weak()来更新head指针,所以这里没有恶性的条件竞争。“比较/交换”成功时,节点已经准备就绪,且随时可以提取。因为这里没有锁,所以就不存在死锁的情况,这里的push()函数实现的很成功。
那么,你现在已经有往栈中添加数据的方法了,现在需要删除数据的方法。其步骤如下,也很简单:
- 读取当前head指针的值。
- 读取head->next。
- 设置head到head->next。
- 通过索引node,返回data数据。
- 删除索引节点。
但在多线程环境下,就不像看起来那么简单了。当有两个线程要从栈中移除数据,两个线程可能在步骤1中读取到同一个head(值相同)。当其中一个线程处理到步骤5,而另一个线程还在处理步骤2时,这个还在处理步骤2的线程将会解引用一个悬空指针。这只是写无锁代码所遇到的最大问题之一,所以现在只能跳过步骤5,让节点泄露。
另一个问题就是:当两个线程读取到同一个head值,他们将返回同一个节点。这就违反了栈结构的意图,所以你需要避免这样的问题产生。你可以像在push()函数中解决条件竞争那样来解决这个问题:使用“比较/交换”操作更新head。当“比较/交换”操作失败时,不是一个新节点已被推入,就是其他线程已经弹出了想要弹出的节点。无论是那种情况,都得返回步骤1(“比较/交换”操作将会重新读取head)。
当“比较/交换”成功,就可以确定当前线程是弹出给定节点的唯一线程,之后就可以放心的执行步骤4了。这里先看一下pop()的雏形:1
2
3
4
5
6
7
8
9
10
11template<typename T>
class lock_free_stack
{
public:
void pop(T& result)
{
node* old_head=head.load();
while(!head.compare_exchange_weak(old_head,old_head->next));
result=old_head->data;
}
};
虽然这段代码很优雅,但这里还有两个节点泄露的问题。首先,这段代码在空链表的时候不工作:当head指针式一个空指针时,当要访问next指针时,将引起未定义行为。这很容易通过对nullptr的检查进行修复(在while循环中),要不对空栈抛出一个异常,要不返回一个bool值来表明成功与否。
第二个问题就是异常安全问题。当在第3章中介绍栈结构时,了解了在返回值的时候会出现异常安全问题:当有异常被抛出时,复制的值将丢失。在这种情况下,传入引用是一种可以接受的解决方案;因为这样就能保证,当有异常抛出时,栈上的数据不会丢失。不幸的是,不能这样做;只能在单一线程对值进行返回的时候,才进行拷贝,以确保拷贝操作的安全性,这就意味着在拷贝结束后这个节点就被删除了。因此,通过引用获取返回值的方式就没有任何优势:直接返回也是可以的。若想要安全的返回值,你必须使用第3章中的其他方法:返回指向数据值的(智能)指针。
当返回的是智能指针时,就能返回nullptr以表明没有值可返回,但是要求在堆上对智能指针进行内存分配。将分配过程做为pop()的一部分时(也没有更好的选择了),堆分配时可能会抛出一个异常。与此相反,可以在push()操作中对内存进行分配——无论怎样,都得对node进行内存分配。返回一个std::shared_ptr<>
不会抛出异常,所以在pop()中进行分配就是安全的。将上面的观点放在一起,就能看到如下的代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28template<typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data; // 1 指针获取数据
node* next;
node(T const& data_):
data(std::make_shared<T>(data_)) // 2 让std::shared_ptr指向新分配出来的T
{}
};
std::atomic<node*> head;
public:
void push(T const& data)
{
node* const new_node=new node(data);
new_node->next=head.load();
while(!head.compare_exchange_weak(new_node->next,new_node));
}
std::shared_ptr<T> pop()
{
node* old_head=head.load();
while(old_head && // 3 在解引用前检查old_head是否为空指针
!head.compare_exchange_weak(old_head,old_head->next));
return old_head ? old_head->data : std::shared_ptr<T>(); // 4
}
};
智能指针指向当前数据①,这里必须在堆上为数据分配内存(在node结构体中)②。而后,在compare_exchage_weak()
循环中③,需要在old_head指针前,检查指针是否为空。最终,如果存在相关节点,那么将会返回相关节点的值;当不存在时,将返回一个空指针④。注意,结构是无锁的,但并不是无等待的,因为在push()
和pop()
函数中都有while循环,当compare_exchange_weak()
总是失败的时候,循环将会无限循环下去。
停止内存泄露:使用无锁数据结构管理内存
第一次了解pop()
时,为了避免条件竞争选择了带有内存泄露的节点。但是,不论什么样的C++程序,存在内存泄露都不可接受。所以,现在来解决这个问题!
基本问题在于,当要释放一个节点时,需要确认其他线程没有持有这个节点。当只有一个线程调用pop()
,就可以放心的进行释放。当节点添加入栈后,push()
就不会与节点有任何的关系了,所以只有调用pop()
函数的线程与已加入节点有关,并且能够安全的将节点删除。
另一方面,当栈同时处理多线程对pop()
的调用时,就需要知道节点在什么时候被删除。这实际上就需要你写一个节点专用的垃圾收集器。这听起来有些可怖,同时也相当棘手,不过并不是多么糟糕:这里需要检查节点,并且检查哪些节点被pop()
访问。不需要对push()中的节点有所担心,因为这些节点推到栈上以后,才能被访问到,而多线程只能通过pop()
访问同一节点。
当没有线程调用pop()
时,这时可以删除栈上的任意节点。因此,当添加节点到“可删除”列表中时,就能从中提取数据了。而后,当没有线程通过pop()
访问节点时,就可以安全的删除这些节点了。那怎么知道没有线程调用pop()
了呢?很简单——计数即可。当计数器数值增加时,就是有节点推入;当减少时,就是有节点被删除。这样从“可删除”列表中删除节点就很安全了,直到计数器的值为0为止。当然,这个计数器必须是原子的,这样它才能在多线程的情况下正确的进行计数。下面的清单中,展示了修改后的pop()
函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22template<typename T>
class lock_free_stack
{
private:
std::atomic<unsigned> threads_in_pop; // 1 原子变量
void try_reclaim(node* old_head);
public:
std::shared_ptr<T> pop()
{
++threads_in_pop; // 2 在做事之前,计数值加1
node* old_head=head.load();
while(old_head &&
!head.compare_exchange_weak(old_head,old_head->next));
std::shared_ptr<T> res;
if(old_head)
{
res.swap(old_head->data); // 3 回收删除的节点
}
try_reclaim(old_head); // 4 从节点中直接提取数据,而非拷贝指针
return res;
}
};
threads_in_pop
①原子变量用来记录有多少线程试图弹出栈中的元素。当pop()
②函数调用的时候,计数器加一;当调用try_reclaim()
时,计数器减一,当这个函数被节点调用时,说明这个节点已经被删除④。因为暂时不需要将节点删除,可以通过swap()函数来删除节点上的数据③(而非只是拷贝指针),当不再需要这些数据的时候,这些数据会自动删除,而不是持续存在着。接下来看一下try_reclaim()
是如何实现的。
1 | template<typename T> |
回收节点时①,threads_in_pop的数值是1,也就是当前线程正在对pop()进行访问,这时就可以安全的将节点进行删除了⑦(将等待节点删除也是安全的)。当数值不是1时,删除任何节点都不安全,所以需要向等待列表中继续添加节点⑧。
假设在某一时刻,threads_in_pop的值为1。那就可以尝试回收等待列表,如果不回收,节点就会继续等待,直到整个栈被销毁。要做到回收,首先要通过一个原子exchange操作声明②删除列表,并将计数器减一③。如果之后计数的值为0,就意味着没有其他线程访问等待节点链表。出现新的等待节点时,不必为其烦恼,因为它们将被安全的回收。而后,可以使用delete_nodes对链表进行迭代,并将其删除④。
当计数值在减后不为0,回收节点就不安全;所以如果存在⑤,就需要将其挂在等待删除链表之后⑥,这种情况会发生在多个线程同时访问数据结构的时候。一些线程在第一次测试threads_in_pop①和对“回收”链表的声明②操作间调用pop(),这可能新填入一个已经被线程访问的节点到链表中。在图7.1中,线程C添加节点Y到to_be_deleted链表中,即使线程B仍将其引用作为old_head,之后会尝试访问其next指针。在线程A删除节点的时候,会造成线程B发生未定义的行为。
为了将等待删除的节点添加入等待删除链表,需要复用节点的next指针将等待删除节点链接在一起。在这种情况下,将已存在的链表链接到删除链表后面,通过遍历的方式找到链表的末尾⑨,将最后一个节点的next指针替换为当前to_be_deleted指针⑩,并且将链表中的第一个节点作为新的to_be_deleted指针进行存储⑪。这里需要在循环中使用compare_exchange_weak来保证,通过其他线程添加进来的节点不会发生内存泄露。这样,在链表发生改变时,更新next指针很方便。添加单个节点是一种特殊情况,因为这需要将这个节点作为第一个节点,同时也是最后一个节点进行添加⑫。
在低负荷的情况下,这种方式没有问题,因为在没有线程访问pop(),有一个合适的静态指针。不过,这只是一个瞬时的状态,也就是为什么在回收前,需要检查threads_in_pop计数为0③的原因;同样也是删除节点⑦前进行对计数器检查的原因。删除节点是一项耗时的工作,并且希望其他线程能对链表做的修改越小越好。从第一次发现threads_in_pop是1,到尝试删除节点,会用很长的时间,这样就会让线程有机会调用pop(),会让threads_in_pop不为0,阻止节点的删除操作。
检测使用风险指针(不可回收)的节点
因为删除一个节点可能会让其他引用其的线程处于危险之中。当其他线程持有这个删除的节点的指针,并且解引用进行操作的时候,将会出现未定义行为。这里的基本观点就是,当有线程去访问要被(其他线程)删除的对象时,会先设置对这个对象设置一个风险指针,而后通知其他线程,删除这个指针是一个危险的行为。一旦这个对象不再被需要,那么就可以清除风险指针了。
当线程想要删除一个对象,那么它就必须检查系统中其他线程是否持有风险指针。当没有风险指针的时候,那么它就可以安全删除对象。否则,它就必须等待风险指针的消失了。这样,线程就得周期性的检查其想要删除的对象是否能安全删除。
首先,需要一个地点能存储指向访问对象的指针,这个地点就是风险指针。这个地点必须能让所有线程看到,需要其中一些线程可以对数据结构进行访问。如何正确和高效的分配这些线程,的确是一个挑战,所以这个问题可以放在后面解决,而后假设你有一个get_hazard_pointer_for_current_thread()
的函数,这个函数可以返回风险指针的引用。当你读取一个指针,并且想要解引用它的时候,你就需要这个函数——在这种情况下head数值源于下面的列表:1
2
3
4
5
6
7
8
9
10
11
12
13std::shared_ptr<T> pop()
{
std::atomic<void*>& hp=get_hazard_pointer_for_current_thread();
node* old_head=head.load(); // 1
node* temp;
do
{
temp=old_head;
hp.store(old_head); // 2
old_head=head.load();
} while(old_head!=temp); // 3
// ...
}
在while循环中就能保证node不会在读取旧head指针①时,以及在设置风险指针的时被删除了。这种模式下,其他线程不知道有线程对这个给定的节点进行了访问。幸运的是,当旧head节点要被删除时,head本身是要改变的,所以需要对head进行检查,并持续循环,直到head指针中的值与风险指针中的值相同③。使用风险指针,如同依赖对已删除对象的引用。当使用默认的new和delete操作对风险指针进行操作时,会出现未定义行为,所以需要确定实现是否支持这样的操作,或使用自定义分配器来保证这种用法的正确性。
现在已经设置了风险指针,那就可以对pop()
进行处理了,基于现在了解到的安全知识,这里不会有其他线程来删除节点。啊哈!这里每一次重新加载old_head时,解引用刚刚读取到的指针时,就需要更新风险指针。当从链表中提取一个节点时,就可以将风险指针清除了。如果没有其他风险指针引用节点,就可以安全的删除节点了;否则,就需要将其添加到链表中,之后再将其删除。下面的代码就是对该方案的完整实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33std::shared_ptr<T> pop()
{
std::atomic<void*>& hp=get_hazard_pointer_for_current_thread();
node* old_head=head.load();
do
{
node* temp;
do // 1 直到将风险指针设为head指针
{
temp=old_head;
hp.store(old_head);
old_head=head.load();
} while(old_head!=temp);
}
while(old_head &&
!head.compare_exchange_strong(old_head,old_head->next));
hp.store(nullptr); // 2 当声明完成,清除风险指针
std::shared_ptr<T> res;
if(old_head)
{
res.swap(old_head->data);
if(outstanding_hazard_pointers_for(old_head)) // 3 在删除之前对风险指针引用的节点进行检查
{
reclaim_later(old_head); // 4
}
else
{
delete old_head; // 5
}
delete_nodes_with_no_hazards(); // 6
}
return res;
}
首先,循环内部会对风险指针进行设置,在当“比较/交换”操作失败会重载old_head,再次进行设置①。使用compare_exchange_strong(),是因为需要在循环内部做一些实际的工作:当compare_exchange_weak()伪失败后,风险指针将被重置(没有必要)。这个过程能保证风险指针在解引用(old_head)之前,能被正确的设置。当已声明了一个风险指针,那么就可以将其清除了②。如果想要获取一个节点,就需要检查其他线程上的风险指针,检查是否有其他指针引用该节点③。如果有,就不能删除节点,只能将其放在链表中,之后再进行回收④;如果没有,就能直接将这个节点删除了⑤。最后,如果需要对任意节点进行检查,可以调用reclaim_later()。如果链表上没有任何风险指针引用节点,就可以安全的删除这些节点⑥。当有节点持有风险指针,就只能让下一个调用pop()的线程离开。
当然,这些函数——get_hazard_pointer_for_current_thread()
, reclaim_later()
, outstanding_hazard_pointers_for()
, 和delete_nodes_with_no_hazards()
——的实现细节我们还没有看到,先来看看它们是如何工作的。
为线程分配风险指针实例的具体方案:使用get_hazard_pointer_for_current_thread()
与程序逻辑的关系并不大(不过会影响效率,接下会看到具体的情况)。可以使用一个简单的结构体:固定长度的“线程ID-指针”数组。get_hazard_pointer_for_curent_thread()
就可以通过这个数据来找到第一个释放槽,并将当前线程的ID放入到这个槽中。当线程退出时,槽就再次置空,可以通过默认构造std::thread::id()
将线程ID放入槽中。这个实现就如下所示:
1 | unsigned const max_hazard_pointers=100; |
get_hazard_pointer_for_current_thread()
的实现看起来很简单③:一个hp_owner
④类型的thread_local(本线程所有)变量,用来存储当前线程的风险指针,可以返回这个变量所持有的指针⑤。之后的工作:第一次有线程调用这个函数时,新hp_owner实例就被创建。这个实例的构造函数⑥,会通过查询“所有者/指针”表,寻找没有所有者的记录。其用compare_exchange_strong()
来检查某个记录是否有所有者,并进行析构②。当compare_exchange_strong()
失败,其他线程的拥有这个记录,所以可以继续执行下去。当交换成功,当前线程就拥有了这条记录,而后对其进行存储,并停止搜索⑦。当遍历了列表也没有找到物所有权的记录①,就说明有很多线程在使用风险指针,所以这里将抛出一个异常。
一旦hp_owner
实例被一个给定的线程所创建,那么之后的访问将会很快,因为指针在缓存中,所以表不需要再次遍历。
当线程退出时,hp_owner
的实例将会被销毁。析构函数会在std::thread::id()
设置拥有者ID前,将指针重置为nullptr,这样就允许其他线程对这条记录进行复用⑧⑨。
实现get_hazard_pointer_for_current_thread()
后,outstanding_hazard_pointer_for()
实现就简单了:只需要对风险指针表进行搜索,就可以找到对应记录。1
2
3
4
5
6
7
8
9
10
11bool outstanding_hazard_pointers_for(void* p)
{
for(unsigned i=0;i<max_hazard_pointers;++i)
{
if(hazard_pointers[i].pointer.load()==p)
{
return true;
}
}
return false;
}
实现都不需要对记录的所有者进行验证:没有所有者的记录会是一个空指针,所以比较代码将总返回false,通过这种方式将代码简化。
reclaim_later()
和delete_nodes_with_no_hazards()
可以对简单的链表进行操作;reclaim_later()
只是将节点添加到列表中,delete_nodes_with_no_hazards()
就是搜索整个列表,并将无风险指针的记录进行删除。下面将展示它们的具体实现。
1 | template<typename T> |
首先,reclaim_later()
是一个函数模板④。因为风险指针是一个通用解决方案,所以这里就不能将栈节点的类型写死。使用std::atomic<void*>
对风险指针进行存储。需要对任意类型的指针进行处理,不过不能使用void*
形式,因为当要删除数据项时,delete操作只能对实际类型指针进行操作。data_to_reclaim
的构造函数处理的就很优雅:reclaim_later()
只是为指针创建一个data_to_reclaim
的实例,并且将实例添加到回收链表中⑤。add_to_reclaim_list()
③就是使用compare_exchange_weak()
循环来访问链表头(就如你之前看到的那样)。
当将节点添加入链表时,data_to_reclaim
的析构函数不会被调用;析构函数会在没有风险指针指向节点的时候调用,这也就是delete_nodes_with_no_hazards()
的作用。
delete_nodes_with_no_hazards()
将已声明的链表节点进行回收,使用的是exchange()
函数⑥(这个步骤简单且关键,是为了保证只有一个线程回收这些节点)。这样,其他线程就能自由将节点添加到链表中,或在不影响回收指定节点线程的情况下,对节点进行回收。
只要有节点存在于链表中,就需要检查每个节点,查看节点是否被风险指针所指向⑦。如果没有风险指针,那么就可以安全的将记录删除(并且清除存储的数据)⑧。否则,就只能将这个节点添加到链表的后面,再进行回收⑨。
虽然这个实现很简单,也的确安全的回收了被删除的节点,不过这个过程增加了很多开销。遍历风险指针数组需要检查max_hazard_pointers
原子变量,并且每次pop()
调用时,都需要再检查一遍。原子操作很耗时——在台式CPU上,100次原子操作要比100次非原子操作慢——所以,这里pop()
成为了性能瓶颈。这种方式,不仅需要遍历节点的风险指针链表,还要遍历等待链表上的每一个节点。显然,这种方式很糟糕。当有max_hazard_pointers
在链表中,那么就需要检查max_hazard_pointers
多个已存储的风险指针。
对风险指针(较好)的回收策略
当然有更好的办法。这里只展示一个风险指针的简单实现,来帮助解释技术问题。首先,要考虑的是内存性能。比起对回收链表上的每个节点进行检查都要调用pop()
,除非有超过max_hazard_pointer
数量的节点存在于链表之上,要不就不需要尝试回收任何节点。这样就能保证至少有一个节点能够回收,如果只是等待链表中的节点数量达到max_hazard_pointers+1
,那比之前的方案也没好到哪里去。当获取了max_hazard_pointers
数量的节点时,可以调用pop()
对节点进行回收,所以这样也不是很好。不过,当有2max_hazard_pointers
个节点在列表中时,就能保证至少有max_hazard_pointers
可以被回收,在再次尝试回收任意节点前,至少会对pop()
有max_hazard_pointers
次调用。这就很不错了。比起检查max_hazard_pointers
个节点就调用max_hazard_pointers
次pop()
(而且还不一定能回收节点),当检查2max_hazard_pointers
个节点时,每max_hazard_pointers
次对pop()
的调用,就会有max_hazard_pointers
个节点能被回收。这就意味着,对两个节点检查调用pop()
,其中就有一个节点能被回收。
这个方法有个缺点(有增加内存使用的情况):就是得对回收链表上的节点进行计数,这就意味着要使用原子变量,并且还有很多线程争相对回收链表进行访问。如果还有多余的内存,可以增加内存的使用来实现更好的回收策略:每个线程中的都拥有其自己的回收链表,作为线程的本地变量。这样就不需要原子变量进行计数了。这样的话,就需要分配max_hazard_pointers x max_hazard_pointers
个节点。所有节点被回收完毕前时,有线程退出,那么其本地链表可以像之前一样保存在全局中,并且添加到下一个线程的回收链表中,让下一个线程对这些节点进行回收。
应用于无锁栈上的内存模型
在修改内存序之前,需要检查一下操作之间的依赖关系。而后,再去确定适合这种需求关系的最小内存序。为了保证这种方式能够工作,需要在从线程的视角进行观察。其中最简单的视角就是,向栈中推入一个数据项,之后让其他线程从栈中弹出这个数据。
即使在简单的例子中,都需要三个重要的数据参与。1、counted_node_ptr转移的数据head。2、head引用的node。3、节点所指向的数据项。
做push()
的线程,会先构造数据项和节点,再设置head。做pop()
的线程,会先加载head的值,再做在循环中对head做“比较/交换”操作,并增加引用计数,再读取对应的node节点,获取next的指向的值,现在就可以看到一组需求关系。next的值是普通的非原子对象,所以为了保证读取安全,这里必须确定存储(推送线程)和加载(弹出线程)的先行关系。因为唯一的原子操作就是push()
函数中的compare_exchange_weak()
,这里需要释放操作来获取两个线程间的先行关系,这里compare_exchange_weak()
必须是std::memory_order_release
或更严格的内存序。当compare_exchange_weak()
调用失败,什么都不会改变,并且可以持续循环下去,所以使用std::memory_order_relaxed
就足够了。1
2
3
4
5
6
7
8
9void push(T const& data)
{
counted_node_ptr new_node;
new_node.ptr=new node(data);
new_node.external_count=1;
new_node.ptr->next=head.load(std::memory_order_relaxed)
while(!head.compare_exchange_weak(new_node.ptr->next,new_node,
std::memory_order_release,std::memory_order_relaxed));
}
那pop()
的实现呢?为了确定先行关系,必须在访问next值之前使用std::memory_order_acquire
或更严格内存序的操作。因为,在increase_head_count()
中使用compare_exchange_strong()
就获取next指针指向的旧值,所以想要其获取成功就需要确定内存序。如同调用push()
那样,当交换失败,循环会继续,所以在失败的时候使用松散的内存序:1
2
3
4
5
6
7
8
9
10
11
12
13
14void increase_head_count(counted_node_ptr& old_counter)
{
counted_node_ptr new_counter;
do
{
new_counter=old_counter;
++new_counter.external_count;
}
while(!head.compare_exchange_strong(old_counter,new_counter,
std::memory_order_acquire,std::memory_order_relaxed));
old_counter.external_count=new_counter.external_count;
}
当compare_exchange_strong()
调用成功,那么ptr中的值就被存到old_counter中。存储操作是push()
中的一个释放操作,并且compare_exchange_strong()
操作是一个获取操作,现在存储同步于加载,并且能够获取先行关系。因此,在push()
中存储ptr的值,要先行于在pop()
中对ptr->next的访问。现在的操作就安全了。
要注意的是,内存序对head.load()
的初始化并不妨碍分析,所以现在就可以使用std::memory_order_relaxed
。
接下来,compare_exchange_strong()
将old_head.ptr->next
设置为head。是否需要做什么来保证操作线程中的数据完整性呢?当交换成功,你就能访问ptr->data
,所以这里需要保证在push()
线程中已经对ptr->data进行了存储(在加载之前)。在increase_head_count()
中的获取操作,能保证与push()
线程中的存储和“比较/交换”同步。这里的先行关系是:在push()线程中存储数据,先行于存储head指针;调用increase_head_count()
先行于对ptr->data的加载。即使,pop()
中的“比较/交换”操作使用std::memory_order_relaxed
,这些操作还是能正常运行。唯一不同的地方就是,调用swap()
让ptr->data
有所变化,且没有其他线程可以对同一节点进行操作(这就是“比较/交换”操作的作用)。
当compare_exchange_strong()
失败,那么新值就不会去更新old_head,继续循环。这里,已确定在increase_head_count()
中使用std::memory_order_acquire
内存序的可行性,所以这里使用std::memory_order_relaxed
也可以。
其他线程呢?是否需要设置一些更为严格的内存序来保证其他线程的安全呢?回答是“不用”。因为,head只会因“比较/交换”操作有所改变;对于“读-改-写”操作来说,push()
中的“比较/交换”操作是构成释放序列的一部分。因此,即使有很多线程在同一时间对head进行修改,push()
中的compare_exchange_weak()
与increase_head_count()
(读取已存储的值)中的compare_exchange_strong()
也是同步的。
剩余操作就可以用来处理fetch_add()
操作(用来改变引用计数的操作),因为已知其他线程不可能对该节点的数据进行修改,所以从节点中返回数据的线程可以继续执行。不过,当线程获取其他线程修改后的值时,就代表操作失败(swap()
是用来提取数据项的引用)。那么,为了避免数据竞争,需要保证swap()
先行于delete操作。一种简单的解决办法,在“成功返回”分支中对fetch_add()
使用std::memory_order_release
内存序,在“再次循环”分支中对fetch_add()
使用std::memory_order_qcquire
内存序。不过,这就有点矫枉过正:只有一个线程做delete操作(将引用计数设置为0的线程),所以只有这个线程需要获取操作。幸运的是,因为fetch_add()
是一个“读-改-写”操作,是释放序列的一部分,所以可以使用一个额外的load()
做获取。当“再次循环”分支将引用计数减为0时,fetch_add()
可以重载引用计数,这里使用std::memory_order_acquire
为了保持需求的同步关系;并且,fetch_add()
本身可以使用std::memory_order_relaxed
。使用新pop()
的栈实现如下。
1 | template<typename T> |
并发代码设计
线程间划分工作的技术
递归划分
快速排序有两个最基本的步骤:将数据划分到中枢元素之前或之后,然后对中枢元素之前和之后的两半数组再次进行快速排序。这里不能通过对数据的简单划分达到并行,因为,只有在一次排序结束后,才能知道哪些项在中枢元素之前和之后。当要对这种算法进行并行化,很自然的会想到使用递归。每一级的递归都会多次调用quick_sort函数,因为需要知道哪些元素在中枢元素之前和之后。递归调用是完全独立的,因为其访问的是不同的数据集,并且每次迭代都能并发执行。比起对大于和小于的数据块递归调用函数,使用std::async()
可以为每一级生成小于数据块的异步任务。使用std::async()
时,C++线程库就能决定何时让一个新线程执行任务,以及同步执行任务。
重要的是:对一个很大的数据集进行排序时,当每层递归都产生一个新线程,最后就会产生大量的线程。你会看到其对性能的影响,如果有太多的线程存在,那么你的应用将会运行的很慢。如果数据集过于庞大,会将线程耗尽。那么在递归的基础上进行任务的划分,就是一个不错的主意;你只需要将一定数量的数据打包后,交给线程即可。std::async()
可以出里这种简单的情况,不过这不是唯一的选择。
另一种选择是使用std::thread::hardware_concurrency()
函数来确定线程的数量。然后,你可以将已排序的数据推到线程安全的栈上。当线程无所事事,不是已经完成对自己数据块的梳理,就是在等待一组排序数据的产生;线程可以从栈上获取这组数据,并且对其排序。
1 | template<typename T> |
这里,parallel_quick_sort
函数⑲代表了sorter
类①的功能,其支持在栈上简单的存储无序数据块②,并且对线程进行设置③。do_sort
成员函数⑨主要做的就是对数据进行划分⑩。相较于对每一个数据块产生一个新的线程,这次会将这些数据块推到栈上⑪;并在有备用处理器⑫的时候,产生新线程。因为小于部分的数据块可能由其他线程进行处理,那么就得等待这个线程完成⑬。为了让所有事情顺利进行,当线程处于等待状态时⑭,就让当前线程尝试处理栈上的数据。try_sort_chunk
只是从栈上弹出一个数据块⑦,并且对其进行排序⑧,将结果存在promise中,让线程对已经存在于栈上的数据块进行提取⑮。
当end_of_data
没有被设置时⑯,新生成的线程还在尝试从栈上获取需要排序的数据块⑰。在循环检查中,也要给其他线程机会⑱,可以从栈上取下数据块进行更多的操作。这里的实现依赖于sorter
类④对线程的清理。当所有数据都已经排序完成,do_sort
将会返回(即使还有工作线程在运行),所以主线程将会从parallel_quick_sort
⑳中返回,在这之后会销毁sorter对象。析构函数会设置end_of_data
标志⑤,以及等待所有线程完成工作⑥。标志的设置将终止线程函数内部的循环⑯。
影响并发代码性能的因素
有多少个处理器?
处理器个数是影响多线程应用的首要因素。一个单核16芯的处理器和四核双芯或十六核单芯的处理器相同:在任何系统上,都能运行16个并发线程。当线程数量少于16个时,会有处理器处于空闲状态。另一方面,当多于16个线程在运行的时候(都没有阻塞或等待),应用将会浪费处理器的运算时间在线程间进行切换。
为了扩展应用线程的数量,与硬件所支持的并发线程数量一致,C++标准线程库提供了std::thread::hardware_concurrency()
。使用这个函数就能知道在给定硬件上可以扩展的线程数量了。
需要谨慎使用std::thread::hardware_concurrency()
,因为代码不会考虑有其他运行在系统上的线程(除非已经将系统信息进行共享)。最坏的情况就是,多线程同时调用std::thread::hardware_concurrency()
函数来对线程数量进行扩展,这样将导致庞大的超额认购。
数据争用与乒乓缓存
当两个线程并发的在不同处理器上执行,并且对同一数据进行读取,通常不会出现问题;因为数据将会拷贝到每个线程的缓存中,并且可以让两个处理器同时进行处理。不过,当有线程对数据进行修改的时候,这个修改需要更新到其他核芯的缓存中去,就要耗费一定的时间。
思考下面简短的代码段:1
2
3
4
5
6
7
8std::atomic<unsigned long> counter(0);
void processing_loop()
{
while(counter.fetch_add(1,std::memory_order_relaxed)<100000000)
{
do_something();
}
}
counter变量是全局的,所以任何线程都能调用processing_loop()
去修改同一个变量。因此,当新增加的处理器时,counter变量必须要在缓存内做一份拷贝,再改变自己的值,或其他线程以发布的方式对缓存中的拷贝副本进行更新。即使用std::memory_order_relaxed
,编译器不会为任何数据做同步操作,fetch_add是一个“读-改-写”操作,因此就要对最新的值进行检索。如果另一个线程在另一个处理器上执行同样的代码,counter的数据需要在两个处理器之间进行传递,那么这两个处理器的缓存中间就存有counter的最新值(当counter的值增加时)。
如果do_something()
足够短,或有很多处理器来对这段代码进行处理时,处理器将会互相等待;一个处理器准备更新这个值,另一个处理器正在修改这个值,所以该处理器就不得不等待第二个处理器更新完成,并且完成更新传递时,才能执行更新。这种情况被称为高竞争(high contention)。如果处理器很少需要互相等待,那么这种情况就是低竞争(low contention)。
在这个循环中,counter的数据将在每个缓存中传递若干次。这就叫做乒乓缓存(cache ping-pong),这种情况会对应用的性能有着重大的影响。当一个处理器因为等待缓存转移而停止运行时。
互斥量的竞争通常不同于原子操作的竞争,最简单的原因是,互斥量通常使用操作系统级别的序列化线程,而非处理器级别的。如果有足够的线程去执行任务,当有线程在等待互斥量时,操作系统会安排其他线程来执行任务,而处理器只会在其他线程运行在目标处理器上时,让该处理器停止工作。不过,对互斥量的竞争,将会影响这些线程的性能;毕竟,只能让一个线程在同一时间运行。
伪共享
处理器缓存通常不会用来处理在单个存储位置,但其会用来处理称为缓存行(cache lines)的内存块。内存块通常大小为32或64字节,实际大小需要由正在使用着的处理器模型来决定。因为硬件缓存进处理缓存行大小的内存块,较小的数据项就在同一内存行的相邻内存位置上。
每当线程访问0号数据项,并对其值进行更新时,缓存行的所有权就需要转移给执行该线程的处理器,这仅是为了让更新1号数据项的线程获取1号线程的所有权。缓存行是共享的(即使没有数据存在),因此使用伪共享来称呼这种方式。这个问题的解决办法就是对数据进行构造,让同一线程访问的数据项存在临近的内存中(就像是放在同一缓存行中),这样那些能被独立线程访问的数据将分布在相距很远的地方,并且可能是存储在不同的缓存行中。
如何让数据紧凑?
伪共享发生的原因:某个线程所要访问的数据过于接近另一线程的数据,另一个是与数据布局相关的陷阱会直接影响单线程的性能。问题在于数据过于接近:当数据能被单线程访问时,那么数据就已经在内存中展开,就像是分布在不同的缓存行上。
现在,对于单线程代码来说就很关键了,何至于此呢?原因就是任务切换(task switching)。如果系统中的线程数量要比核芯多,每个核上都要运行多个线程。这就会增加缓存的压力,为了避免伪共享,努力让不同线程访问不同缓存行。因此,当处理器切换线程的时候,就要对不同内存行上的数据进行重新加载,而非对缓存中的数据保持原样(当线程中的数据都在同一缓存行时)。
如果线程数量多于内核或处理器数量,操作系统可能也会选择将一个线程安排给这个核芯一段时间,之后再安排给另一个核芯一段时间。因此就需要将缓存行从一个内核上,转移到另一个内核上;这样的话,就需要转移很多缓存行,也就意味着要耗费很多时间。虽然,操作系统通常避免这样的情况发生,不过当其发生的时候,对性能就会有很大的影响。
超额认购和频繁的任务切换
多线程系统中,通常线程的数量要多于处理的数量。不过,线程经常会花费时间来等待外部I/O完成,或被互斥量阻塞,或等待条件变量,等等;所以等待不是问题。应用使用额外的线程来完成有用的工作,而非让线程在处理器处以闲置状态时继续等待。
这也并非长久之计,如果有很多额外线程,就会有很多线程准备执行,而且数量远远大于可用处理器的数量,不过操作系统就会忙于在任务间切换,以确保每个任务都有时间运行。
如果只是简单的通过数据划分生成多个线程,那可以限定工作线程的数量。如果超额认购是对工作的天然划分而产生,那么不同的划分方式对这种问题就没有太多益处了。
其他因素也会影响多线程代码的性能。即使CPU类型和时钟周期相同,乒乓缓存的开销可以让程序在两个单核处理器和在一个双核处理器上,产生巨大的性能差,不过这只是那些对性能影响可见的因素。接下来,让我们看一下这些因素如何影响代码与数据结构的设计。
为多线程性能设计数据结构
为多线程性能而设计数据结构的时候,需要考虑竞争(contention),伪共享(false sharing)和数据距离(data proximity)。这三个因素对于性能都有着重大的影响,并且你通常可以改善的是数据布局,或者将赋予其他线程的数据元素进行修改。首先,让我们来看一个轻松方案:线程间划分数组元素。
为复杂操作划分数组元素
线程间划分工作是有很多种方式的。假设矩阵的行或列数量大于处理器的数量,可以让每个线程计算出结果矩阵列上的元素,或是行上的元素,亦或计算一个子矩阵。
对于一个数组来说,访问连续的元素是最好的方式,因为这将会减少缓存的使用,并且降低伪共享的概率。如果要让每个线程处理几行,线程需要读取第一个矩阵中的每一个元素,并且读取第二个矩阵上的相关行上的数据。给定的两个矩阵是以行连续的方式存储,这就意味着当你访问第一个矩阵的第一行的前N个元素,然后是第二行的前N个元素,以此类推(N是列的数量)。其他线程会访问每行的的其他元素;很明显的,应该访问相邻的列,所以从行上读取的N个元素也是连续的,这将最大程度的降低伪共享的几率。
另一方面,当每个线程处理一组行,就需要读取第二个矩阵上的每一个数据,还要读取第一个矩阵中的相关行上的值,不过这里只需要对行上的值进行写入。因为矩阵是以行连续的方式存储,那么现在可以以N行的方式访问所有的元素。如果再次选择相邻行,这就意味着线程现在只能写入N行,这里就有不能被其他线程所访问的连续内存块。
第三个选择——将矩阵分成小矩阵块?这可以看作先对列进行划分,再对行进行划分。因此,划分列的时候,同样有伪共享的问题存在。如果你可以选择内存块所拥有行的数量,就可以有效的避免伪共享;将大矩阵划分为小块,对于读取来说是有好处的:就不再需要读取整个源矩阵了。这里,只需要读取目标矩形里面相关行列的值就可以了。具体的来看,考虑1,000行和1,000列的两个矩阵相乘。就会有1百万个元素。如果有100个处理器,这样就可以每次处理10行的数据,也就是10,000个元素。不过,为了计算10,000个元素,就需要对第二个矩阵中的全部内容进行访问(1百万个元素),再加上10,000个相关行(第一个矩阵)上的元素,大概就要访问1,010,000个元素。另外,硬件能处理100x100的数据块(总共10,000个元素),这就需要对第一个矩阵中的100行进行访问(100x1,000=100,000个元素),还有第二个矩阵中的100列(另外100,000个)。这才只有200,000个元素,就需要五轮读取才能完成。如果这里读取的元素少一些,缓存缺失的情况就会少一些,对于性能来说就好一些。
因此,将矩阵分成小块或正方形的块,要比使用单线程来处理少量的列好的多。当然,可以根据源矩阵的大小和处理器的数量,在运行时对块的大小进行调整。和之前一样,当性能是很重要的指标,就需要对目标架构上的各项指标进行测量。
其他数据结构中的数据访问模式
根本上讲,同样的考虑适用于想要优化数据结构的数据访问模式,就像优化对数组的访问:
- 尝试调整数据在线程间的分布,就能让同一线程中的数据紧密联系在一起。
- 尝试减少线程上所需的数据量。
- 尝试让不同线程访问不同的存储位置,以避免伪共享。
假设你有一个简单的类,包含一些数据项和一个用于保护数据的互斥量(在多线程环境下)。如果互斥量和数据项在内存中很接近,对一个需要获取互斥量的线程来说是很理想的情况;需要的数据可能早已存入处理器的缓存中了,因为在之前为了对互斥量进行修改,已经加载了需要的数据。不过,这还有一个缺点:当其他线程尝试锁住互斥量时(第一个线程还没有是释放),线程就能对对应的数据项进行访问。互斥锁是当做一个“读-改-写”原子操作实现的,对于相同位置的操作都需要先获取互斥量,如果互斥量已锁,那就会调用系统内核。这种“读-改-写”操作,可能会让数据存储在缓存中,让线程获取的互斥量变得毫无作用。从目前互斥量的发展来看,这并不是个问题;线程不会直到互斥量解锁,才接触互斥量。不过,当互斥量共享同一缓存行时,其中存储的是线程已使用的数据,这时拥有互斥量的线程将会遭受到性能打击,因为其他线程也在尝试锁住互斥量。
一种测试伪共享问题的方法是:对大量的数据块填充数据,让不同线程并发的进行访问。比如,你可以使用:1
2
3
4
5
6struct protected_data
{
std::mutex m;
char padding[65536]; // 65536字节已经超过一个缓存行的数量级
my_data data_to_protect;
};
用来测试互斥量竞争或1
2
3
4
5
6
7struct my_data
{
data_item1 d1;
data_item2 d2;
char padding[65536];
};
my_data some_array[256];
用来测试数组数据中的伪共享。如果这样能够提高性能,你就能知道伪共享在这里的确存在。
设计并发代码的注意事项
虽然,非扩展性代码依旧可以正常工作——单线程应用就无法扩展——例如,异常安全是一个正确性问题。如果你的代码不是异常安全的,最终会破坏不变量,或是造成条件竞争,亦或是你的应用意外终止,因为某个操作会抛出异常。有了这个想法,我们就率先来看一下异常安全的问题。
并行算法中的异常安全
异常安全是衡量C++代码一个很重要的指标,并发代码也不例外。实际上,相较于串行算法,并行算法常会格外要求注意异常问题。当一个操作在串行算法中抛出一个异常,算法只需要考虑对其本身进行处理,以避免资源泄露和损坏不变量;这里可以允许异常传递给调用者,由调用者对异常进行处理。通过对比,在并行算法中很多操作要运行在独立的线程上。在这种情况下,异常就不再允许被传播,因为这将会使调用堆栈出现问题。如果一个函数在创建一个新线程后带着异常退出,那么这个应用将会终止。
1 | template<typename Iterator,typename T> |
现在让我们来看一下异常要在哪抛出:基本上就是在调用函数的地方抛出异常,或在用户定义类型上执行某个操作时可能抛出异常。
首先,需要调用distance②,其会对用户定义的迭代器类型进行操作。因为,这时还没有做任何事情,所以对于调用线程来说,所有事情都没问题。接下来,就需要分配results③和threads④。再后,调用线程依旧没有做任何事情,或产生新的线程,所以到这里也是没有问题的。当然,如果在构造threads抛出异常,那么对已经分配的results将会被清理,析构函数会帮你打理好一切。
跳过block_start
⑤的初始化(因为也是安全的),来到了产生新线程的循环⑥⑦⑧。当在⑦处创建了第一个线程,如果再抛出异常,就会出问题的;对于新的std::thread
对象将会销毁,程序将调用std::terminate
来中断程序的运行。使用std::terminate
的地方,可不是什么好地方。
accumulate_block
⑨的调用就可能抛出异常,就会产生和上面类似的结果;线程对象将会被销毁,并且调用std::terminate
。另一方面,最终调用std::accumulate
⑩可能会抛出异常,不过处理起来没什么难度,因为所有的线程在这里已经汇聚回主线程了。
上面只是对于主线程来说的,不过还有很多地方会抛出异常:对于调用accumulate_block
的新线程来说就会抛出异常①。没有任何catch块,所以这个异常不会被处理,并且当异常发生的时候会调用std::terminater()
来终止应用的运行。
也许这里的异常问题并不明显,不过这段代码是非异常安全的。
如果你仔细的了解过新线程用来完成什么样的工作,要返回一个计算的结果的同时,允许代码产生异常。这可以将std::packaged_task
和std::future
相结合,来解决这个问题。如果使用std::packaged_task
重新构造代码,代码可能会是如下模样。
1 | template<typename Iterator,typename T> |
第一个修改就是调用accumulate_block
的操作现在就是直接将结果返回,而非使用引用将结果存储在某个地方①。使用std::packaged_task
和std::future
是线程安全的,所以你可以使用它们来对结果进行转移。当调用std::accumulate
②时,需要你显示传入T的默认构造函数,而非复用result的值,不过这只是一个小改动。
下一个改动就是,不用向量来存储结果,而使用futures
向量为每个新生线程存储std::future<T>
③。在新线程生成循环中,首先要为accumulate_block
创建一个任务④。std::packaged_task<T(Iterator,Iterator)>
声明,需要操作的两个Iterators和一个想要获取的T。然后,从任务中获取future⑤,再将需要处理的数据块的开始和结束信息传入⑥,让新线程去执行这个任务。当任务执行时,future将会获取对应的结果,以及任何抛出的异常。
使用future,就不能获得到一组结果数组,所以需要将最终数据块的结果赋给一个变量进行保存⑦,而非对一个数组进行填槽。同样,因为需要从future中获取结果,使用简单的for循环,就要比使用std::accumulate
好的多;循环从提供的初始值开始⑧,并且将每个future上的值进行累加⑨。如果相关任务抛出一个异常,那么异常就会被future捕捉到,并且使用get()
的时候获取数据时,这个异常会再次抛出。最后,在返回结果给调用者之前,将最后一个数据块上的结果添加入结果中⑩。
这样,一个问题就已经解决:在工作线程上抛出的异常,可以在主线程上抛出。如果不止一个工作线程抛出异常,那么只有一个能在主线程中抛出,不过这不会有产生太大的问题。如果这个问题很重要,你可以使用类似std::nested_exception
来对所有抛出的异常进行捕捉。
剩下的问题就是,当生成第一个新线程和当所有线程都汇入主线程时,抛出异常;这样会让线程产生泄露。最简单的方法就是捕获所有抛出的线程,汇入的线程依旧是joinable()
的,并且会再次抛出异常:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19try
{
for(unsigned long i=0;i<(num_threads-1);++i)
{
// ... as before
}
T last_result=accumulate_block()(block_start,last);
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join));
}
catch(...)
{
for(unsigned long i=0;i<(num_thread-1);++i)
{
if(threads[i].joinable())
thread[i].join();
}
throw;
}
现在好了,无论线程如何离开这段代码,所有线程都可以被汇入。不过,try-catch很不美观,并且这里有重复代码。可以将“正常”控制流上的线程在catch块上执行的线程进行汇入。重复代码是没有必要的,因为这就意味着更多的地方需要改变。不过,现在让我们来提取一个对象的析构函数;毕竟,析构函数是C++中处理资源的惯用方式。看一下你的类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class join_threads
{
std::vector<std::thread>& threads;
public:
explicit join_threads(std::vector<std::thread>& threads_):
threads(threads_)
{}
~join_threads()
{
for(unsigned long i=0;i<threads.size();++i)
{
if(threads[i].joinable())
threads[i].join();
}
}
};
当创建了线程容器,就对新类型创建了一个实例①,可让退出线程进行汇入。然后,可以再显式的汇入循环中将线程删除,在原理上来说是安全的:因为线程,无论怎么样退出,都需要汇入主线程。注意这里对futures[i].get()
②的调用,将会阻塞线程,直到结果准备就绪,所以这里不需要显式的将线程进行汇入。和清单8.2中的原始代码不同:原始代码中,你需要将线程汇入,以确保results向量被正确填充。不仅需要异常安全的代码,还需要较短的函数实现,因为这里已经将汇入部分的代码放到新(可复用)类型中去了。
现在,你已经了解了,当需要显式管理线程的时候,需要代码是异常安全的。那现在让我们来看一下使用std::async()
是怎么样完成异常安全的。在本例中,标准库对线程进行了较好的管理,并且当“期望”处以就绪状态的时候,就能生成一个新的线程。对于异常安全,还需要注意一件事,如果在没有等待的情况下对“期望”实例进行销毁,析构函数会等待对应线程执行完毕后才执行。这就能桥面的必过线程泄露的问题,因为线程还在执行,且持有数据的引用。下面的代码将展示使用std::async()
完成异常安全的实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
unsigned long const length=std::distance(first,last); // 1
unsigned long const max_chunk_size=25;
if(length<=max_chunk_size)
{
return std::accumulate(first,last,init); // 2
}
else
{
Iterator mid_point=first;
std::advance(mid_point,length/2); // 3
std::future<T> first_half_result=
std::async(parallel_accumulate<Iterator,T>, // 4
first,mid_point,init);
T second_half_result=parallel_accumulate(mid_point,last,T()); // 5
return first_half_result.get()+second_half_result; // 6
}
}
这个版本对数据进行递归划分,而非在预计算后对数据进行分块;因此,这个版本要比之前的版本简单很多,并且这个版本也是异常安全的。和之前一样,一开始要确定序列的长度①,如果其长度小于数据块包含数据的最大数量,那么可以直接调用std::accumulate②。如果元素的数量超出了数据块包含数据的最大数量,那么就需要找到数量中点③,将这个数据块分成两部分,然后再生成一个异步任务对另一半数据进行处理④。第二半的数据是通过直接的递归调用来处理的⑤,之后将两个块的结果加和到一起⑥。标准库能保证std::async的调用能够充分的利用硬件线程,并且不会产生线程的超额认购,一些“异步”调用是在调用get()⑥后同步执行的。
优雅的地方,不仅在于利用硬件并发的优势,并且还能保证异常安全。如果有异常在递归调用⑤中抛出,通过调用std::async④所产生的“期望”,将会在异常传播时被销毁。这就需要依次等待异步任务的完成,因此也能避免悬空线程的出现。另外,当异步任务抛出异常,且被future所捕获,在对get()⑥调用的时候,future中存储的异常,会再次抛出。
在实践中设计并发代码
并行实现:std::for_each
std::for_each
的原理很简单:其对某个范围中的元素,依次调用用户提供的函数。并行和串行调用的最大区别就是函数的调用顺序。std::for_each
是对范围中的第一个元素调用用户函数,接着是第二个,以此类推,而在并行实现中对于每个元素的处理顺序就不能保证了,并且它们可能(我们希望如此)被并发的处理。
为了实现这个函数的并行版本,需要对每个线程上处理的元素进行划分。你事先知道元素数量,所以可以处理前对数据进行划分。假设只有并行任务运行,就可以使用std::thread::hardware_concurrency()
来决定线程的数量。同样,这些元素都能被独立的处理,所以可以使用连续的数据块来避免伪共享。
这里的算法有点类似于并行版的std::accumulate
,不过比起计算每一个元素的加和,这里对每个元素仅仅使用了一个指定功能的函数。因为不需要返回结果,可以假设这可能会对简化代码,不过想要将异常传递给调用者,就需要使用std::packaged_task
和std::future
机制对线程中的异常进行转移。这里展示一个样本实现。
1 | template<typename Iterator,typename Func> |
最重要的不同在于futures
向量对std::future<void>
类型①变量进行存储,因为工作线程不会返回值,并且简单的lambda函数会对block_start到block_end上的任务②执行f函数。这是为了避免传入线程的构造函数③。当工作线程不需要返回一个值时,调用futures[i].get()
④只是提供检索工作线程异常的方法;如果不想把异常传递出去,就可以省略这一步。
实现并行std::accumulate
的时候,使用std::async
会简化代码;同样,parallel_for_each
也可以使用std::async
。实现如下所示。
1 | template<typename Iterator,typename Func> |
和基于std::async
的parallel_accumulate
一样,是在运行时对数据进行迭代划分的,而非在执行前划分好,这是因为你不知道你的库需要使用多少个线程。像之前一样,当你将每一级的数据分成两部分,异步执行另外一部分②,剩下的部分就不能再进行划分了,所以直接运行这一部分③;这样就可以直接对std::for_each
①进行使用了。这里再次使用std::async
和std::future
的get()
成员函数④来提供对异常的传播。
并行实现:std::find
接下来是std::find算法,因为这是一种不需要对数据元素做任何处理的算法。比如,当第一个元素就满足查找标准,那就没有必要对其他元素进行搜索了。将会看到,算法属性对于性能具有很大的影响,并且对并行实现的设计有着直接的影响。这个算法是一个很特别的例子,数据访问模式都会对代码的设计产生影响。该类中的另一些算法包括std::equal
和std::any_of
。
如果不中断其他线程,那么串行版本的性能可能会超越并行版,因为串行算法可以在找到匹配元素的时候,停止搜索并返回。如果系统能支持四个并发线程,那么每个线程就可以对总数据量的1/4进行检查,并且在我们的实现只需要单核完成的1/4的时间,就能完成对所有元素的查找。如果匹配的元素在第一个1/4块中,串行算法将会返回第一个,因为算法不需要对剩下的元素进行处理了。
一种办法,中断其他线程的一个办法就是使用一个原子变量作为一个标识,在处理过每一个元素后就对这个标识进行检查。如果标识被设置,那么就有线程找到了匹配元素,所以算法就可以停止并返回了。用这种方式来中断线程,就可以将那些没有处理的数据保持原样,并且在更多的情况下,相较于串行方式,性能能提升很多。缺点就是,加载原子变量是一个很慢的操作,会阻碍每个线程的运行。
如何返回值和传播异常呢?现在你有两个选择。你可以使用一个future数组,使用std::packaged_task
来转移值和异常,在主线程上对返回值和异常进行处理;或者使用std::promise
对工作线程上的最终结果直接进行设置。这完全依赖于你想怎么样处理工作线程上的异常。如果想停止第一个异常(即使还没有对所有元素进行处理),就可以使用std::promise
对异常和最终值进行设置。另外,如果想要让其他工作线程继续查找,可以使用std::packaged_task
来存储所有的异常,当线程没有找到匹配元素时,异常将再次抛出。
这种情况下,我会选择std::promise
,因为其行为和std::find
更为接近。这里需要注意一下搜索的元素是不是在提供的搜索范围内。因此,在所有线程结束前,获取future上的结果。如果被future阻塞住,所要查找的值不在范围内,就会持续的等待下去。实现代码如下。
1 | template<typename Iterator,typename MatchType> |
函数主体与之前的例子相似。这次,由find_element
类①的函数调用操作实现,来完成查找工作的。循环通过在给定数据块中的元素,检查每一步上的标识②。如果匹配的元素被找到,就将最终的结果设置到promise③当中,并且在返回前对done_flag④进行设置。
如果有一个异常被抛出,那么它就会被通用处理代码⑤捕获,并且在promise⑥尝中试存储前,对done_flag进行设置。如果对应promise已经被设置,设置在promise上的值可能会抛出一个异常,所以这里⑦发生的任何异常,都可以捕获并丢弃。
这意味着,当线程调用find_element查询一个值,或者抛出一个异常时,如果其他线程看到done_flag被设置,那么其他线程将会终止。如果多线程同时找到匹配值或抛出异常,它们将会对promise产生竞争。不过,这是良性的条件竞争;因为,成功的竞争者会作为“第一个”返回线程,因此这个结果可以接受。
回到parallel_find函数本身,其拥有用来停止搜索的promise⑧和标识⑨;随着对范围内的元素的查找⑪,promise和标识会传递到新线程中。主线程也使用find_element来对剩下的元素进行查找⑫。像之前提到的,需要在全部线程结束前,对结果进行检查,因为结果可能是任意位置上的匹配元素。这里将“启动-汇入”代码放在一个块中⑩,所以所有线程都会在找到匹配元素时⑬进行汇入。如果找到匹配元素,就可以调用std::future<Iterator>
的成员函数get()来获取返回值或异常。
不过,这里假设你会使用硬件上所有可用的的并发线程,或使用其他机制对线程上的任务进行提前划分。就像之前一样,可以使用std::async
,以及递归数据划分的方式来简化实现(同时使用C++标准库中提供的自动缩放工具)。使用std::async
的parallel_find
实现如下所示。
1 | template<typename Iterator,typename MatchType> // 1 |
如果想要在找到匹配项时结束,就需要在线程之间设置一个标识来表明匹配项已经被找到。因此,需要将这个标识递归的传递。通过函数①的方式来实现是最简单的办法,只需要增加一个参数——一个done标识的引用,这个表示通过程序的主入口点传入⑫。
核心实现和之前的代码一样。通常函数的实现中,会让单个线程处理最少的数据项②;如果数据块大小不足于分成两半,就要让当前线程完成所有的工作了③。实际算法在一个简单的循环当中(给定范围),直到在循环到指定范围中的最后一个,或找到匹配项,并对标识进行设置④。如果找到匹配项,标识done就会在返回前进行设置⑤。无论是因为已经查找到最后一个,还是因为其他线程对done进行了设置,都会停止查找。如果没有找到,会将最后一个元素last进行返回⑥。
如果给定范围可以进行划分,首先要在std::async
在对第二部分进行查找⑧前,要找数据中点⑦,而且需要使用std::ref
将done以引用的方式传递。同时,可以通过对第一部分直接进行递归查找。两部分都是异步的,并且在原始范围过大时,直接递归查找的部分可能会再细化。
如果直接查找返回的是mid_point,这就意味着没有找到匹配项,所以就要从异步查找中获取结果。如果在另一半中没有匹配项的话,返回的结果就一定是last,这个值的返回就代表了没有找到匹配的元素⑩。如果“异步”调用被延迟(非真正的异步),那么实际上这里会运行get();这种情况下,如果对下半部分的元素搜索成功,那么就不会执行对上半部分元素的搜索了。如果异步查找真实的运行在其他线程上,那么async_result变量的析构函数将会等待该线程完成,所以这里不会有线程泄露。
像之前一样,std::async
可以用来提供“异常-安全”和“异常-传播”特性。如果直接递归抛出异常,future的析构函数就能让异步执行的线程提前结束;如果异步调用抛出异常,那么这个异常将会通过对get()成员函数的调用进行传播⑩。使用try/catch块只能捕捉在done发生的异常,并且当有异常抛出⑪时,所有线程都能很快的终止运行。不过,不使用try/catch的实现依旧没问题,不同的就是要等待所有线程的工作是否完成。
实现中一个重要的特性就是,不能保证所有数据都能被std::find串行处理。其他并行算法可以借鉴这个特性,因为要让一个算法并行起来这是必须具有的特性。如果有顺序问题,元素就不能并发的处理了。如果每个元素独立,虽然对于parallel_for_each不是很重要,不过对于parallel_find,即使在开始部分已经找到了匹配元素,也有可能返回范围中最后一个元素;如果在知道结果的前提下,这样的结果会让人很惊讶。
并行实现:std::partial_sum
std::partial_sum
会计算给定范围中的每个元素,并用计算后的结果将原始序列中的值替换掉。比如,有一个序列[1,2,3,4,5],在执行该算法后会成为:[1,3(1+2),6(1+2+3),10(1+2+3+4),15(1+2+3+4+5)]。让这样一个算法并行起来会很有趣,因为这里不能讲任务分块,对每一块进行独立的计算。比如,原始序列中的第一个元素需要加到后面的一个元素中去。
将原始数据分割成块,加上之前块的部分和就能够并行了。如果每个块中的末尾元素都是第一个被更新的,那么块中其他的元素就能被其他线程所更新,同时另一个线程对下一块进行更新,等等。当处理的元素比处理核心的个数多的时候,这样完成工作没问题,因为每一个核芯在每一个阶段都有合适的数据可以进行处理。
比起将数据块中的最后一个元素的结果向后面的元素块传递,可以对部分结果进行传播:第一次与相邻的元素(距离为1)相加和(和之前一样),之后和距离为2的元素相加,在后来和距离为4的元素相加,以此类推。比如,初始序列为[1,2,3,4,5,6,7,8,9],第一次后为[1,3,5,7,9,11,13,15,17],第二次后为[1,3,6,10,14,18, 22,26,30],下一次就要隔4个元素了。第三次后[1, 3, 6, 10, 15, 21, 28, 36, 44],下一次就要隔8个元素了。第四次后[1, 3, 6, 10, 15, 21, 28, 36, 45],这就是最终的结果。虽然,比起第一种方法多了很多步骤,不过在可并发平台下,这种方法提高了并行的可行性;每个处理器可在每一步中处理一个数据项。
总体来说,当有N个操作时(每步使用一个处理器)第二种方法需要log(N)
步;在本节中,N就相当于数据链表的长度。比起第一种,每个线程对分配块做N/k个操作,然后在做N/k次结果传递(这里的k是线程的数量)。因此,第一种方法的时间复杂度为O(N),不过第二种方法的时间复杂度为Q(Nlog(N))。当数据量和处理器数量相近时,第二种方法需要每个处理器上log(N)个操作,第一种方法中每个处理器上执行的操作数会随着k的增加而增多,因为需要对结果进行传递。对于处理单元较少的情况,第一种方法会比较合适;对于大规模并行系统,第二种方法比较合适。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85template<typename Iterator>
void parallel_partial_sum(Iterator first,Iterator last)
{
typedef typename Iterator::value_type value_type;
struct process_chunk // 1
{
void operator()(Iterator begin,Iterator last,
std::future<value_type>* previous_end_value,
std::promise<value_type>* end_value)
{
try
{
Iterator end=last;
++end;
std::partial_sum(begin,end,begin); // 2
if(previous_end_value) // 3
{
value_type& addend=previous_end_value->get(); // 4
*last+=addend; // 5
if(end_value)
{
end_value->set_value(*last); // 6
}
std::for_each(begin,last,[addend](value_type& item) // 7
{
item+=addend;
});
}
else if(end_value)
{
end_value->set_value(*last); // 8
}
}
catch(...) // 9
{
if(end_value)
{
end_value->set_exception(std::current_exception()); // 10
}
else
{
throw; // 11
}
}
}
};
unsigned long const length=std::distance(first,last);
if(!length)
return last;
unsigned long const min_per_thread=25; // 12
unsigned long const max_threads=
(length+min_per_thread-1)/min_per_thread;
unsigned long const hardware_threads=
std::thread::hardware_concurrency();
unsigned long const num_threads=
std::min(hardware_threads!=0?hardware_threads:2,max_threads);
unsigned long const block_size=length/num_threads;
typedef typename Iterator::value_type value_type;
std::vector<std::thread> threads(num_threads-1); // 13
std::vector<std::promise<value_type> >
end_values(num_threads-1); // 14
std::vector<std::future<value_type> >
previous_end_values; // 15
previous_end_values.reserve(num_threads-1); // 16
join_threads joiner(threads);
Iterator block_start=first;
for(unsigned long i=0;i<(num_threads-1);++i)
{
Iterator block_last=block_start;
std::advance(block_last,block_size-1); // 17
threads[i]=std::thread(process_chunk(), // 18
block_start,block_last,
(i!=0)?&previous_end_values[i-1]:0,
&end_values[i]);
block_start=block_last;
++block_start; // 19
previous_end_values.push_back(end_values[i].get_future()); // 20
}
Iterator final_element=block_start;
std::advance(final_element,std::distance(block_start,last)-1); // 21
process_chunk()(block_start,final_element, // 22
(num_threads>1)?&previous_end_values.back():0,
0);
}
这个实现中,使用的结构体和之前算法中的一样,将问题进行分块解决,每个线程处理最小的数据块⑫。其中,有一组线程⑬和一组promise⑭,用来存储每块中的最后一个值;并且实现中还有一组future⑮,用来对前一块中的最后一个值进行检索。可以为future⑯做些储备,以避免生成新线程时,再分配内存。
主循环和之前一样,不过这次是让迭代器指向了每个数据块的最后一个元素,而不是作为一个普通值传递到最后⑰,这样就方便向其他块传递当前块的最后一个元素了。实际处理是在process_chunk
函数对象中完成的,这个结构体看上去不是很长;当前块的开始和结束迭代器和前块中最后一个值的future一起,作为参数进行传递,并且promise用来保留当前范围内最后一个值的原始值⑱。
生成新的线程后,就对开始块的ID进行更新,别忘了传递最后一个元素⑲,并且将当前块的最后一个元素存储到future,上面的数据将在循环中再次使用到⑳。
在处理最后一个数据块前,需要获取之前数据块中最后一个元素的迭代器(21),这样就可以将其作为参数传入process_chunk(22)中了。std::partial_sum
不会返回一个值,所以在最后一个数据块被处理后,就不用再做任何事情了。当所有线程的操作完成时,求部分和的操作也就算完成了。
OK,现在来看一下process_chunk
函数对象①。对于整块的处理是始于对std::partial_sum
的调用,包括对于最后一个值的处理②,不过得要知道当前块是否是第一块③。如果当前块不是第一块,就会有一个previous_end_value
值从前面的块传过来,所以这里需要等待这个值的产生④。为了将算法最大程度的并行,首先需要对最后一个元素进行更新⑤,这样你就能将这个值传递给下一个数据块(如果有下一个数据块的话)⑥。当完成这个操作,就可以使用std::for_each
和简单的lambda函数⑦对剩余的数据项进行更新。
如果previous_end_value
值为空,当前数据块就是第一个数据块,所以只需要为下一个数据块更新end_value⑧(如果有下一个数据块的话——当前数据块可能是唯一的数据块)。
最后,如果有任意一个操作抛出异常,就可以将其捕获⑨,并且存入promise⑩,如果下一个数据块尝试获取前一个数据块的最后一个值④时,异常会再次抛出。处理最后一个数据块时,异常会全部重新抛出⑪,因为抛出动作一定会在主线程上进行。
因为线程间需要同步,这里的代码就不容易使用std::async
重写。任务等待会让线程中途去执行其他的任务,所以所有的任务必须同时执行。
基于块,以传递末尾元素值的方法就介绍到这里,让我们来看一下第二种计算方式。
实现以2的幂级数为距离部分和算法
第二种算法通过增加距离的方式,让更多的处理器充分发挥作用。在这种情况下,没有进一步同步的必要了,因为所有中间结果都直接传递到下一个处理器上去了。不过,在实际中我们很少见到,单个处理器处理对一定数量的元素执行同一条指令,这种方式成为单指令-多数据流(SIMD)。因此,代码必须能处理通用情况,并且需要在每步上对线程进行显式同步。
完成这种功能的一种方式是使用栅栏(barrier)——一种同步机制:只有所有线程都到达栅栏处,才能进行之后的操作;先到达的线程必须等待未到达的线程。C++11标准库没有直接提供这样的工具,所以你得自行设计一个。
1 | class barrier |
这个实现中,用一定数量的“座位”构造了一个barrier①,这个数量将会存储count变量中。起初,栅栏中的spaces与count数量相当。当有线程都在等待时,spaces的数量就会减少③。当spaces的数量减到0时,spaces的值将会重置为count④,并且generation变量会增加,以向线程发出信号,让这些等待线程能够继续运行⑤。如果spaces没有到达0,那么线程会继续等待。这个实现使用了一个简单的自旋锁⑥,对generation的检查会在wait()开始的时候进行②。因为generation只会在所有线程都到达栅栏的时候更新⑤,在等待的时候使用yield()
⑦就不会让CPU处于忙等待的状态。
这个实现比较“简单”的真实意义:使用自旋等待的情况下,如果让线程等待很长时间就不会很理想,并且如果超过count数量的线程对wait()
进行调用,这个实现就没有办法工作了。如果想要很好的处理这样的情况,必须使用一个更加健壮(更加复杂)的实现。我依旧坚持对原子变量操作顺序的一致性,因为这会让事情更加简单,不过有时还是需要放松这样的约束。全局同步对于大规模并行架构来说是消耗巨大的,因为相关处理器会穿梭于存储栅栏状态的缓存行中,所以需要格外的小心,来确保使用的是最佳同步方法。
不论怎么样,这些都需要你考虑到;需要有固定数量的线程执行同步循环。好吧,大多数情况下线程数量都是固定的。你可能还记得,代码起始部分的几个数据项,只需要几步就能得到其最终值。这就意味着,无论是让所有线程循环处理范围内的所有元素,还是让栅栏来同步线程,都会递减count的值。我会选择后者,因为其能避免线程做不必要的工作,仅仅是等待最终步骤完成。
这意味着你要将count改为一个原子变量,这样在多线程对其进行更新的时候,就不需要添加额外的同步:1
std::atomic<unsigned> count;
初始化保持不变,不过当spaces的值被重置后,你需要显式的对count进行load()操作:1
spaces=count.load();
这就是要对wait()
函数的改动;现在需要一个新的成员函数来递减count。这个函数命名为done_waiting(),因为当一个线程完成其工作,并在等待的时候,才能对其进行调用它:1
2
3
4
5
6
7
8
9void done_waiting()
{
--count; // 1
if(!--spaces) // 2
{
spaces=count.load(); // 3
++generation;
}
}
实现中,首先要减少count①,所以下一次spaces将会被重置为一个较小的数。然后,需要递减spaces的值②。如果不做这些操作,有些线程将会持续等待,因为spaces被旧的count初始化,大于期望值。一组当中最后一个线程需要对计数器进行重置,并且递增generation的值③,就像在wait()
里面做的那样。最重要的区别:最后一个线程不需要等待。当最后一个线程结束,整个等待也就随之结束!
现在就准备开始写部分和的第二个实现吧。在每一步中,每一个线程都在栅栏出调用wait()
,来保证线程所处步骤一致,并且当所有线程都结束,那么最后一个线程会调用done_waiting()
来减少count的值。如果使用两个缓存对原始数据进行保存,栅栏也可以提供你所需要的同步。每一步中,线程都会从原始数据或是缓存中读取数据,并且将新值写入对应位置。如果有线程先从原始数据处获取数据,那下一步就从缓存上获取数据(或相反)。这就能保证在读与写都是由独立线程完成,并不存在条件竞争。当线程结束等待循环,就能保证正确的值最终被写入到原始数据当中。下面的代码就是这样的实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80struct barrier
{
std::atomic<unsigned> count;
std::atomic<unsigned> spaces;
std::atomic<unsigned> generation;
barrier(unsigned count_):
count(count_),spaces(count_),generation(0)
{}
void wait()
{
unsigned const gen=generation.load();
if(!--spaces)
{
spaces=count.load();
++generation;
}
else
{
while(generation.load()==gen)
{
std::this_thread::yield();
}
}
}
void done_waiting()
{
--count;
if(!--spaces)
{
spaces=count.load();
++generation;
}
}
};
template<typename Iterator>
void parallel_partial_sum(Iterator first,Iterator last)
{
typedef typename Iterator::value_type value_type;
struct process_element // 1
{
void operator()(Iterator first,Iterator last,
std::vector<value_type>& buffer,
unsigned i,barrier& b)
{
value_type& ith_element=*(first+i);
bool update_source=false;
for(unsigned step=0,stride=1;stride<=i;++step,stride*=2)
{
value_type const& source=(step%2)? // 2
buffer[i]:ith_element;
value_type& dest=(step%2)?
ith_element:buffer[i];
value_type const& addend=(step%2)? // 3
buffer[i-stride]:*(first+i-stride);
dest=source+addend; // 4
update_source=!(step%2);
b.wait(); // 5
}
if(update_source) // 6
{
ith_element=buffer[i];
}
b.done_waiting(); // 7
}
};
unsigned long const length=std::distance(first,last);
if(length<=1)
return;
std::vector<value_type> buffer(length);
barrier b(length);
std::vector<std::thread> threads(length-1); // 8
join_threads joiner(threads);
Iterator block_start=first;
for(unsigned long i=0;i<(length-1);++i)
{
threads[i]=std::thread(process_element(),first,last, // 9
std::ref(buffer),i,std::ref(b));
}
process_element()(first,last,buffer,length-1,b); // 10
}
代码的整体结构应该不用说了。process_element
类有函数调用操作可以用来做具体的工作①,就是运行一组线程⑨,并将线程存储到vector中⑧,同样还需要在主线程中对其进行调用⑩。这里与之前最大的区别就是,线程的数量是根据列表中的数据量来定的,而非根据std::thread::hardware_concurrency
。如我之前所说,除非你使用的是一个大规模并行的机器,因为这上面的线程都十分廉价(虽然这样的方式并不是很好),还能为我们展示了其整体结构。这个结构在有较少线程的时候,每一个线程只能处理源数据中的部分数据,当没有足够的线程支持该结构时,效率要比传递算法低。
不管怎样,主要的工作都是调用process_element
的函数操作符来完成的。每一步,都会从原始数据或缓存中获取第i个元素②,并且将获取到的元素加到指定stride的元素中去③,如果从原始数据开始读取的元素,加和后的数需要存储在缓存中④。然后,在开始下一步前,会在栅栏处等待⑤。当stride超出了给定数据的范围,当最终结果已经存在缓存中时,就需要更新原始数据中的数据,同样这也意味着本次加和结束。最后,在调用栅栏中的done_waiting()函数⑦。
注意这个解决方案并不是异常安全的。如果某个线程在process_element
执行时抛出一个异常,其就会终止整个应用。这里可以使用一个std::promise来存储异常,就像在清单8.9中parallel_find的实现,或仅使用一个被互斥量保护的std::exception_ptr即可。
高级线程管理
线程池
最简单的线程池
作为最简单的线程池,其拥有固定数量的工作线程(通常工作线程数量与std::thread::hardware_concurrency()
相同)。当工作需要完成时,可以调用函数将任务挂在任务队列中。每个工作线程都会从任务队列上获取任务,然后执行这个任务,执行完成后再回来获取新的任务。在最简单的线程池中,线程就不需要等待其他线程完成对应任务了。如果需要等待,就需要对同步进行管理。
1 | class thread_pool |
实现中有一组工作线程②,并且使用了一个线程安全队列①来管理任务队列。这种情况下,用户不用等待任务,并且任务不需要返回任何值,所以可以使用std::function<void()>
对任务进行封装。submit()函数会将函数或可调用对象包装成一个std::function<void()>
实例,并将其推入队列中⑫。
线程始于构造函数:使用std::thread::hardware_concurrency()
来获取硬件支持多少个并发线程⑧,这些线程会在worker_thread()
成员函数中执行⑨。
当有异常抛出时,线程启动就会失败,所以需要保证任何已启动的线程都能停止,并且能在这种情况下清理干净。当有异常抛出时,通过使用try-catch来设置done标志⑩,还有join_threads
类的实例③用来汇聚所有线程。当然也需要析构函数:仅设置done标志⑪,并且join_threads确保所有线程在线程池销毁前全部执行完成。注意成员声明的顺序很重要:done标志和worker_queue必须在threads数组之前声明,而数据必须在joiner前声明。这就能确保成员能以正确的顺序销毁;比如,所有线程都停止运行时,队列就可以安全的销毁了。
worker_thread
函数很简单:从任务队列上获取任务⑤,以及同时执行这些任务⑥,执行一个循环直到done标志被设置④。如果任务队列上没有任务,函数会调用std::this_thread::yield()让线程休息⑦,并且给予其他线程向任务队列上推送任务的机会。
等待提交到线程池中的任务
使用线程池,就需要等待任务提交到线程池中,而非直接提交给单个线程。虽然,会增加代码的复杂度,不过,要比直接对任务进行等待的方式好很多。
通过增加线程池的复杂度,可以直接等待任务完成。使用submit()
函数返回一个对任务描述的句柄,用来等待任务的完成。任务句柄会用条件变量或future进行包装,这样能使用线程池来简化代码。
一种特殊的情况是,执行任务的线程需要返回一个结果到主线程上进行处理。下边展示了对简单线程池的修改,通过修改就能等待任务完成,以及在工作线程完成后,返回一个结果到等待线程中去,不过std::packaged_task<>
实例是不可拷贝的,仅是可移动的,所以不能再使用std::function<>
来实现任务队列,因为std::function<>
需要存储可复制构造的函数对象。包装一个自定义函数,用来处理只可移动的类型。这就是一个带有函数操作符的类型擦除类。只需要处理那些没有函数和无返回的函数,所以这是一个简单的虚函数调用。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65class function_wrapper
{
struct impl_base {
virtual void call()=0;
virtual ~impl_base() {}
};
std::unique_ptr<impl_base> impl;
template<typename F>
struct impl_type: impl_base
{
F f;
impl_type(F&& f_): f(std::move(f_)) {}
void call() { f(); }
};
public:
template<typename F>
function_wrapper(F&& f):
impl(new impl_type<F>(std::move(f)))
{}
void operator()() { impl->call(); }
function_wrapper() = default;
function_wrapper(function_wrapper&& other):
impl(std::move(other.impl))
{}
function_wrapper& operator=(function_wrapper&& other)
{
impl=std::move(other.impl);
return *this;
}
function_wrapper(const function_wrapper&)=delete;
function_wrapper(function_wrapper&)=delete;
function_wrapper& operator=(const function_wrapper&)=delete;
};
class thread_pool
{
thread_safe_queue<function_wrapper> work_queue; // 使用function_wrapper,而非使用std::function
void worker_thread()
{
while(!done)
{
function_wrapper task;
if(work_queue.try_pop(task))
{
task();
}
else
{
std::this_thread::yield();
}
}
}
public:
template<typename FunctionType>
std::future<typename std::result_of<FunctionType()>::type> // 1
submit(FunctionType f)
{
typedef typename std::result_of<FunctionType()>::type
result_type; // 2
std::packaged_task<result_type()> task(std::move(f)); // 3
std::future<result_type> res(task.get_future()); // 4
work_queue.push(std::move(task)); // 5
return res; // 6
}
// 休息一下
};
首先,修改的是submit()
函数①返回一个std::future<>
保存任务的返回值,并且允许调用者等待任务完全结束。因为需要知道提供函数f的返回类型,所以使用std::result_of<>
:std::result_of<FunctionType()>::type
是FunctionType
类型的引用实例(如,f),并且没有参数。同样,函数中可以对result_type typedef
②使用std::result_of<>
。
然后,将f
包装入std::packaged_task<result_type()>
③,因为f
是一个无参数的函数或是可调用对象,能够返回result_type类型的实例。向任务队列推送任务⑤和返回future⑥前,就可以从std::packaged_task<>
中获取future④。注意,要将任务推送到任务队列中时,只能使用std::move()
,因为std::packaged_task<>
是不可拷贝的。为了对任务进行处理,队列里面存的就是function_wrapper
对象,而非std::function<void()>
对象。
1 | template<typename Iterator,typename T> |
首先,工作量是依据使用的块数(num_blocks①),而不是线程的数量。为了利用线程池的最大化可扩展性,需要将工作块划分为最小工作块。当线程池中线程不多时,每个线程将会处理多个工作块,不过随着硬件可用线程数量的增长,会有越来越多的工作块并发执行。
当你选择“因为能并发执行,最小工作块值的一试”时,就需要谨慎了。向线程池提交一个任务有一定的开销;让工作线程执行这个任务,并且将返回值保存在std::future<>
中,对于太小的任务,这样的开销不划算。如果任务块太小,使用线程池的速度可能都不及单线程。
假设,任务块的大小合理,就不用为这些事而担心:打包任务、获取future或存储之后要汇入的std::thread
对象;使用线程池的时候,这些都需要注意。之后,就是调用submit()
来提交任务②。
线程池也需要注意异常安全。任何异常都会通过submit()
返回给future,并在获取future的结果时,抛出异常。如果函数因为异常退出,线程池的析构函数会丢掉那些没有完成的任务,等待线程池中的工作线程完成工作。
在简单的例子中,这个线程池工作的还算不错,因为这里的任务都是相互独立的。不过,当任务队列中的任务有依赖关系时,这个线程池就不能胜任了。
等待依赖任务
快速排序算法为例,原理很简单:数据与中轴数据项比较,在中轴项两侧分为大于和小于的两个序列,然后再对这两组序列进行排序。这两组序列会递归排序,最后会整合成一个全排序序列。要将这个算法写成并发模式,需要保证递归调用能够使用硬件的并发能力。
回到第4章,第一次接触这个例子,我们使用std::async
来执行每一层的调用,让标准库来选择,是在新线程上执行这个任务,还是当对应get()
调用时,进行同步执行。运行起来很不错,因为每一个任务都在其自己的线程上执行,或当需要的时候进行调用。
在这样的情况下,使用了栈来挂起要排序的数据块。当每个线程在为一个数据块排序前,会向数据栈上添加一组要排序的数据,然后对当前数据块排序结束后,接着对另一块进行排序。这里,等待其他线程完成排序,可能会造成死锁,因为这会消耗有限的线程。有一种情况很可能会出现,就是所有线程都在等某一个数据块被排序,不过没有线程在做排序。通过拉取栈上数据块的线程,对数据块进行排序,来解决这个问题;因为,已处理的指定数据块,就是其他线程都在等待排序的数据块。
最简单的方法就是在thread_pool中添加一个新函数,来执行任务队列上的任务,并对线程池进行管理。高级线程池的实现可能会在等待函数中添加逻辑,或等待其他函数来处理这个任务,优先的任务会让其他的任务进行等待。下面清单中的实现,就展示了一个新run_pending_task()
函数,对于快速排序的修改将会在清单9.5中展示。
1 | void thread_pool::run_pending_task() |
run_pending_task()
的实现去掉了在worker_thread()
函数的主循环。函数任务队列中有任务的时候,执行任务;要是没有的话,就会让操作系统对线程进行重新分配。
下面快速排序算法的实现要简单许多,因为所有线程管理逻辑都被移入到线程池。
1 | template<typename T> |
这里将实际工作放在sorter类模板的do_sort()
成员函数中执行①,即使例子中仅对thread_pool实例进行包装②。
线程和任务管理,在线程等待的时候,就会少向线程池中提交一个任务③,并且执行任务队列上未完成的任务④。需要显式的管理线程和栈上要排序的数据块。当有任务提交到线程池中,可以使用std::bind()
绑定this指针到do_sort()
上,绑定是为了让数据块进行排序。这种情况下,需要对new_lower_chunk
使用std::move()
将其传入函数,数据移动要比拷贝的方式开销少。
虽然,使用等待其他任务的方式,解决了死锁问题,这个线程池距离理想的线程池很远。
首先,每次对submit()
的调用和对run_pending_task()
的调用,访问的都是同一个队列。
避免队列中的任务竞争
线程每次调用线程池的submit()
函数,都会推送一个任务到工作队列中。就像工作线程为了执行任务,从任务队列中获取任务一样。这意味着随着处理器的增加,在任务队列上就会有很多的竞争,这会让性能下降。使用无锁队列会让任务没有明显的等待,但是乒乓缓存会消耗大量的时间。
为了避免乒乓缓存,每个线程建立独立的任务队列。这样,每个线程就会将新任务放在自己的任务队列上,并且当线程上的任务队列没有任务时,去全局的任务列表中取任务。下面列表中的实现,使用了一个thread_local变量,来保证每个线程都拥有自己的任务列表(如全局列表那样)。
1 | class thread_pool |
因为不希望非线程池中的线程也拥有一个任务队列,使用std::unique_ptr<>
指向线程本地的工作队列②;这个指针在worker_thread()
中进行初始化③。std:unique_ptr<>
的析构函数会保证在线程退出的时候,工作队列被销毁。
submit()
会检查当前线程是否具有一个工作队列④。如果有,就是线程池中的线程,可以将任务放入线程的本地队列中;否者,就像之前一样将这个任务放在线程池中的全局队列中⑤。
run_pending_task()
⑥中的检查和之前类似,只是要对是否存在本地任务队列进行检查。如果存在,就会从队列中的第一个任务开始处理;注意本地任务队列可以是一个普通的std::queue<>①,因为这个队列只能被一个线程所访问,就不存在竞争。如果本地线程上没有任务,就会从全局工作列表上获取任务⑦。
这样就能有效避免竞争,不过当任务分配不均时,造成的结果就是:某个线程本地队列中有很多任务的同时,其他线程无所事事。例如:举一个快速排序的例子,只有一开始的数据块能在线程池上被处理,因为剩余部分会放在工作线程的本地队列上进行处理,这样的使用方式也违背使用线程池的初衷。
幸好,这个问题是有解:本地工作队列和全局工作队列上没有任务时,可从别的线程队列中窃取任务。
窃取任务
为了让没有任务的线程能从其他线程的任务队列中获取任务,就需要本地任务列表可以进行访问,这样才能让run_pending_tasks()
窃取任务。需要每个线程在线程池队列上进行注册,或由线程池指定一个线程。同样,还需要保证数据队列中的任务适当的被同步和保护,这样队列的不变量就不会被破坏。
实现一个无锁队列,让其拥有线程在其他线程窃取任务的时候,能够推送和弹出一个任务是可能的;不过,这个队列的实现就超出了本书的讨论范围。为了证明这种方法的可行性,将使用一个互斥量来保护队列中的数据。我们希望任务窃取是一个不常见的现象,这样就会减少对互斥量的竞争,并且使得简单队列的开销最小。下面,实现了一个简单的基于锁的任务窃取队列。
1 | class work_stealing_queue |
这个队列对std::deque<fuction_wrapper>
进行了简单的包装①,就能通过一个互斥锁来对所有访问进行控制了。push()
②和try_pop()
③对队列的前端进行操作,try_steal()
④对队列的后端进行操作。
这就说明每个线程中的“队列”是一个后进先出的栈,最新推入的任务将会第一个执行。从缓存角度来看,这将对性能有所提升,因为任务相关的数据一直存于缓存中,要比提前将任务相关数据推送到栈上好。同样,这种方式很好的映射到某个算法上,例如:快速排序。之前的实现中,每次调用do_sort()
都会推送一个任务到栈上,并且等待这个任务执行完毕。通过对最新推入任务的处理,就可以保证在将当前所需数据块处理完成前,其他任务是否需要这些数据块,从而可以减少活动任务的数量和栈的使用次数。try_steal()
从队列末尾获取任务,为了减少与try_pop()
之间的竞争。
1 | class thread_pool |
中断线程
启动和中断线程
先看一下外部接口,需要从可中断线程上获取些什么?最起码需要和std::thread
相同的接口,还要多加一个interrupt()
函数:1
2
3
4
5
6
7
8
9
10class interruptible_thread
{
public:
template<typename FunctionType>
interruptible_thread(FunctionType f);
void join();
void detach();
bool joinable() const;
void interrupt();
};
类内部可以使用std::thread
来管理线程,并且使用一些自定义数据结构来处理中断。现在,从线程的角度能看到什么呢?“能用这个类来中断线程”——需要一个断点(interruption point)。在不添加多余的数据的前提下,为了使断点能够正常使用,就需要使用一个没有参数的函数:interruption_point()
。这意味着中断数据结构可以访问thread_local
变量,并在线程运行时,对变量进行设置,因此当线程调用interruption_point()
函数时,就会去检查当前运行线程的数据结构。我们将在后面看到interruption_point()
的具体实现。
thread_local
标志是不能使用普通的std::thread
管理线程的主要原因;需要使用一种方法分配出一个可访问的interruptible_thread
实例,就像新启动一个线程一样。在使用已提供函数来做这件事情前,需要将interruptible_thread
实例传递给std::thread
的构造函数,创建一个能够执行的线程,就像下面的代码清单所实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30class interrupt_flag
{
public:
void set();
bool is_set() const;
};
thread_local interrupt_flag this_thread_interrupt_flag; // 1
class interruptible_thread
{
std::thread internal_thread;
interrupt_flag* flag;
public:
template<typename FunctionType>
interruptible_thread(FunctionType f)
{
std::promise<interrupt_flag*> p; // 2
internal_thread=std::thread([f,&p]{ // 3
p.set_value(&this_thread_interrupt_flag);
f(); // 4
});
flag=p.get_future().get(); // 5
}
void interrupt()
{
if(flag)
{
flag->set(); // 6
}
}
};
提供函数f
是包装了一个lambda函数③,线程将会持有f副本和本地promise变量(p)的引用②。在新线程中,lambda函数设置promise变量的值到this_thread_interrupt_flag
(在thread_local
①中声明)的地址中,为的是让线程能够调用提供函数的副本④。调用线程会等待与其future相关的promise就绪,并且将结果存入到flag成员变量中⑤。注意,即使lambda函数在新线程上执行,对本地变量p进行悬空引用,都没有问题,因为在新线程返回之前,interruptible_thread
构造函数会等待变量p,直到变量p不被引用。实现没有考虑处理汇入线程,或分离线程。所以,需要flag变量在线程退出或分离前已经声明,这样就能避免悬空问题。
interrupt()
函数相对简单:需要一个线程去做中断时,需要一个合法指针作为一个中断标志,所以可以仅对标志进行设置⑥。
检查线程是否中断
现在就可以设置中断标志了,不过不检查线程是否被中断,这样的意义就不大了。使用interruption_point()
函数最简单的情况;可以在一个安全的地方调用这个函数,如果标志已经设置,就可以抛出一个thread_interrupted
异常:1
2
3
4
5
6
7void interruption_point()
{
if(this_thread_interrupt_flag.is_set())
{
throw thread_interrupted();
}
}
代码中可以在适当的地方使用这个函数:1
2
3
4
5
6
7
8void foo()
{
while(!done)
{
interruption_point();
process_next_item();
}
}
使用std::condition_variable_any中断等待
std::condition_variable_any
与std::condition_variable
的不同在于,std::condition_variable_any
可以使用任意类型的锁,而不仅有std::unique_lock<std::mutex>
。可以让事情做起来更加简单,并且std::condition_variable_any
可以比std::condition_variable
做的更好。因为能与任意类型的锁一起工作,就可以设计自己的锁,上锁/解锁interrupt_flag的内部互斥量set_clear_mutex,并且锁也支持等待调用,就像下面的代码。
1 | class interrupt_flag |
自定义的锁类型在构造的时候,需要所锁住内部set_clear_mutex
①,对thread_cond_any
指针进行设置,并引用std::condition_variable_any
传入锁的构造函数中②。Lockable引用将会在之后进行存储,其变量必须被锁住。现在可以安心的检查中断,不用担心竞争了。如果这时中断标志已经设置,那么标志一定是在锁住set_clear_mutex
时设置的。当条件变量调用自定义锁的unlock()
函数中的wait()
时,就会对Lockable对象和set_clear_mutex进行解锁③。这就允许线程可以尝试中断其他线程获取set_clear_mutex锁;以及在内部wait()调用之后,检查thread_cond_any
指针。这就是在替换std::condition_variable
后,所拥有的功能(不包括管理)。当wait()
结束等待(因为等待,或因为伪苏醒),因为线程将会调用lock()函数,这里依旧要求锁住内部set_clear_mutex,并且锁住Lockable对象④。现在,在wait()调用时,custom_lock的析构函数中⑤清理thread_cond_any
指针(同样会解锁set_clear_mutex)之前,可以再次对中断进行检查。
中断其他阻塞调用
这次轮到中断条件变量的等待了,不过其他阻塞情况,比如:互斥锁,等待future等等,该怎么办呢?通常情况下,可以使用std::condition_variable
的超时选项,因为在实际运行中不可能很快的将条件变量的等待终止(不访问内部互斥量或future的话)。不过,在某些情况下,你知道知道你在等待什么,这样就可以让循环在interruptible_wait()
函数中运行。作为一个例子,这里为std::future<>
重载了interruptible_wait()
的实现:1
2
3
4
5
6
7
8
9
10
11template<typename T>
void interruptible_wait(std::future<T>& uf)
{
while(!this_thread_interrupt_flag.is_set())
{
if(uf.wait_for(lk,std::chrono::milliseconds(1)==
std::future_status::ready)
break;
}
interruption_point();
}
等待会在中断标志设置好的时候,或future准备就绪的时候停止,不过实现中每次等待future的时间只有1ms。这就意味着,中断请求被确定前,平均等待的时间为0.5ms(这里假设存在一个高精度的时钟)。通常wait_for至少会等待一个时钟周期,所以如果时钟周期为15ms,那么结束等待的时间将会是15ms,而不是1ms。接受与不接受这种情况,都得视情况而定。
处理中断
从中断线程的角度看,中断就是thread_interrupted异常,因此能像处理其他异常那样进行处理。
特别是使用标准catch块对其进行捕获:1
2
3
4
5
6
7
8try
{
do_something();
}
catch(thread_interrupted&)
{
handle_interruption();
}
捕获中断,进行处理。其他线程再次调用interrupt()时,线程将会再次被中断,这就被称为断点(interruption point)。如果线程执行的是一系列独立的任务,就会需要断点;中断一个任务,就意味着这个任务被丢弃,并且该线程就会执行任务列表中的其他任务。
因为thread_interrupted
是一个异常,在能够被中断的代码中,之前线程安全的注意事项都是适用的,就是为了确保资源不会泄露,并在数据结构中留下对应的退出状态。通常,让线程中断是可行的,所以只需要让异常传播即可。不过,当异常传入std::thread
的析构函数时,std::terminate()
将会调用,并且整个程序将会终止。为了避免这种情况,需要在每个将interruptible_thread
变量作为参数传入的函数中放置catch(thread_interrupted)处理块,可以将catch块包装进interrupt_flag的初始化过程中。因为异常将会终止独立进程,就能保证未处理的中断是异常安全的。interruptible_thread
构造函数中对线程的初始化,实现如下:1
2
3
4
5
6
7
8
9internal_thread=std::thread([f,&p]{
p.set_value(&this_thread_interrupt_flag);
try
{
f();
}
catch(thread_interrupted const&)
{}
});
下面,我们来看个更加复杂的例子。
应用退出时中断后台任务
试想,在桌面上查找一个应用。这就需要与用户互动,应用的状态需要能在显示器上显示,就能看出应用有什么改变。为了避免影响GUI的响应时间,通常会将处理线程放在后台运行。后台进程需要一直执行,直到应用退出;后台线程会作为应用启动的一部分被启动,并且在应用终止的时候停止运行。通常这样的应用只有在机器关闭时,才会退出,因为应用需要更新应用最新的状态,就需要全时间运行。在某些情况下,当应用被关闭,需要使用有序的方式将后台线程关闭,其中一种方式就是中断。
下面清单中为一个系统实现了简单的线程管理部分。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35std::mutex config_mutex;
std::vector<interruptible_thread> background_threads;
void background_thread(int disk_id)
{
while(true)
{
interruption_point(); // 1
fs_change fsc=get_fs_changes(disk_id); // 2
if(fsc.has_changes())
{
update_index(fsc); // 3
}
}
}
void start_background_processing()
{
background_threads.push_back(
interruptible_thread(background_thread,disk_1));
background_threads.push_back(
interruptible_thread(background_thread,disk_2));
}
int main()
{
start_background_processing(); // 4
process_gui_until_exit(); // 5
std::unique_lock<std::mutex> lk(config_mutex);
for(unsigned i=0;i<background_threads.size();++i)
{
background_threads[i].interrupt(); // 6
}
for(unsigned i=0;i<background_threads.size();++i)
{
background_threads[i].join(); // 7
}
}
启动时,后台线程就已经启动④。之后,对应线程将会处理GUI⑤。当用户要求进程退出时,后台进程将会被中断⑥,并且主线程会等待每一个后台线程结束后才退出⑦。后台线程运行在一个循环中,并时刻检查磁盘的变化②,对其序号进行更新③。调用interruption_point()
函数,可以在循环中对中断进行检查。
库
头文件
<condition_variable>
头文件提供了条件变量的定义。其作为基本同步机制,允许被阻塞的线程在某些条件达成或超时时,解除阻塞继续执行。
头文件内容1
2
3
4
5
6namespace std
{
enum class cv_status { timeout, no_timeout };
class condition_variable;
class condition_variable_any;
}
std::condition_variable类
std::condition_variable允许阻塞一个线程,直到条件达成。
std::condition_variable
实例不支持CopyAssignable(拷贝赋值), CopyConstructible(拷贝构造), MoveAssignable(移动赋值)和 MoveConstructible(移动构造)。
类型定义1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32class condition_variable
{
public:
condition_variable();
~condition_variable();
condition_variable(condition_variable const& ) = delete;
condition_variable& operator=(condition_variable const& ) = delete;
void notify_one() noexcept;
void notify_all() noexcept;
void wait(std::unique_lock<std::mutex>& lock);
template <typename Predicate>
void wait(std::unique_lock<std::mutex>& lock,Predicate pred);
template <typename Clock, typename Duration>
cv_status wait_until(
std::unique_lock<std::mutex>& lock,
const std::chrono::time_point<Clock, Duration>& absolute_time);
template <typename Clock, typename Duration, typename Predicate>
bool wait_until(
std::unique_lock<std::mutex>& lock,
const std::chrono::time_point<Clock, Duration>& absolute_time,
Predicate pred);
template <typename Rep, typename Period>
cv_status wait_for(
std::unique_lock<std::mutex>& lock,
const std::chrono::duration<Rep, Period>& relative_time);
template <typename Rep, typename Period, typename Predicate>
bool wait_for(
std::unique_lock<std::mutex>& lock,
const std::chrono::duration<Rep, Period>& relative_time,
Predicate pred);
};
void notify_all_at_thread_exit(condition_variable&,unique_lock<mutex>);
std::condition_variable_any类
std::condition_variable_any
类允许线程等待某一条件为true的时候继续运行。不过std::condition_variable
只能和std::unique_lock<std::mutex>
一起使用,std::condition_variable_any
可以和任意可上锁(Lockable)类型一起使用。
std::condition_variable_any
实例不能进行拷贝赋值(CopyAssignable)、拷贝构造(CopyConstructible)、移动赋值(MoveAssignable)或移动构造(MoveConstructible)。
类型定义1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38class condition_variable_any
{
public:
condition_variable_any();
~condition_variable_any();
condition_variable_any(
condition_variable_any const& ) = delete;
condition_variable_any& operator=(
condition_variable_any const& ) = delete;
void notify_one() noexcept;
void notify_all() noexcept;
template<typename Lockable>
void wait(Lockable& lock);
template <typename Lockable, typename Predicate>
void wait(Lockable& lock, Predicate pred);
template <typename Lockable, typename Clock,typename Duration>
std::cv_status wait_until(
Lockable& lock,
const std::chrono::time_point<Clock, Duration>& absolute_time);
template <
typename Lockable, typename Clock,
typename Duration, typename Predicate>
bool wait_until(
Lockable& lock,
const std::chrono::time_point<Clock, Duration>& absolute_time,
Predicate pred);
template <typename Lockable, typename Rep, typename Period>
std::cv_status wait_for(
Lockable& lock,
const std::chrono::duration<Rep, Period>& relative_time);
template <
typename Lockable, typename Rep,
typename Period, typename Predicate>
bool wait_for(
Lockable& lock,
const std::chrono::duration<Rep, Period>& relative_time,
Predicate pred);
};