std::memory_order_relaxed cppreference.com 中的示例

std::memory_order_relaxed example in cppreference.com

cppreference.com给出了下面使用std::memory_order_relaxed的例子。 (https://en.cppreference.com/w/cpp/atomic/memory_order)

#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
 
std::atomic<int> cnt = {0};
 
void f()
{
    for (int n = 0; n < 1000; ++n) {
        cnt.fetch_add(1, std::memory_order_relaxed);
    }
}
 
int main()
{
    std::vector<std::thread> v;
    for (int n = 0; n < 10; ++n) {
        v.emplace_back(f);
    }
    for (auto& t : v) {
        t.join();
    }
    std::cout << "Final counter value is " << cnt << '\n';
}

输出: 最终计数器值为 10000

这是一个 correct/sound 示例吗(标准的投诉编译器可以引入会产生不同答案的优化吗?)。由于 std::memory_order_relaxed 只保证操作是原子的,一个线程可能看不到另一个线程的更新。我错过了什么吗?

可以在描述的第一句中找到有关此作品为何有效的提示 on the page you linked(强调我的):

std::memory_order specifies how memory accesses, including regular, non-atomic memory accesses, are to be ordered around an atomic operation.

注意这不是在讨论原子本身的内存访问,而是在讨论原子周围的内存访问。对单个原子的并发访问总是有严格的顺序要求,否则无法首先推断出它们的行为。

在计数器的情况下,您可以保证 fetch_add 的行为与预期的非常相似:计数器一次增加一个,不会跳过任何值,也不会对任何值进行两次计数。您可以通过检查各个 fetch_add 调用的 return 值轻松验证这一点。无论内存顺序如何,您始终可以获得这些保证。

一旦您在周围程序逻辑的上下文中为这些计数器值赋予意义,事情就会变得有趣起来。例如,您可以使用某个计数器值来指示特定数据片段已通过较早的计算步骤可用。这将需要内存排序,如果计数器和数据之间的关系需要跨线程持续存在:使用宽松的排序,在您观察到您正在等待的计数器值时,您无法保证您正在等待的数据for 也准备好了。即使在生产线程写入数据后设置计数器,这种内存操作顺序也不会跨线程边界转换。您将需要指定一个内存顺序,该顺序根据线程间计数器的更改对数据的写入进行排序。 这里要理解的关键是,虽然保证操作在一个线程内以特定顺序发生,但是当从不同线程观察相同数据时,不再保证该顺序。

所以经验法则是:如果您只是孤立地操作原子,则不需要任何排序。一旦在其他不相关的内存访问的上下文中解释该操作(即使这些访问本身是原子的!),您需要担心使用正确的顺序。

通常的建议是,除非你真的、真的、真的 有充分的理由这样做,否则你应该坚持使用默认值 memory_order_seq_cst。作为应用程序开发人员,您不想弄乱内存排序,除非您有 strong 经验证据证明值得您毫无疑问 运行 进入。

是的,这是一个正确的例子 - 所以不,编译器不能引入会产生不同结果的优化。你是对的,一般来说,一个线程不能保证看到另一个线程的更新(或者更具体地说,不能保证 什么时候 这样的更新变得可见)。但是,在这种情况下 cnt 是使用原子读-修改-写操作更新的,[atomics.order] 中的标准状态:

Atomic read-modify-write operations shall always read the last value (in the modification order) written before the write associated with the read-modify-write operation.

如果您考虑一下,这绝对是有道理的,因为否则不可能使读-修改-写操作成为原子操作。假设 fetch_add 不会看到最新的更新,而是一些旧值。这意味着该操作将增加旧值并存储它。但这意味着 1) fetch_add 返回的值没有严格增加(一些线程会看到相同的值)和 2) 一些更新被遗漏了。