《C++ Concurrency in Action》笔记26 原子操作和强制排序

来源:互联网 发布:女生变漂亮知乎 编辑:程序博客网 时间:2024/06/05 06:13

5.3 同步操作和强制排序

假如你有2个线程,一个填充数据,另一个读取数据。它们通过一个标志位来达到同步的目的:

Listing 5.2 Reading and writing variables from different threads

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] << ”\n”;//(2)}void writer_thread(){data.push_back(42);//(3)data_ready = true;//(4)}

先不考虑循环等待数据是否可读带来的效率低下问题,如果你不这样做那么就无法共享数据:数据的每一项都必须被强制成为原子的。你也许已经知道:如果不使用原子操作,同时读写这些数据会产生未定义行为,所以必须在某处做强制排序。

需要做强制排序的是data_ready变量;这些操作通过内存模型相关的happens-before以及synchronizes-with来实现。数据写入发生在标志位写入之前,标志位读取发生在数据读取之前。当读取data_ready 得到的值为true时,则写数据synchronizes-with读操作,建立了一个happens-before关系。

因为happens-before是可传递的,写数据(3)发生在写标识(4)之前,而写标识又发生在读标识等于true(1)之前,而(1)又发生在读数据(2)之前。这样你就拥有一个强制排序:写数据发生在读数据之前,所有事情都能正常工作。

原子类型还有其他的操作可以实现强制排序。稍后介绍。

现在我们来看看happens-before和synchronizes-with的原理,先从synchronizes-with开始。

5.3.1 synchronizes-with关系

synchronizes-with关系是在多个线程中执行原子操作只能获取的唯一事物。操作一个数据结构(例如锁定一个mutex),如果这个数据结构包含原子类型,并且针对它的操作都能提供内部原子操作,那么它就能提供synchronizes-with关系,但是从根本上讲,这种关系来自于原子类型操作。

The basic idea is this: a suitably tagged atomic write operation  W on a variable  x syn-chronizes-with a suitably tagged atomic read operation on  x that reads the value stored by either that write ( W ), or a subsequent atomic write operation on  x by the same thread that performed the initial write  W , or a sequence of atomic read-modify-write operations on  x (such as  fetch_add() or  compare_exchange_weak() ) by any thread, where the value read by the first thread in the sequence is the value written by  W.

现在可以抛开“适当标记”,因为原子类型的所有操作都是默认被适当标记的。这基本上可以能就像你想的那样: 如果线程A保存数据,线程B读取数据,那么这2个操作就存在一种synchronizes-with关系。

我确定,你可能已经猜到,细微的差别就在“适当标记”部分。这部分将在5.3.3中讲解,现在让我们回头看看happens-before关系。

5.3.2 happens-before关系

happens-before关系是程序中的基本的操作顺序块;它指定了哪些操作可以看到其他操作的影响。对于单线程来说,这非常简单:如果一个操作A的执行顺序排在在另一个操作B之前(sequenced before),那也意味着A发生在B之前(happens-before)。如果操作发生在同一条语句中,那么通常情况下它们之间就没有happens-before的关系了,因为它们是未排序的,或者说执行顺序为指定。下面的程序将输出 “1,2” 或者  “2,1” ,因为两个 get_num()没有被指定执行的先后顺序:

Listing 5.3 Order of evaluation of arguments to a function call is unspecified

void foo(int a, int b){std::cout << a << ”, ” << b << std::endl;}int get_num(){static int i = 0;return ++i;}int main(){foo(get_num(), get_num());}

在某些情况下,单个语句中的操作是有序的,例如使用内置逗号操作符的语句,或者将一个表达式的结果用作另一个表达式的参数。但通常情况下,一个语句中的多个操作的执行顺序是不确定的。

这是单个线程中的情况,多个线程中呢?如果一个线程中的操作A先于另一个线程中的操作B发生(inter-thread happens-before),那么A先于(happens-before)B发生。这并没有什么帮助:徒劳引入一个新的概念:inter-thread happens-before。但这对于编写多线程代码是尤为重要的。

在基本层面上来说,inter-thread happens-before相当简单,而且它依赖于5.3.1中所讲的synchronizes-with关系:如果一个线程中的操作A synchronizes-with 另一个线程中的操作B,那么A inter-thread happens-before B。而且这种关系具有传递性:if A inter-thread happens-before B and B inter-thread happens-before C, then A inter-thread happens-before C.

Inter-thread happens-before也可以与sequenced-before关系相结合:如果操作A sequenced-before 操作B,而B又Inter-thread happens-before于C,那么A就 inter-thread happens-before于C。类似的,如果 A synchronizes-with B, 而且B sequenced-before C, 那么A inter-thread happens-before 于C。这些都说明了一件事:如果你能保证在一个线程中对数据的改变操作都是序列化的,那么你只需要与另一个线程中的C操作建立一个synchronizes-with关系。

现在我们来看看内存序列(memory-ordering)标准是如何影响synchronizes-with关系的。

5.3.3 原子操作的内存序列

有6个内存序列选项可以提供给原子操作:

memory_order_relaxed ,  memory_order_consume ,  memory_order_acquire ,memory_order_release ,  memory_order_acq_rel , 以及  memory_order_seq_cst。

如果不指定参数的话,所有操作的缺省内存序列都是 memory_order_seq_cst,这是最严格的可用的选项。虽然有6个选项,但是它们代表了3种模式:

顺序一致(sequentially consistent)序列: memory_order_seq_cst

请求-释放(acquire-release)序列: memory_order_consume ,  memory_order_acquire ,  memory_order_release , memory_order_acq_rel

自由(relaxed)序列:memory_order_relaxed

这些不同的内存序列模式,在不同的CPU架构上,开销是不一样的。

For example, on systems based on architectures with fine control over the visibility of operations by processors other than the one that made the change,additional synchronization instructions can be required for sequentially consistent ordering over acquire-release ordering or relaxed ordering and for acquire-release order-ing over relaxed ordering. If these systems have many processors, these additional synchronization instructions may take a significant amount of time, thus reducing the overall performance of the system. On the other hand,  CPU s that use the x86 or x86-64 architectures (such as the Intel and  AMD processors common in desktop  PC s) don’t require any additional instructions for acquire-release ordering beyond those necessary for ensuring atomicity, and even sequentially-consistent ordering doesn’t require any special treatment for load operations, although there’s a small additional cost on stores.

让我们看看选择不同的内存序列对操作顺序和synchronizes-with的影响。

顺序一致

缺省的序列被称为顺序一致,这是因为它意味着程序的行为与看上去是一致的。如果对原子类型的实例的所有操作都是顺序一致的,则多线程程序的行为就好像所有这些操作都是由单个线程以特定的顺序执行的。这是到目前为止最容易理解的内存序列,把它作为缺省的原因是:所有线程必须看到相同的操作顺序。这使得很容易理解用原子变量编写的代码的行为。您可以通过不同的线程写下所有可能的操作序列,消除那些不一致的操作,并验证您的代码是否符合期望。这也意味着操作无法重新排序;如果在你的代码中,同一个线程中的一个操作先于另一个操作,则所有其他线程都必须看到该顺序。

从同步的角度来看,对于同一个数据,顺序一致的读操作与顺序一致的写操作是synchronizes-with关系。顺序一致为多个线程的的操作提供了顺序限制,但是顺序一致序列的功能远不止于此。Any sequentially consistent atomic operations done after that load must also appear after the store to other threads in the system using sequentially consistent atomic operations. 稍后给出的程序说明了这点。

不过,容易理解的东西是要付出代价的。在一个多核的弱指令机器上,这可能带来明显的性能损失,因为要在多核中保持整个操作的一致性需要在各个处理器之间做出大量的昂贵的同步操作。据说,一些处理器架构(如普通的x86和x86-64架构)相对便宜地提供顺序一致性,如果你想考虑使用顺序一致对性能的影响,那么就应该检查您的目标处理器架构的相关文档

下面的程序演示了顺序一致在实际中的使用,尽管memory_order_seq_cst参数可以省略不写,因为它是缺省参数,但是为了明确这个程序的意义,我们还是显示的写出来:

Listing 5.4 Sequential consistency implies a total ordering

std::atomic<bool> x, y;std::atomic<int> z;vector<long long> v(4);mutex mu;condition_variable cd;bool run = false;void wait_for_run(){unique_lock<mutex> lk(mu);cd.wait(lk, [] {return run; });}void record_time(int index){v[index] = chrono::system_clock::now().time_since_epoch().count();}void write_x(){wait_for_run();x.store(true, std::memory_order_seq_cst);record_time(0);}void write_y(){wait_for_run();y.store(true, std::memory_order_seq_cst);record_time(1);}void read_x_then_y(){wait_for_run();while (!x.load(std::memory_order_seq_cst));if (y.load(std::memory_order_seq_cst))++z;record_time(2);}void read_y_then_x(){wait_for_run();while (!y.load(std::memory_order_seq_cst));if (x.load(std::memory_order_seq_cst))++z;record_time(3);}void call_by_main(){auto cur = chrono::system_clock::now().time_since_epoch().count();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);this_thread::sleep_for(chrono::seconds(1));run = true;cd.notify_all();a.join();b.join();c.join();d.join();assert(z.load() != 0);cout << "z:" << z << endl;for (auto i : v){cout << i - cur << endl;}system("pause");}

可能的输出:

z:110460987104609191046117110460919请按任意键继续. . .

无论是先执行write_x()还是先执行write_y(),程序都能保证z的结果大于0,要么是1,要么是2。不管哪个线程,它们看到的变量的操作顺序都是一样的。

顺序一致序列会导致严重的性能损失,为了避免这个缺点,需要使用其他内存指令。

非顺序一致的内存指令

一旦抛开顺序一致,事情就变得复杂起来。可能要处理的最大的一个问题是事实上不再有单一的全局性顺序事件。这意味着相同的操作可能被不同的线程可以看到不同的效果
,任何来自不同线程的,一个接一个地整齐地交错执行的模型,必须被丢弃。你不仅必须解决真正并发的事情,而且线程不必就事件的顺序达成一致。编译器不仅可以对指令进行重新排序,尽管是同一段代码,如果是不同的线程去执行,它们仍然有可能不按序执行,因为在其他线程中的操作是在没有明确的排序限制的情况下进行的,计算机CPU的缓存可能保持了同一段内存的不同数值。我还要再说一遍:线程不需要就事件的顺序达成一致。

你不但必须抛弃按需交错执行的思想,而且也要抛弃编辑器重排指令的思想。在没有明确的顺序限制下,唯一能保证的就是,所有线程都要统一对每一个独立变量的修改顺序。对不同变量的操作在不同的线程上可能会体现不同的顺序,提供的值要与任意附加顺序限制保持一致。

为所有操作使用memory_order_relaxed是抛弃顺序一致的最好演示。一旦你理解了它的使用,那么你将回到请求-释放关系中,这使您可以有选择地引入操作之间的顺序关系,让你重新变得理智一些。

自由序列

以自由顺序执行的原子类型的操作不涉及synchronizes-with关系。在一个线程中操作同一个变量仍属于happens-before关系,但是这几乎不要求与另一个线程的操作有任何顺序关系。唯一的要求是访问单个来自同一线程的原子变量无法重新排序,一旦给定线程看到一个原子变量的特定值,该线程的后续读取操作不能获取变量的较早的值。在使用memory_order_relaxed的线程之间共享数据,只需要注意每个变量的修改顺序,不需要额外的同步。

为了描述自由序列是如何自由的,你只需要两个线程,看下面的例子:

Listing 5.5 Relaxed operations have very few ordering requirements

std::atomic<bool> x, y;std::atomic<int> z;void write_x_then_y(){x.store(true, std::memory_order_relaxed);y.store(true, std::memory_order_relaxed);}void read_y_then_x(){while (!y.load(std::memory_order_relaxed));if (x.load(std::memory_order_relaxed))++z;}void f(){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);}

这次assert()有可能被触发。因为x.load可能得到false,尽管y.load()得到了true,并且x.store()发生在y.store()之前。x和y是不同的变量,所以每个操作的顺序是没有保证的。对于不同变量的操作可以是自由排序,只要它们遵守应该遵守的happend-before关系即可(例如在一个线程中)。它们并不引入synchronizes-with关系。

看下面的程序:

Listing 5.6 Relaxed operations on multiple threads

std::atomic<int> x(0), y(0), z(0);std::atomic<bool> go(false);unsigned const loop_count = 10;struct read_values{int x, y, z;};read_values values1[loop_count];read_values values2[loop_count];read_values values3[loop_count];read_values values4[loop_count];read_values values5[loop_count];void increment(std::atomic<int>* var_to_inc, read_values* values){while (!go)std::this_thread::yield();for (unsigned i = 0; i<loop_count; ++i){values[i].x = x.load(std::memory_order_relaxed);values[i].y = y.load(std::memory_order_relaxed);values[i].z = z.load(std::memory_order_relaxed);var_to_inc->store(i + 1, std::memory_order_relaxed);std::this_thread::yield();}}void read_vals(read_values* values){while (!go)std::this_thread::yield();for (unsigned i = 0; i<loop_count; ++i){values[i].x = x.load(std::memory_order_relaxed);values[i].y = y.load(std::memory_order_relaxed);values[i].z = z.load(std::memory_order_relaxed);std::this_thread::yield();}}void print(read_values* v){for (unsigned i = 0; i<loop_count; ++i){if (i)std::cout << ",";std::cout << "(" << v[i].x << "," << v[i].y << "," << v[i].z << ")";}std::cout << std::endl;}void call_by_main(){std::thread t1(increment, &x, values1);std::thread t2(increment, &y, values2);std::thread t3(increment, &z, values3);std::thread t4(read_vals, values4);std::thread t5(read_vals, values5);go = true;t5.join();t4.join();t3.join();t2.join();t1.join();print(values1);print(values2);print(values3);print(values4);print(values5);system("pause");}

尽管每个线程中的保存于保存之间存在happens-before关系,同样每个线程中的读取和读取也存在这样的关系,但是并不意味着每个保存和读取都存在这样的关系。

原子变量go的作用是让所有线程尽可能几乎同一时刻开始工作。启动线程是个耗时的操作,如果没有的等待的话,可能最后一个线程启动时,第一个线程已经执行完毕了。

程序的一种可能的输出是:

(0,0,0),(1,0,0),(2,0,0),(3,0,0),(4,0,0),(5,7,0),(6,7,8),(7,9,8),(8,9,8),(9,9,10)(0,0,0),(0,1,0),(0,2,0),(1,3,5),(8,4,5),(8,5,5),(8,6,6),(8,7,9),(10,8,9),(10,9,10)(0,0,0),(0,0,1),(0,0,2),(0,0,3),(0,0,4),(0,0,5),(0,0,6),(0,0,7),(0,0,8),(0,0,9)(1,3,0),(2,3,0),(2,4,1),(3,6,4),(3,9,5),(5,10,6),(5,10,8),(5,10,10),(9,10,10),(10,10,10)(0,0,0),(0,0,0),(0,0,0),(6,3,7),(6,5,7),(7,7,7),(7,8,7),(8,8,7),(8,8,9),(8,8,9)

从输出可以看到如下现象:

1.第一组输出中的x是每次增加1,第二组中的y、第三组中的z也是这样。

2.仅仅在每组中可以看到x、y、z是都是顺序增加的,但是增加的幅度并不均匀,而且在不同组间的相对顺序也不同。

3.第三组中看不到x或者y的值被更新,但这并不妨碍其他线程在看到x和y更新的同时看到z的更新。

理解自由序列

为了理解它的原理,我们想象,每个变量都是一个拿着记事本的人,他们处于不同的小房间中。记事本上记着的是一个数值表(list),你可以打电话问他要某个数值,或者你可以让他写下一个数值。如果你让他写下一个数值,那么他就会在表的最后写下这个数值。如果你问他要一个数值,他会从列表中给你一个数字。

你第一次跟他通话时,如果你跟他要一个数值,他会从本上读取任何数值给你。然后你再次跟他要数值时,他可能给你同一个值,或者表中靠后的值。他绝不会给你表中靠前的值。如果你让他写一个值,然后接着管他要一个值,他可能给你刚才你让他写的那个值,或者表中靠后的值。

想象一下在某个时刻,他的本子上记着的数字是:5, 10, 23, 3, 1, 2。如果你和他要一个数值,他可能给你这些数值中的任何一个。如果他给了你10,那么下次你再管他要的时候他可能再次给你10,或者10之后的数值,但不会给你5。例如,你要了5次,他可能先后给你这5个数值:10, 10, 1, 2, 2。如果你告诉它写下42,他将把42写在表中的最后位置。如果你再管他要数值,那么直到有另一个数值被写到这个表的后面之前,他始终会给你42这个数。

现在想象,如果除了你以外还有另一个人卡尔,卡尔也具有和你一样的权利,并且小房间里面的人对卡尔遵守同样的规则。小房间里的人,他只有一台电话,每次只能处理一个人的请求,所以它本子上的表示非常简单的。但是,你让他写下一个数值时不代表他必须将其告诉卡尔,反过来也如此。如果卡尔跟他要了一个数值,并且被告知23,之后你让他写下了一个数字42,但这不代表下次他会告诉卡尔42这个数。下次,他可能会告诉卡尔23, 3, 1, 2, 42中的任何数值,甚至是在你之后弗雷德让他写下的67。他会很高兴的告诉卡尔23, 3, 3, 1, 67中的任何一个数值,但是不必与他曾经告诉过你的记录保持一致。这就像他保持了一些标签,分别跟踪告诉每个人的数,就像下图这样:

再想像一下,现在不仅仅有一个在小房间中的人,而是一整个农场,里面的人都带着本子和电话。他们都是我们的原子变量。每个变量都有他们自己的修改顺序(本子上的表),但是他们之间没有任何关系。如果每个给他们打电话的人都是一个线程,那这种关系就是所有操作都使用memory_order_relaxed序列的情形。另外,还有一些额外的事情你可以让房间里的人去做,例如:告诉他写下一个数,并把之前表中最后一个数值告诉你(exchanged()),如果表中的最后的数值是这个值,那么写下另一个值( compare_exchange_strong()),但这并不影响总体规则。

如果你回过头思考listing5.5中程序的逻辑,x和y分别是两个房间中的拿着本子的人,执行2个函数的2个线程就像外面要打电话的2个人,他们分别给两个房间中的人打电话,分别沉浸在自己与每个房间的人之间建立的通讯世界里,而彼此不相知晓。x和y手中的表中初始记录的是false。 write_x_then_y()相当于某人告诉x写下true,然后再告诉y写下true。read_y_then_x()相当于另一个人先跟y要数值,直到得到true,然后再和x要数值,而x很自然的可能给出一个false或者true中的任何值。

这使得自由原子序列操作非常难于处理。它们必须与具有更强排序语义的原子操作结合使用,才有助于进行线程之间的同步。我强烈建议避免使用自由原子序列操作,除非它们是绝对必要的,甚至仅是非常小心的使用也不建议。

在不产生因为完整的顺序一致性的开销情况下实现同步的一种方式是使用acquire-release序列。

acquire-release序列

acquire-release一种自由序列的升级版。此时,依然没有全局固定的操作顺序,但是它引入了一些同步。在这种序列模型下,原子读取是一种acquire操作(memory_order_acquire),原子存储是一种release操作( memory_order_release),而原子read-modify-write操作(就像fetch_add()或者exchange())是要么acquire要么release的操作,或者既acquire又release操作。在获取的线程和发布的线程之间,同步是成对的。发布操作与读取写入值的获取操作同步。这意味着,不同的线程会看到不同的顺序,但是这些顺序是受到约束的。

下面的程序是5.4程序的翻版,它使用acquire-release代替了squentially-consistent:

Listing 5.7 Acquire-release doesn’t imply a total ordering

std::atomic<bool> x, y;std::atomic<int> z;void write_x(){x.store(true, std::memory_order_release);}void write_y(){y.store(true, std::memory_order_release);}void read_x_then_y(){while (!x.load(std::memory_order_acquire));if (y.load(std::memory_order_acquire))//❶++z;}void read_y_then_x(){while (!y.load(std::memory_order_acquire));if (x.load(std::memory_order_acquire))//❷++z;}void call_by_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);//❸}

此处的assert()可能触发(就像使用relaxed-ordering那样),因为x.load()❶和y.load()❷都有可能得到false,x和y是由不同的线程写入的,所以在每种情况下从释放到获取的顺序对其他线程的操作没有影响。

要想发挥acquire-release的益处,需要考虑在一个线程中来保存两个变量的值,就像程序5.5中那样。然后使用memory_order_release对y执行存储,使用memory_order_acquire对y执行获取,那么你实际上对x上的操作施加了一个排序。看下面的程序:

Listing 5.8 Acquire-release operations can impose ordering on relaxed operations

std::atomic<bool> x, y;std::atomic<int> z;void write_x_then_y(){x.store(true, std::memory_order_relaxed);//❶y.store(true, std::memory_order_release);//❷}void read_y_then_x(){while (!y.load(std::memory_order_acquire));//❸if (x.load(std::memory_order_relaxed))//❹++z;}void call_by_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);//❺cout << z << endl;system("pause");}

终于,对y进行的读取❸可以得到对y存储❷的true,因为存储使用了 memory_order_release,而读取使用了 memory_order_acquire,存储synchronizes-with读取。对x的存储❶发生在对y的存储❷之前(happens-before),因为它们在一个线程。由于对y的存储同步于(synchronizes-with)对y的读取,所以,对x的存储一定发生在对y的存储之前,进而严续为对x的存储一定发生在对x的读取之前。(哦买嘎,到这里终于有点头绪了!这书的英文真是绕!)

对x的读取操作一定能得到true,所以assert()永远不会触发。如果对y的读取没有位于一个while循环中,情况可能就大不相同了,对y的读取可能得到一个false,此时x读到的值就没有保证了(in which case there’d be no requirement on the value read from  x )。

为了同步,release和acquire必须成对出现。如果在❷处的存储或者在❸处的读取操作是一种自由顺序,那就不能保证能从x获取true,assert()就可能被触发。

你也可以将release-acqiure同之前讲的房间里拿着本子的人的例子联系到一起,但是你需要为这个模型增加一些事情。首先,想象每此存储都是一批更新动作中的一部分,所以,当你告诉他要写下一个数的时候,还需要告诉他这次存储是哪一批更新动作中的一部分:请写下99,作为第423批更新动作的一部分。如果存储的是一批更新中的最后一个数,那么你也要告诉他:请写下147,这是第423批更新动作的最后一个更新。他将适当的写下这个信息,挨着告诉他数值的那个人。这构建了一个存储-发布模型。下次你再告诉某人写下数值时需要更新批次序号:请写下42,作为第424批次的一部分。

当你想要跟他要一个数时,你有2个选择:要么只是要个数值(对应于自由读取),那样他会给你一个数值;或者你也在问一个数字的同时告诉他那个数是否是一批中的最后一个(对应于load-acquire)。当你询问批次信息时,如果值不是一批中的最后一个数值时,他会告诉你:数值是987,但这只是这批中的一个普通值,而如果数值是一批中的最后一个数值,那么他会告诉你:数值是987,这是由安妮更新的第956批中的最后一个数值。

现在acquire-release的语义已经呈现出来了:当你要一个数值时,如果你告诉他所有你知道的批次,那他会在所有你告诉他的批次中查找最后的数值,然后给你那个数值或者或者在表中继续往下查(one further down the list)。

acquire-release的语义究竟是什么?再次查看我们的示例。首先,运行write_x_then_y()函数的线程a对房间x中的人说:请写下数值true,这来自a的第1批次更新动作,然后他写下了。然后对房间y中的人说:请写下数值true,这来自a的第1批次更新动作,并且是最后一个数值,然后他写下了。

同时,线程b在执行read_y_then_x()函数。线程b一直在问房间y中的人要一个数值,并且带着批次信息,直到他给出了一个true值。b可能需要问很多次,但最终会得到true,此时y中的人不仅仅会给他这个true,还会告诉b,这个数值是来自a更新的第1批次的最后一个数值。

现在线程b继续跟x中的人要一个数值,但这次他说:能不能给我一个数值,顺便说一句,我知道来自线程a的第1个批次更新。因此,x中的人不得不去查看来自线程a的第1批次更新的最靠后的数据,此时,唯一的数值是true,也是表中的最后一个,所以他必须将这个值给出,否则他就破坏游戏规则了。

由于inter-thread happens-before关系可以传递,因此acquire-release序列可以在多个线程之间建立同步关系,尽管有些线程并没有接触到数据。

使用acquire-release传递同步

为了思考顺序的传递,必须至少有3个线程。第一个线程修改一些共享数据,然后对其中一个使用store-release序列。第二个线程则使用load-acquire读取那个使用store-release序列存储的数据,然后再使用store-release序列为第二个共享变量存储数据。最后,第三个线程使用load-acquire读取第二个共享变量。由于有了这些同步手段,第三个线程可以直接读取被第一个线程存储的其他变量,下面列出程序:

Listing 5.9 Transitive synchronization using acquire and release ordering

std::atomic<int> data[5];std::atomic<bool> sync1(false), sync2(false);void thread_1(){data[0].store(42, std::memory_order_relaxed);data[1].store(97, std::memory_order_relaxed);data[2].store(17, std::memory_order_relaxed);data[3].store(-141, std::memory_order_relaxed);data[4].store(2003, std::memory_order_relaxed);sync1.store(true, std::memory_order_release);}void thread_2(){while (!sync1.load(std::memory_order_acquire));sync2.store(true, std::memory_order_release);}void thread_3(){while (!sync2.load(std::memory_order_acquire));assert(data[0].load(std::memory_order_relaxed) == 42);assert(data[1].load(std::memory_order_relaxed) == 97);assert(data[2].load(std::memory_order_relaxed) == 17);assert(data[3].load(std::memory_order_relaxed) == -141);assert(data[4].load(std::memory_order_relaxed) == 2003);}

所有assert都不会被触发。

此时,可以在线程2中通过使用一个带有memory_order_acq_rel序列的read-modify-write的操作来把变量sync1和sync2合并为一个变量。一种方法是使用 compare_exchange_strong()以确保当看见这个被线程1存储的值时,值只被线程2更新了一次:

std::atomic<int> sync(0);void thread_1(){// ...sync.store(1, std::memory_order_release);}void thread_2(){int expected = 1;while (!sync.compare_exchange_strong(expected, 2,std::memory_order_acq_rel))expected = 1;}void thread_3(){while (sync.load(std::memory_order_acquire)<2);// ...}

如果你使用read-modify-write操作,那么原子序列的选择尤为重要。此时,你既希望使用acquire又想使用release,所以memory_order_acq_rel是合适的。

如果你将acquire-release和顺序一致序列混合使用,那么顺序一致的读取操作就像带着acquire语音的读取操作一样,而顺序一致的存储操作就像带着release的存储操作一样。顺序一致的的read-modify-write操作就是带着acquire和release的操作。自由序列操作仍然自由,但是要受到由acquire-release引入的synchronizes-with和随之的happens-before关系的限制。

尽管潜在的结果不是那么直观,任何使用mutex的人都不得不处理顺序问题:锁定一个mutex是一个acquire操作,释放一个mutex是一个release操作。对于mutex,你必须确保在度或者写的时候它是处于锁定状态的,同样的道理也适合这里;acquire和release操作必须针对同一个变量以确保它一个顺序。锁的独特性质意味着,结果是与顺序一致的锁定和解锁操作分不开的。同样,如果你利用原子变量的的acquire和release操作去构建一个简单的锁,从使用锁这一角度来看,它的行为将是顺序一致的,尽管内部操作并不是这样。

如果你的原子操作不需要严格的顺序一致,成对的acquire-release序列是潜在的比全局性排序的顺序一致操作更少消耗的同步手段。这里需要在保证排序正常,并且跨线程的非直观行为不出现问题的前提下,权衡心理成本。

acquire-release和memory-order-consume之间的数据依赖

在这一章中,我说过memory_order_consume是acquire-release模型中的一部分,但是之前一直没说这是怎么回事。这是因为memory_order_consume很特别:它完全与数据依赖有关,它引入了数据依赖与5.3.2节中提到的inter-thread happens-before关系之间的细微差别。

处理数据依赖时涉及2中关系:dependency-ordered-before以及carries-a-dependency-to。就像sequenced-before,carries-a-dependency-to严格应用于单个线程,并且基于数据依赖模型。如果操作B使用了操作A的结果,那么A就carries-a-dependency-to于B。如果A的结果是一个存数值类型,比如int,并且A的结果保存到了一个变量中,然后这个变量的被B使用,那么这种关系仍然有效。这种关系也是具有传递性质的,如果A carries-a-dependency-to B,而且B carries-a-dependency-to C,那么A carries-a-dependency-to C。

而另一方面,dependency-ordered-before可以在线程之间起作用。它是通过使用了memory_order_consume 标识位的原子读取操作来引入的。这是memory_order_acquire的一种特殊情况,它将数据的同步限制在直接依赖关系上。一个使用了memory_order_release或memory_order_acq_rel或memory_order_seq_cst标识的存储操作A,dependency-ordered-before 一个使用了 memory_order_consume标识的读取操作(读取的正是被A保存的数据)。如果你使用 memory_order_acquire标志来读取的话,那么你将得到一个 synchronizes-with关系,dependency-ordered-before与synchronizes-with是相反的。如果操作B carries-a-dependency-to 操作C,那么操作A自然而然的就dependency-ordered-before C操作。

这实际上并不适用于同步目的,如果它不影响inter-thread happens-before关系的话,但是它的确做到了这一点:如果A dependency-ordered-before B,那么A也inter-thread happens-before B。

这种序列的一个重要应用是使用原子操作读取指针。先使用memory_order_release存储数据,然后再使用memory_order_consume读取数据,这样可以保证指针指向的数据被同步,并且对于不依赖的数据没有强加任何同步措施。

看下面示例:

Listing 5.10 Using  std::memory_order_consume to synchronize data

struct X{int i;std::string s;};std::atomic<X*> p;std::atomic<int> a;void create_x(){X* x = new X;x->i = 42;x->s = "hello";a.store(99, std::memory_order_relaxed);//❶p.store(x, std::memory_order_release);//❷}void use_x(){X* x;while (!(x = p.load(std::memory_order_consume)))//❸std::this_thread::sleep_for(std::chrono::microseconds(1));assert(x->i == 42);//❹assert(x->s == "hello");//❺assert(a.load(std::memory_order_relaxed) == 99);//❻}int main(){std::thread t1(create_x);std::thread t2(use_x);t1.join();t2.join();}

对p的存储(2)使用了memory_order_release,对p的读取(3)使用了memory_order_consume。这就意味着,对p的存储仅发生在 依赖于p值的读取p操作 之前。这也意味着assert(4)和(5)都不会被触发。但是assert(6)则有可能被触发,因为它不依赖于p的值。

有时,你不想要承担依赖的开销。而是希望编译器能在寄存器中缓存变量的值,并且重排指令以优化代码,而不比关心依赖关系。在这种情况下,可以使用std::kill_dependency()函数来打破依赖链。 std::kill_dependency()是一个简单的函数模板,它只是拷贝传入的参数并作为返回值,但是它的确能打破依赖关系。

例如,如果你有一个全局的只读数组,当你从另一个线程中获取一个指向这个数组的index时使用了memory_order_consume,这时你可以使用std::kill_dependency()函数解除依赖关系,通知编译器不用重新获取数组的值:

void do_something_with(int n){}int global_data[] = { … };std::atomic<int> index;void f(){int i = index.load(std::memory_order_consume);do_something_with(global_data[std::kill_dependency(i)]);//这样就不会重新获取global_data当前的值}

当然,这么简单的场景下无需使用std::kill_dependency()函数,但是在复杂的代码中,类似情节需要调用它。一定要记住,这是一种优化手段,所以使用它时一定要小心,并且在确实需要时才使用。

现在我们已经讲述了基本的内存序列,现在我们看看synchronizes-with关系的更复杂部分:release sequences。

5.3.4 release sequence与synchronizes-with

在5.3.1节中,我曾经说过,能够在存储一个原子变量和读取一个原子变量之间保持一个synchronizes-with关系,尽管在两者之间存在一系列的read-modify-write操作(都提供了适当的序列参数)。

现在我已经讲过了可能的所有内存序列,现在详细说一下。如果存储使用了memory_order_release ,  memory_order_acq_rel , 或者memory_order_seq_cst , 而读取使用了memory_order_consume ,  memory_order_acquire,或者memory_order_seq_cst ,而且操作链上的每个读取操作都读取之前的存储操作写入的值,那么这个操作链就构成了一个release sequence,而初始的存储操作synchronizes-with(对于memory_order_acquire或memory_order_seq_cst)于后来的读取操作,或者dependency-ordered-before(对于memory_order_consume)于后来的读取操作。任何处于这个操作链中的read-modify-write操作可以使用任意内存序列(甚至是 memory_order_relaxed)。

来看看它的原理以及重要性,考虑一个 atomic<int>被当做一个共享队列的元素个数,看下面的程序:

Listing5.11 Reading values from a queue with atomic operations

std::vector<int> queue_data;std::atomic<int> count;void populate_queue(){unsigned const number_of_items = 20;queue_data.clear();for (unsigned i = 0; i<number_of_items; ++i){queue_data.push_back(i);}count.store(number_of_items, std::memory_order_release);//❶初始存储}void consume_queue_items(){while (true){int item_index;if ((item_index = count.fetch_sub(1, std::memory_order_acquire)) <= 0)//❷一个RMW操作{wait_for_more_items();//❸等待更多元素continue;}process(queue_data[item_index - 1]);//❹读取队列数据是安全的}}int main(){std::thread a(populate_queue);std::thread b(consume_queue_items);std::thread c(consume_queue_items);a.join();b.join();c.join();}

如果只有一个线程执行consum_queue_items()函数,初始存储使用了memory_order_release,而读取使用了memory_order_acquire,所以存储synchronizes-with读取。如果有2个线程执行consum_queue_items()函数,那么第2个fetch_sub()函数将看到第一个fetch_sub()函数的操作结果,而不是由store()操作的结果。

如果没有发布顺序(release order)的规则,第二个读取线程就不会与第一个读取线程成为happens-before关系。那么这两个线程同时访问共享数据(count)就是不安全的,除非第一个fetch()函数使用了 memory_order_release序列,那将在两个读取线程中引入不必要的同步操作。

如果没有发布顺序(release order)的规则,或者没有在fetch_sub()中加入 memory_order_release,那么就不能保证第二个读取操作可以看到store()操作的结果,将会产生数据竞争。

幸运的是,第一个fetch_sub操作确实处于发布序列(release sequece)中,所以 store()操作同步于(synchronizes-with)第二个fetch_sub操作。但是,2个读取线程之间仍然没有synchronizes-with的关系。

There can be any number of links in the chain, but provided they’re all read-modify-write operations such as  fetch_sub() , the  store() will still synchronize-with each one
that’s tagged  memory_order_acquire . In this example, all the links are the same, and all are acquire operations, but they could be a mix of different operations with differ-
ent memory-ordering semantics.

虽然大多数的内存关系取决于执行原子操作时使用的内存序列,但是使用栅栏也有可能引入顺序限制。

5.3.5 栅栏(Fences)

如果没有一组栅栏,那么原子操作库就是不完整的。它们的典型用法是与使用了memory_order_relaxed的原子操作一起,在不修改数据的情况下对操作进行强制排序限制。栅栏是全局操作,影响执行栅栏的线程中的其他原子操作的顺序。栅栏也被称为“内存屏障(memory barriers)”,这个名字的由来就是因为它就像在代码中划了一条线,某些操作不能逾越这条线。当你重新查看5.3.3章节时,在不同变量上的自由操作通常可以被编译器或者硬件自由重排序。栅栏则限制了这种自由度,并且引入了以前不存在的happens-before和synchronizes-with关系。

让我们以这个程序开始,在两个线程中的两个原子操作间引入一个栅栏:

Listing 5.12 Relaxed operations can be ordered with fences

std::atomic<bool> x, y;std::atomic<int> z;void write_x_then_y(){x.store(true, std::memory_order_relaxed);//❶std::atomic_thread_fence(std::memory_order_release);//❷y.store(true, std::memory_order_relaxed);//❸}void read_y_then_x(){while (!y.load(std::memory_order_relaxed));//❹std::atomic_thread_fence(std::memory_order_acquire);//❺if (x.load(std::memory_order_relaxed))//❻++z;}void call_by_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);//不会触发中断system("pause");}

release栅栏❷ synchronizes-with acquire栅栏❺,因为读取y的操作❹读到了❸处保存的值。这就意味着,对x的存储操作❶ happens-before对x的读取操作❻,所以取到的x的值一定是true,assert()永远不会被触发。对比与原来的程序,如果没有加入栅栏,那么将会触发assert导致中断。注意,两个栅栏都是必要的:需要在一个线程中添加release栅栏,在另一个线程中添加acquire栅栏,以获取 synchronizes-with关系。

在这个程序中,release栅栏❷就相当于把对y的存储操作❸改成了memory_order_release,acquire栅栏❺就相当于把对y的读取操作❹改成了memory_order_acuire。这就是栅栏的通常理念:如果一个acquire操作看到了一个发生在release栅栏之后的存储操作的结果,那么这个release栅栏操作就synchronizes-with这个acquire操作;如果一个发生在acquire栅栏之前的load操作看见了release操作的结果,那么这个release操作就synchronizes-with那个acquire栅栏。当然,你也可以将两边换为栅栏,对于这个例子,如果一个发生在acquire栅栏之前的读取操作看到了一个存储操作的结果,这个存储操作是发生在一个release栅栏之后的,那么这个release栅栏就synchronizes-with那个acquire栅栏。

尽管栅栏同步依靠在栅栏之前或者之后的读取或者存储操作,需要注意的是:同步点在栅栏本身。如果你将上面程序中的 write_x_then_y()函数中对x的存储语句拿到栅栏语句之后,那么assert()就不能保证不触发了:

void 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);//}

(我的机器是x64的处理器,试了很多次也不会触发断言)

这里面的两个操作没有被栅栏分开,因此也就不会被排序了。只有当栅栏处于对x的存储操作和对y的存储操作之间才会对其排序。当然,一个由于其他原子操作而形成的happens-before的关系不会受到是否存在栅栏的影响。

到目前为止,所有的例子都是完全构建在原子类型变量基础上的。但是,使用原子类型以便于对操作进行排序的益处是:它们可以对非原子操作提供排序以避免数据竞争带来的未定义行为,就像你在listing5.2程序中看到的那样。

5.3.6 使用原子类型对非原子操作进行排序

如果你将5.12程序中的x换成一个原初的非原子类型bool型,程序的行为依然能够得到保证:

Listing 5.13 Enforcing ordering on nonatomic operations

bool x = false;std::atomic<bool> y;std::atomic<int> z;void write_x_then_y(){x = true;std::atomic_thread_fence(std::memory_order_release);y.store(true, std::memory_order_relaxed);}void read_y_then_x(){while (!y.load(std::memory_order_relaxed));std::atomic_thread_fence(std::memory_order_acquire);if (x)++z;}void call_by_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);//不会触发中断}

对x的存储操作和对x的读取操作依然是happens-before关系,因此assert()不会被触发导致中断。对于y的存储和读取操作仍然必须是原子操作,否则在操作y时将会有数据竞争,但是一旦读取线程看到了y存储的值,栅栏就会对x的操作强加排序。这种强制排序意味着对x的操作不会有任何数据竞争,尽管一个线程读一个线程写。

并不是只有栅栏可以对非原子类型的操作进行排序,在5.10程序中,使用一个 memory_order_release / memory_order_consume对儿,对一个动态分配的非原子对象的操作进行强制排序。而且,这章中的很多例子中使用 memory_order_relaxed的操作都可以被替换成非原子操作。

通过使用原子操作对非原子的操作进行排序,在这里sequenced-before作为happens-before的一部分是很重要的。如果一个非原子操作sequenced-before一个原子操作,而那个原子操作happens-before另一个线程中的一个操作,那么这个非原子操作也happens-before另一个线程中的那个操作。这就是5.13程序中对x操作的排序的由来,以及5.2程序的原理。这也是C++标准库中高层及同步手段的根基,例如mutex和condition_variable。思考它如何工作的原理,可以回头看程序5.1中的spin-lock mutex。

lock()函数使用循环,以flag.test_and_set()作为条件,使用的是std::memory_order_acquire。而unlock()函数则使用了flag.clear(),用的是memory_order_release。当第一个线程调用了lock()函数,flog初始被清空,所以第一次调用test_and_set()将设置它的状态并且返回false,代表当前的线程已经获得了这个锁,并结束循环。然后这个线程可以自由修改被这个mutex保护的数据。任何其他的线程此时调用lock()函数,都会发现flag已经被设置进而陷入循环中,相当于被阻塞。

当获取了锁的线程修改完了被保护的数据后,它执行unlock()函数,将flag的状态清空,并且使用了std::memory_order_release。这就与之后另一个线程成功执行的lock()函数中的test_and_set()构成了synchronizes-with关系,因为test_and_set()函数使用了memory_order_acquire。因为对被保护数据的修改操作必然sequenced before于调用unlock()函数,那么修改操作happens-before于unlock(),而且unlock()又happens-before于之后第二个线程调用的lock()(unlock()操作synchronizes-with于lock()操作),进而happens-before于第二个线程之后所有获取到锁的操作。

尽管其他的mutex拥有不同的内部操作,但是基本原理是一样的:lock()是对某个内存位置的一种acquire操作,而unlock()是对同一个内存位置的release操作。






阅读全文
0 0
原创粉丝点击