在C++中,有时候可以使用std::atomic代替std::mutex吗?

6

我认为std::atomic有时可以替代std::mutex的使用。但是,使用原子操作是否总是安全的呢?以下是示例代码:

std::atomic_flag f, ready; // shared

// ..... Thread 1 (and others) ....
while (true) {
    // ... Do some stuff in the beginning ...
    while (f.test_and_set()); // spin, acquire system lock
    if (ready.test()) {
        UseSystem(); // .... use our system for 50-200 nanoseconds ....
    }
    f.clear(); // release lock
    // ... Do some stuff at the end ...
}

// ...... Thread 2 .....
while (true) {
    // ... Do some stuff in the beginning ...
    InitSystem();
    ready.test_and_set(); // signify system ready
    // .... sleep for 10-30 milli-seconds ....
    while (f.test_and_set()); // acquire system lock
    ready.clear(); // signify system shutdown
    f.clear(); // release lock
    DeInitSystem(); // finalize/destroy system
    // ... Do some stuff at the end ...
}

这里我使用std::atomic_flag来保护我的系统(一些复杂的库)的使用。但这段代码是否安全?这里假设如果readyfalse,那么系统不可用,我无法使用它;如果为true,则可用,我可以使用它。为简单起见,假设上面的代码不会抛出异常。
当然,我可以使用std::mutex来保护系统的读取/修改操作。但现在我需要在Thread-1中使用高性能代码,需要经常使用原子操作而不是互斥量(如果需要,Thread-2可以慢慢使用互斥量)。
在Thread-1中,系统使用代码(while循环内部)非常频繁地运行,每次迭代大约需要花费50-200纳秒。因此,使用额外的互斥量会很重。但是Thread-2的迭代非常大,如您在while循环的每次迭代中所看到的,当系统就绪时,它会休眠10-30毫秒,因此仅在Thread-2中使用互斥量是可以接受的。
Thread-1是一个线程的示例,在我的实际项目中有几个线程运行相同(或非常相似)的代码。
我担心的是内存操作顺序,这意味着当ready在Thread-1中变为true时,系统可能仍未完全处于一致状态(尚未完全初始化)。此外,当Thread-1中ready变为false太晚时,系统已经执行了某些销毁(去初始化)操作。此外,正如您所看到的,在Thread-2的循环中,系统可以被初始化/销毁多次,并在它是ready时被多次使用。
我的任务是否可以在没有std::mutex和Thread-1中的其他繁重内容的情况下解决?只使用std::atomic(或std::atomic_flag)。如果需要,Thread-2可以使用重量级同步工具,如互斥量等。
基本上,Thread-2应该在ready变为true之前以某种方式将整个系统初始化状态传播到所有核心和其他线程,并且还应在进行任何单个小操作的系统销毁(去初始化)之前将ready等于false的状态传播出去。通过传播状态,我指的是所有系统的已初始化数据都应该100%一致地写入全局内存和其他核心的缓存中,以便其他线程在readytrue时看到完全一致的系统。
如果有助于改善情况并保证,甚至可以在系统初始化后和ready设置为true之前暂停一小段时间(毫秒级)。在ready设置为false之后开始系统销毁(去初始化)之前也可以暂停。如果存在"将所有Thread-2写操作传播到全局内存和所有其他CPU核心和线程"等操作,则进行一些昂贵的CPU操作也是可以接受的。

更新: 针对我上面的问题,在我的项目中,我决定使用以下代码来解决,使用std::atomic_flag替换std::mutex:

std::atomic_flag f = ATOMIC_FLAG_INIT; // shared
// .... Later in all threads ....
while (f.test_and_set(std::memory_order_acquire)) // try acquiring
    std::this_thread::yield();
shared_value += 5; // Any code, it is lock-protected.
f.clear(std::memory_order_release); // release

以上解决方案在我的Windows 10 64位2Ghz双核笔记本电脑上,使用单线程(发布编译)平均运行9纳秒(测量2^25次操作)。而在同一台Windows PC上,为了实现相同的保护目的,使用std::unique_lock<std::mutex> lock(mux);需要100-120纳秒的时间。如果需要线程在等待时自旋而不是休眠,则可以在上述代码中使用分号;代替std::this_thread::yield();。完整的在线示例,包括时间测量。


我不知道为什么您认为互斥量很重。它们并不重。只有两个问题:(1)根据使用情况,互斥锁可能会导致长时间等待。(2)内存屏障指令可能会有问题。相比于自旋锁,最好使用互斥锁。 - ALX23z
@ALX23z 我在我的Win 10 64位系统上测试了std::mutex,似乎只有std::unique_lock<std::mutex> lock(mux);这一行代码的执行时间大约为100-120纳秒。而在Linux上,它要快得多,大约只需要20-30纳秒。而atomic<size_t>/atomic_flag在Windows和Linux上都只需要17纳秒。这些测试都是在CLang release -O3下进行的。对我来说,17纳秒比100-120纳秒更可取。 - Arty
适当测试这种东西的性能非常复杂,因此我严重怀疑您能够正确地完成它。此外,您需要考虑代码和内部同步等各种复杂问题。例如,您如何使用原子操作?您需要在原子操作中放置适当的内存栅栏才能开始公平地进行测试。然后您需要思考,在程序运行期间,什么需要更多时间 - 单个元素指令还是内存栅栏?后者通常是主要问题。 - ALX23z
@ALX23z 这基本上是我提出问题的目的,即如何仅使用原子操作正确解决我的任务。如果可以仅使用原子值解决或者是否需要额外的内存屏障才能解决。一般来说,问题是在什么情况下原子操作可以替代互斥锁。可能在最一般的形式下,原子操作的解决方案不会比互斥锁更快,但对于某些特定的特殊任务,原子操作可能会显着更快。因此,我想弄清楚在什么情况下原子操作可以用作互斥锁的更快替代品。 - Arty
一般来说,不行。互斥锁和原子变量有两个不同的作用。互斥锁保护代码,而原子变量保护数据。这里提出的问题并不是标题所暗示的那样;问题是是否可以使用原子变量实现互斥锁,表面上的答案是肯定的,当然可以。 - Pete Becker
@PeteBecker,有一个atomic_flag,其唯一目的是实现自旋锁 - 这与互斥锁相同,只是不建议使用它,因为大多数现代互斥锁实现会自旋一小段时间。 - ALX23z
1个回答

8
为了回答问题,我会忽略你的代码,答案通常是肯定的。
锁执行以下操作:
1. 任何时候只允许一个线程获取它。 2. 当锁被获取时,会放置读障碍。 3. 在释放锁之前,会放置写障碍。
上述三点的组合使得关键部分是线程安全的。只有一个线程可以访问共享内存,由于读障碍,所有更改都由锁定线程观察到,由于写障碍,所有更改对其他锁定线程可见。
你能使用原子操作来实现吗?可以,实际生活中的锁(例如Win32/Posix提供的锁)要么使用原子操作和无锁编程实现,要么使用使用原子操作和无锁编程的锁。
现实情况下,你应该使用自己编写的锁替代标准锁吗?绝对不应该。
许多并发教程都保留了旋转锁比常规锁“更有效”的观念。我无法强调这是多么愚蠢。用户模式自旋锁永远不会比操作系统提供的锁更有效。原因很简单,操作系统锁连接到操作系统调度程序。所以如果一个锁尝试锁定一个锁并失败了 - 操作系统知道要冻结此线程,并且不重新安排它运行,直到锁被释放为止。
对于用户模式自旋锁,这种情况不会发生。操作系统无法知道相关线程在紧密循环中尝试获取锁。让出CPU时间只是一个补丁而不是解决方案 - 我们希望旋转一段时间,然后进入睡眠状态,直到锁被释放。使用用户模式自旋锁,我们可能会浪费整个线程量子尝试锁定自旋锁并让出CPU时间。
为了诚实起见,我要说,最近的C++标准确实使我们能够在等待原子值改变时休眠。因此,我们可以以非常糟糕的方式实现自己的“真正”锁,尝试旋转一段时间,然后睡眠,直到锁被释放。但是,如果您不是并发专家,则很难实现正确而有效的锁。

我个人的哲学观点是,在2021年,开发者应该很少涉及那些非常低级别的并发主题。把这些事情留给内核专家。 使用一些高级的并发库,专注于你想要开发的产品,而不是微调你的代码。这就是并发,正确性>>>效率。

Linus Torvalds的相关抱怨


请注意 - 在我的情况下,我有非常小的操作和很多这样的操作。每个操作都是几十纳秒。因此,在原子自旋锁上应该比使用OS调度程序睡眠线程更快,因为在去切换到OS调度程序、等待30纳秒并再次唤醒线程时不会有任何收益。如果您能提供一些正确使用原子操作来模拟您的3个步骤的代码,那将是很好的。因为我有多核心,所以每个线程自旋锁定几十纳秒就可以了。 - Arty
你能否重新设计你的代码,使得每个线程都在自己的非原子数据上工作,然后所有线程合并它们的结果?通常,这会给出最正确和高效的代码。在线程之间共享数据的最佳方式是一开始就不要共享它。 - David Haim
不幸的是,这不能以这种方式重新设计,使用System的整个代码非常混乱。只是举个例子,我放了一个简单的while循环。在我的项目的实际代码中,System被以混乱的方式使用,在每个线程中随机时间点执行大量操作,即使所有线程都运行不同的代码。每个操作非常短,几十纳秒。但是因为它们都使用相同的共享System,所以所有工作线程对该系统的访问应该由唯一锁定,并为每个小操作单独锁定。 - Arty
如果可以的话,能否给我展示一个在标准C++20中实现以下操作的最小代码示例:1)通过自旋锁定单个原子变量来获取唯一的全局锁,2)发出读取屏障,3)读取/写入系统(任何非原子代码),4)发出写入屏障,5)释放原子锁。我不知道如何在标准C++20中完成这5个步骤。例如,我看到了这篇文档,但无法完全正确地将其用于我的情况。 - Arty
例如,上面评论中提到的这五个步骤,这个代码 是否是正确的代码?我是否使用了正确的内存顺序?如果锁定块内没有任何内部代码操作可能逃脱出这些屏障,那么这种锁定方式对于任何复杂代码而言是完全正确的吗? - Arty
显示剩余2条评论

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