“发布顺序”是什么意思?

18

我不明白,在下面的示例中,如果我们有两个线程,为什么没有释放序列就会出现问题。 我们对原子变量count只进行了2次操作。 如输出所示,count按顺序递减。

来自《C++并发编程实战》,作者:Antony Williams

我提到过,即使在一系列的 read-modify-write 操作中,一个 store 操作与一个原子变量的 load 操作之间存在着 synchronizes-with 关系,并且这些操作都被适当地标记,那么在另一个线程中,甚至存在对该原子变量的 load 操作,这条语句也是成立的。如果 store 操作被标记为 memory_order_release、memory_order_acq_rel 或 memory_order_seq_cst,而 load 操作被标记为 memory_order_consume、memory_order_acquire 或 memory_order_seq_cst,并且链中的每个操作都加载了上一个操作写入的值,则操作链构成一个 release sequence,初始的 store 操作会与最终的 load 操作形成 synchronizes-with 关系(对于 memory_order_acquire 或 memory_order_seq_cst),或者是 dependency-ordered-before(对于 memory_order_consume)。链中的任何原子 read-modify-write 操作都可以具有任何内存顺序(即使是 memory_order_relaxed)。
要了解这意味着什么(release sequence)以及它的重要性,请考虑将 atomic 用作共享队列中项目数量的计数器,如以下列表所示。
处理事情的一种方法是让生产数据的线程将项目存储在共享缓冲区中,然后执行 count.store(number_of_items, memory_order_release) #1 来告诉其他线程数据可用。消费队列项的线程可能会执行 count.fetch_sub(1, memory_order_acquire) #2 以从队列中获取一个项目,然后才实际读取共享缓冲区 #4。一旦计数变为零,就没有更多的项目了,线程必须等待 #3。
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
#include <mutex>

std::vector<int> queue_data;
std::atomic<int> count;
std::mutex m;
void process(int i)
{

    std::lock_guard<std::mutex> lock(m);
    std::cout << "id " << std::this_thread::get_id() << ": " << i << std::endl;
}


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); //#1 The initial store
}

void consume_queue_items()
{
    while (true)
    {
        int item_index;
        if ((item_index = count.fetch_sub(1, std::memory_order_acquire)) <= 0) //#2 An RMW operation
        {
            std::this_thread::sleep_for(std::chrono::milliseconds(500)); //#3
            continue;
        }
        process(queue_data[item_index - 1]); //#4 Reading queue_data is safe
    }
}

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();
}

输出 (VS2015):

id 6836: 19
id 6836: 18
id 6836: 17
id 6836: 16
id 6836: 14
id 6836: 13
id 6836: 12
id 6836: 11
id 6836: 10
id 6836: 9
id 6836: 8
id 13740: 15
id 13740: 6
id 13740: 5
id 13740: 4
id 13740: 3
id 13740: 2
id 13740: 1
id 13740: 0
id 6836: 7

如果只有一个消费者线程,那么这是没有问题的;fetch_sub() 是一个读操作,并且具有 memory_order_acquire 语义,而存储具有 memory_order_release 语义,因此该存储与加载操作同步,线程可以从缓冲区读取项目。
如果有两个线程正在读取,则第二个 fetch_sub() 将看到第一个线程写入的值而不是存储器中写入的值。如果没有关于 release sequence 的规则,那么第二个线程将无法与第一个线程建立 happens-before 关系,除非第一个 fetch_sub() 也具有 memory_order_release 语义,否则在读取共享缓冲区时不安全,这将在两个消费者线程之间引入不必要的同步。如果在 fetch_sub 操作上没有 release sequence 规则或 memory_order_release,那么将没有任何要求将队列数据存储可见于第二个消费者,从而会产生数据竞争。
我应该怎么理解他的意思?两个线程都应该看到 count 的值为 20 吗?但是在我的输出中,count 在线程中被逐个递减。

值得庆幸的是,第一个 fetch_sub() 参与了释放序列,因此 store() 与第二个 fetch_sub() 同步。两个消费者线程之间仍然没有同步关系。如图5.7所示。图5.7中的虚线显示了释放序列,实线显示了 happens-before relationshipsenter image description here


问题实际上是什么?为什么标准不直接说明一个获取读取与所有已发生的释放存储同步? - curiousguy
6个回答

14

这意味着即使最后一次读取的值并不直接等于存储在开头的值,而是由其中一个可能发生竞争的原子指令修改的值,初始存储仍与最终加载 同步。一个更简单的例子,假设有三个线程正在竞争执行这些指令(在竞赛之前将x初始化为0)。

// Thread 1:
A;
x.store(2, memory_order_release);

// Thread 2:
B;
int n = x.fetch_add(1, memory_order_relaxed);
C;

// Thread 3:
int m = x.load(memory_order_acquire);
D;
赛跑结果可能导致哪些取值对于变量n和m?根据我们在变量m和n中读到的内容,我们对指令A、B、C和D的排序有哪些保证?
对于变量n,有两种情况,要么是0,要么是2。对于变量m,我们可以读到0、1、2和3四种可能的取值。
这两个变量的六种有效组合如下:
- m = 0,n = 0。我们没有任何同步关系,因此除了明显的B发生在C之前外,我们无法推断出任何先后关系。 - m = 0,n = 2。即使fetch_add操作读取了store写入的值,由于fetch_add具有relaxed内存顺序,因此两个操作之间没有同步关系。我们无法说A发生在C之前。 - m = 1,n = 0。与前面一样,由于fetch_add没有release语义,我们无法推断出fetch_add和load操作之间的同步关系,因此我们不知道B是否发生在D之前。 - m = 2,n = 0。使用acquire语义load读取的值已经使用release语义store进行了写入。我们保证store同步-with load,因此A发生在D之前。 - m = 2,n = 2。与上面相同,store同步-with load,因此A发生在D之前。通常,从线程1中存储的与fetch_add读取的值相同的值并不意味着任何同步关系。 - m = 3,n = 2。在这种情况下,load读取的数据已经由fetch_add写入,而fetch_add读取的数据已经由store写入。但是,由于fetch_add具有relaxed语义,因此无法假定store和fetch_add以及fetch_add和load之间存在任何同步。显然,在这种情况下无法假定任何同步,与m = 0,n = 0的情况相同。这里就是release sequence概念派上用场的地方:线程1中的release语义store将synchronize-with线程3中的acquire语义load,只要正在读取的值已经在release sequence中进行了写入,其中包括:
1.在释放操作后在同一线程中执行的所有存储器。 2.从相同发布序列读取一个值的所有原子读取-修改-写入操作。
在这种情况下,由于fetch_add是原子读取-修改-写入操作,我们知道线程1中的store同步-with线程3中的load,因此A发生在D之前。但是,我们仍无法确定B和C的排序方式。
在您的情况下,假设number_of_items为2,则伪代码如下:
// Thread 1
Item[0] = ...;
Item[1] = ...;
count.store(2,memory_order_release);

// Thread 2
int i2 = 0;
while (i2 = count.fetch_sub(1,memory_order_acquire) <= 0 ) sleep();
auto x2 = Item[i2-1];
process(x2);

// Thread 3
int i3 = 0;
while (i3 = count.fetch_sub(1,memory_order_acquire) <= 0 ) sleep();
auto x3 = Item[i3-1];
process(x3);

假设第一个读入到i2中的正整数是2,因此第一个读入到i3中的正整数是1。由于从线程2读取的值已经从线程1的存储器中写入,这个存储器的同步-with负责加载,并且我们知道在线程2中auto x2 = Item[1];之前,Item[1] = ...;发生在线程1的happens-before时间。然而,从线程3读取的值1是由线程2使用没有release语义的fetch_sub写入的。因此,线程2中的fetch_sub并没有synchronizes-with线程3中的fetch_sub。然而,由于线程2中的fetch_sub是线程1中storerelease chain的一部分,线程1中的storesynchronizes-with线程3中的fetch_sub,从而我们知道Item[0] = ...;先于auto x3 = Item[0];发生。


同意!那个评论为我澄清了很多。 - mkmostafa
除了明显的B发生在C之前,你确定吗?在Sutter的《原子武器》中,他说memory_order_relaxed不会生成任何屏障,因此任何东西都可以上下流动。因此,不能保证B一定会在C之前发生。 - geranimo
从可观察的内存顺序角度来看(这是“happens-before”考虑的唯一事情),它确实如此。它是“sequenced-before”,这甚至更强。 - pqnet
@pqnet,根据sequenced-before的规则(https://en.cppreference.com/w/cpp/language/eval_order),您在哪里看到B是`sequenced-before` C? - geranimo
2
@geranimo 规则#1。它们是“完整表达式”,因为它们由分号分隔。复杂的规则适用于子表达式(如参数评估),在这种情况下,您不知道哪个先被评估,除非在某些情况下。请注意,“sequenced-before”和“happens-before”不能保留英文含义,其中“happens-before”表示您可以依赖第一个表达式中的副作用在第二个表达式中可观察,与指令重新排序无关(对于“sequenced-before”指令,仅当您未观察到这些副作用时才会发生)。 - pqnet
显示剩余5条评论

7
我和你一样,也遇到了同样的问题。我以为我理解得很好,但是他拿出这个例子并且只使用了std::memory_order_aquire,让我感到困惑。虽然很难找到任何有用的信息,但最终我还是找到了一些有帮助的资源。
我之前不知道的主要信息是一个简单的事实:无论给定什么内存顺序(甚至是std::memory_order_relaxed),读取-修改-写入操作总是在最新值上工作。这确保了在示例中您不会有两次相同的索引。但是,操作的顺序仍然可能混乱(因此您不知道哪个fetch_sub会先发生)。
这是Anthony Williams本人的回答,说明读取-修改-写入操作始终在最新值上工作:Concurrency: Atomic and volatile in C++11 memory model 此外,有人问及fetch_sub与shared_ptr引用计数的组合。在这里,Anthony Williams也做了回应,并澄清了fetch_sub的重新排序情况:https://groups.google.com/a/isocpp.org/forum/#!topic/std-discussion/OHv-oNSuJuk

6
他的意思是什么?两个线程都应该看到计数器的值为20吗?但在我的输出中,计数器在线程中被顺序递减。
不是这样的。所有对于“count”的修改都是原子的,因此在给定的代码中,两个读取线程将始终看到不同的值。
他谈论的是发布序列规则的影响,即当某个线程执行“release”存储时,随后执行相同位置的“acquire”加载的其他“多个”线程形成一个“发布序列”,其中每个后续的“acquire”加载与存储线程(即存储完成发生在复位之前)之间具有“happens-before”关系。这意味着读取线程中的加载操作是与写入线程同步的点,并且在其对应的加载完成时,写入程序中的所有内存操作必须完成并在读取程序中可见。
他说,如果没有这个规则,“只有第一个线程”才会与写入程序同步。因此,第二个线程将在访问“queue”时出现数据竞争(请注意:而不是“count”,因为无论如何,它都受到原子访问的保护)。理论上,在“count”的“store”之前发生的数据的内存操作仅在读取线程号码2自己对“count”的加载操作之后才能被看到。发布序列规则确保这不会发生。
总之:发布序列规则确保了“多个”线程可以将它们的加载同步到单个存储中。所谓的同步是对于与实际原子变量同步的数据的内存访问(由于是原子的,因此已经保证被同步)。
需要注意的是,就大部分而言,这些问题只涉及CPU架构放松有关其内存操作重新排序的规则。英特尔架构并不是其中之一:它是“强制有序”的,并且只有一些非常特定的情况下才可以重新排序内存操作。这些细微差别在讨论其他架构时才真正相关,例如ARM和PowerPC。

他的意思是,如果没有这个规则,只有第一个线程会与写入者同步。因此,第二个线程在访问队列时会出现数据竞争。您的意思是,如果没有“释放序列”,第一个消费者线程将看到“队列”具有值“{1, 2,...,18, 19}”,而第二个消费者可能会看到“队列”仅具有值“{1, 2, 3}”? - Ivan Kush
1
更一般地说,数据竞争意味着行为是未定义的。但是,是的,您的示例在假设情况下是可能的。您可以想象一个设置,在该设置中,运行在不同物理CPU和缓存上的读取器线程2将访问queue的旧版本,因为在没有释放序列要求的情况下,其包含queue的缓存行未与具有更新的queue的其他缓存同步。但正如我所说,这在Intel上不可能发生,只能在其他架构上发生。 - Smeeheey
你能描述一下其他的架构吗?编译器如何尽可能地使其不安全? - curiousguy
这不是关于编译器,而是关于CPU的动态内存访问行为。假设您有3个物理CPU,每个CPU都有一个独立的L1缓存。CPU1是写入者,CPU2和CPU3是读取者。当CPU1在其本地缓存中执行存储并由于fetch_sub的aquire-release同步,CPU1在加载之前更新了其等效缓存行。然而,如果没有释放序列规则,CPU1对CPU3的缓存没有这样的义务,因此CPU3最终会读取“queue”的旧值。请注意,我不知道任何CPU架构实际上是否以这种方式运作,这只是假设。 - Smeeheey
对于任何使用栅栏的架构,编译器必须在释放和获取共享原子对象时插入栅栏(本地原子可能会被优化掉)。然后就不需要关心是否存在释放序列。即使最后一次修改是由另一个线程(而不是RMW)完成的,在实践中,所有执行rel操作的线程的写入对于执行acq操作的所有线程来说都是可见的,这似乎是不可避免的,就好像有H-B一样,即使形式语义表明否定。 - curiousguy
任意数量的线程可以执行纯加载操作,例如 foo.load(acquire) 并进行同步,而不依赖于释放序列规则。当线程执行获取-修改-写入(acquire RMW)操作时,释放序列很重要,而不仅仅是加载操作。或者当原子对象上有某些其他的获取-修改-写入操作(由任何线程,包括第三方)时,即使 RMW 本身是松弛的,也需要释放序列。 - Peter Cordes

2
我遇到了同样的问题,无法理解“release sequence”的含义,所以我查阅了《ISO/IEC 14882:2011》的定义。
该标准首先定义了“modification order”:
所有对于特定原子对象 M 的修改都按照某个特定的全序发生,称为 M 的“modification order”。如果 A 和 B 是对于原子对象 M 的修改,且 A 在 B 之前发生(如下面所定义),则 A 必须在 M 的 modification order 中先于 B。
然后它定义了“release sequence”:
由原子对象 M 上的一个 release 操作 A 所引领的 release 序列是 M 的 modification order 中最大的连续副作用子序列,其中第一个操作是 A,并且每个随后的操作
- 由执行 A 的同一线程执行,或者 - 是一个原子读-改-写操作
此外,该标准还给出了“release operation”的定义:
memory_order_release、memory_order_acq_rel 和 memory_order_seq_cst:一个存储操作对受影响的内存位置执行了一个 release 操作。
最后但并非最不重要的,该标准指定了“原子读-改-写操作”的行为:
原子读-改-写操作始终读取与读-改-写操作相关联的写入之前的最后一个值(按照 modification order)。
希望详细的定义可以帮助那些也被“Chapter 5.3.4 Release sequences and synchronizes-with”困惑的人。

2

fetch_sub 是一种读取-修改-写入操作。它原子地从内存地址中读取值,将其减去提供的参数,然后将其写回内存地址。这一切都是原子性的。

现在,每个原子操作都直接读写内存地址。CPU 不依赖于寄存器或缓存行中缓存的值来提高性能。它直接读写内存地址并防止其他 CPU 在此期间这样做。

“普通”(==松散)原子性不能提供重新排序。编译器和 CPU 都会对读写进行混淆,以加快程序的执行速度。

请看下面的例子:

atomic integer i
regular integer j

Thread A:
i <- 5
//do something else
i -> j
//make some decisions regarding j value.

Thread B:
i++

如果没有提供内存顺序,编译器和CPU可以将代码转换为

Thread A:
i -> j
i <- 5
//do something else
//make some decisions regarding j value.

Thread B:
i++

当然这不是我们想要的,决策出现了问题。

我们需要的是内存重排序。

内存顺序获取:不要打乱内存访问之前的顺序
内存顺序释放:不要打乱内存访问之后的顺序

回到问题:

fetch_sub读取值又写入值。通过指定memory order acquire,我们表示“我只关心读取之前发生的操作的顺序”;通过指定memory order release,我们表示“我只关心写入之后发生的操作的顺序。”

但是你确实关心内存访问的顺序!

如果只有一个消费线程,那么sub_fetch对任何人都没有影响,因为生产者仍然使用普通的store,而fetch_sub的影响只对调用fetch_sub的线程可见。在这种情况下,你只关心读取——读取可以给你当前和更新后的索引。之后存储更新后的索引(比如x-1)发生的事情并不重要。

但是由于有两个线程读取写入counter,因此重要的是线程A知道线程B写入了计数器的新值,并且线程B知道线程A将要读取计数器的值。同样地,线程B必须知道线程A写入了计数器的新值,并且线程A必须知道线程B即将从计数器读取一个值。

你需要这两种保证——每个线程声明它即将读取写入共享计数器。你需要的内存顺序是std::memory_order_acquire_release

但是这个例子很棘手。生产者线程仅仅存储一个新值到counter中,而不管之前的值是什么。如果生产者线程每次推送新项时都要增加计数器,那么即使只有一个消费者,你也必须在生产者消费者线程中使用std::memory_order_acquire_release


你需要两个保证——每个线程都声明要读取和写入共享计数器。你需要的内存顺序是 std::memory_order_acquire_release。您是指书中的示例不正确吗? Williams 只使用了 memory_order_acquirememory_order_release - Ivan Kush
我相当确定在同一线程中按程序顺序排序的指令也具有“先于发生”的关系,因此j被设置的值将是56(如果i++i<-5j<-i指令之间竞争)。 - pqnet

2
释放序列保证允许获取加载(或获取RMW的加载端)与释放存储同步,即使它加载的值不直接来自释放存储。如果该值在原子变量的修改顺序中后出现,并且所有介于其间的修改都是原子RMW而不是纯存储,则仍然可以进行同步。
例如,另一个线程的foo.store(10, seq_cst)会中断由foo.store(10, release)foo.store(10, seq_cst)引导的释放序列,但foo.compare_exchange_weak(x, y, relaxed)将继续该序列。即使CAS成功并存储了新值,即使该CAS是由第三个线程、原始编写者或执行获取加载的线程完成的。对于fetch_sub也是如此。
在这种情况下,由于RMWs采用了acquire排序,它们都与原始的.store(number_of_items, release)同步,以使从queue_data[item_index - 1]读取数据变得安全。(它还必须是一个原子的fetch_sub,以便不同的线程可以为自己声明单个条目。执行这些fetch_sub操作实际上是串行化的。)
在一个假设中没有释放序列规则的情况下(其中RMWs打破了链),生产者可能需要一个第二个变量,比如一个data_ready标志,它将其设置为true,而读取器需要从中进行acquire加载,以从直接存储到非原子数组的线程中加载值。

获取/释放基础知识

如果一个获取-加载操作看到了由一个释放-存储操作存储的值,则它与该释放-存储操作“同步”,从而创建了一种先于关系。详情请参见https://preshing.com/20120913/acquire-and-release-semantics/

// writing thread
  payload = stuff;            // non-atomic, e.g. a largish struct something
  data_ready.store(1, std::memory_order_release);  // atomic release

// A reading thread
  if (data_ready.load(std::memory_order_acquire) {
     // now safe to read payload
  }
  // else do something else, maybe try again later.  Or perhaps spin wait.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接