std :: memory_order_relaxed相对于同一原子变量的原子性

Cur*_*ous 5 c++ multithreading atomicity memory-barriers c++11

关于内存命令的cppreference文档说

放宽内存排序的典型用法是递增计数器,例如std :: shared_ptr的引用计数器,因为这只需要原子性,但不需要排序或同步(请注意,递减shared_ptr计数器需要与析构函数进行获取 - 释放同步)

这是否意味着宽松的内存排序实际上不会导致相同变量的原子性?但更确切地说,结果是与其他放松的内存负载和/或compare_exchanges相关的最终一致性?使用std::memory_order_seq_cst是与配对时看到一致结果的唯一方法std::memory_order_relaxed吗?

我假设std::memory_order_relaxed对于相同的变量仍然是原子的,但是没有提供关于其他数据的加载和存储的任何其他约束.

LWi*_*sey 6

您提出了一些问题,但我将重点关注典型shared_ptr实现所使用的排序约束,因为我认为这涵盖了问题的关键部分.

原子操作相对于它适用的变量(或POD)总是原子的; 对单个变量的修改将以一致的顺序对所有线程可见.
放松原子操作的方式在您的问题中描述:

std::memory_order_relaxed 对于相同的变量仍然是原子的,但是没有提供关于其他数据的加载和存储的任何其他约束

以下是2个典型场景,其中可以省略对原子操作的排序约束(即通过使用std::memory_order_relaxed):

  1. 内存排序不是必需的,因为它没有依赖于其他操作,或者如评论者所说,(..)不是涉及其他内存位置的不变量的一部分.

    一个常见的例子是原子计数器,由多个线程递增以跟踪特定事件发生的次数.fetch_add如果计数器表示不依赖于其他操作的值,则可以放宽递增操作().
    我发现cppreference给出的例子不是很有说服力,因为shared_ptr引用计数确实有依赖性; 即,一旦其值变为零,就删除存储器.更好的示例是Web服务器仅为报告目的跟踪传入请求的数量.

  2. 内存排序是必要的,但不需要使用排序约束,因为已经过了所需的同步(IMO这更好地解释了为什么shared_ptr可以放宽引用计数增量,请参见下面的示例).
    shared_ptr复制/移动的构造只能被称为同时它具有的(参考)复制/移动-从实例(或它将是不确定的行为)的同步视图,因此,不需要额外的排序是必需的.

以下示例说明了shared_ptr实现通常如何使用内存排序来修改其引用计数.假设所有线程在释放 并行运行 sp_main(shared_ptr引用计数则为10).

int main()
{
    std::vector<std::thread> v;
    auto sp_main = std::make_shared<int>(0);

    for (int i = 1; i <= 10; ++i)
    {
        // sp_main is passed by value
        v.push_back(thread{thread_func, sp_main, i});
    }

    sp_main.reset();

    for (auto &t : v)  t.join();
}

void thread_func(std::shared_ptr<int> sp, int n)
{
    // 10 threads are created

    if (n == 7)
    {
        // Only thread #7 modifies the integer
        *sp = 42;
    }

    // The only thead with a synchronized view of the managed integer is #7
    // All other threads cannot read/write access the integer without causing a race

    // 'sp' going out of scope -> destructor called
}
Run Code Online (Sandbox Code Playgroud)

线程创建保证了(make_sharedin main)和sp'copy/move-constructor(每个线程内)之间的(线程间)发生关系.因此,shared_ptr构造函数具有内存的同步视图,可以安全地增加ref_count而无需额外的排序:

ctrlblk->ref_count.fetch_add(1, std::memory_order_relaxed);
Run Code Online (Sandbox Code Playgroud)

对于销毁部分,由于只有线程#7写入共享整数,因此不允许其他9个线程访问相同的内存位置而不会导致争用.这会产生一个问题,因为所有线程几乎在同一时间被破坏(假设reset在之前main已被调用)并且只有一个线程将删除共享整数(ref_count从1 递减到0).
在删除整数之前,最后一个线程必须具有同步的内存视图,但由于10个线程中的9个没有同步视图,因此需要额外的排序.

析构函数可能包含以下内容:

if (ctrlblk->ref_count.fetch_sub(1, std::memory_order_acq_rel) == 1)
{
    // delete managed memory
}
Run Code Online (Sandbox Code Playgroud)

原子ref_count具有单个修改顺序,因此所有原子修改都以某种顺序发生.假设执行最后3次递减的线程(在本例中)ref_count是线程#7(3→2),#5(2→1)和#3(1→0).这两个减少都是由线程执行的,#7并且#5在修改顺序中比在执行的修改顺序中更早#3.
发布顺序变为:

#7(商店发布)→ #5(读 - 修改 - 写,无需订购)→ #3(负载获取)

最终结果是线程执行的释放操作与执行#7的获取操作同步,#3并且整数修改(by #7)保证在整数破坏(by #3)之前发生.

从技术上讲,只有访问过托管内存位置的线程必须执行释放操作,但由于库实现者不知道线程操作,因此所有线程在销毁时都会执行释放操作.

对于共享内存的最终销毁,从技术上讲,只有最后一个线程需要执行获取操作,因此shared_ptr库实现者可以通过设置仅由最后一个线程调用的独立fence来进行优化.

if (ctrlblk->ref_count.fetch_sub(1, std::memory_order_release) == 1)
{
    std::atomic_thread_fence(std::memory_order_acquire);

    // delete managed memory
}
Run Code Online (Sandbox Code Playgroud)

  • @MaximEgorushkin 共享整数(示例中为 42)的存储必须由修改线程(#7)释放并由最后一个线程(#3)获取。如果没有该顺序,删除共享内存位置将构成数据竞争。 (3认同)
  • 我不明白为什么 `std::memory_order_relaxed` 不能用于递减引用计数器,因为没有其他加载/存储不能根据引用计数器的原子更改进行重新排序。 (2认同)