有时可以使用 std::atomic 而不是 C++ 中的 std::mutex 吗?

Can std::atomic be used sometimes instead of std::mutex in C++?

我想 std::atomic 有时可以代替 std::mutex 的用法。但是使用 atomic 而不是 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 那么系统不可用,我不能使用它,如果它是真的那么它是可用的,我可以使用它。为简单起见,假设上面的代码不会抛出异常。

因为我可以使用 std::mutex 来保护我的系统 read/modify。但是现在我需要 Thread-1 中的高性能代码,它应该经常使用原子而不是互斥锁(Thread-2 可能很慢,如果需要的话可以使用互斥锁)。

在线程 1 中,系统使用代码(在 while 循环内)经常 运行,每次迭代大约 50-200 nano-seconds。所以使用额外的互斥量会很重。但是 Thread-2 迭代非常大,正如您在 while 循环的每次迭代中看到的那样,当系统准备好时它会休眠 10-30 milli-seconds,因此仅在 Thread-2 中使用互斥锁是非常好的。

线程 1 是一个线程的示例,在我的实际项目中有多个线程 运行与线程 1 的代码相同(或非常相似)。

我担心内存操作顺序,这意味着当 ready 在 Thread 中变为 true 时,有时系统可能尚未处于完全一致的状态(尚未完全初始化) -1。当系统已经进行了一些破坏(deinit)操作时,也可能发生 ready 在 Thread-1 中变为 false 太晚了。正如您所看到的,系统可以在 Thread-2 的循环中多次 inited/destroyed 并且在 Thread-1 中多次使用 ready.

如果没有 std::mutex 和 Thread-1 中的其他繁重内容,我的任务能否以某种方式解决?仅使用 std::atomic(或 std::atomic_flag)。如果需要,Thread-2 可以使用大量同步内容,互斥锁等。

基本上,在 ready 变为 true 之前,Thread-2 应该以某种方式将系统的整个初始化状态传播到所有内核和其他线程,并且 Thread-2 应该传播 ready 等于 false 在系统销毁(deinit)的任何单个小操作完成之前。通过传播状态,我的意思是所有系统的初始化数据应该 100% 一致地写入全局内存和其他核心的缓存,以便其他线程在 readytrue.[=41= 时看到完全一致的系统]

如果改善情况和保证,甚至允许在系统初始化之后和就绪设置为真之前进行小的(毫秒)暂停。并且还允许在 ready 设置为 false 之后和开始系统销毁(deinit)之前进行暂停。如果存在一些操作,例如“将所有 Thread-2 写入全局内存和缓存传播到所有其他 CPU 内核和线程”,那么在 Thread-2 中执行一些昂贵的 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 2 核上,在单线程(发布编译)中平均(测量 2^25 次操作)上述 运行s 9 nanoseconds 的解决方案笔记本电脑。在同一台 Windows PC 上使用 std::unique_lock<std::mutex> lock(mux); 用于相同的保护目的需要 100-120 nanoseconds。如果线程需要自旋锁而不是在等待时休眠,那么在上面的代码中我只使用分号 ; 而不是 std::this_thread::yield();。完整 online example 使用情况和时间测量值。

为了答案我会忽略你的代码,答案通常是肯定的。

锁做以下事情:

  1. 在任何给定时间只允许一个线程获取它
  2. 当获得锁时,放置一个读屏障
  3. 就在释放锁之前,放置了一个写屏障

结合以上三点,临界区线程安全。只有一个线程可以访问共享内存,由于读屏障,所有更改都被锁定线程观察到,并且由于写屏障,所有更改都对其他锁定线程可见。

能不能用原子来实现?是的,现实生活中的锁(例如,由 Win32/Posix 提供)是通过使用原子和无锁编程实现的,或者通过使用使用原子和无锁编程的锁实现的。

现在,现实地说,你应该使用自写锁而不是标准锁吗?绝对不是。

许多并发教程保留自旋锁比常规锁“更有效”的概念。我怎么强调都不为过这是多么愚蠢。用户模式自旋锁永远不会比 OS 提供的锁更有效。原因很简单,OS 锁连接到 OS 调度程序。因此,如果一个锁试图锁定一个锁但失败了 - OS 知道冻结该线程并且不会将其重新安排到 运行 直到锁被释放。

使用用户模式自旋锁,这不会发生。 OS 无法知道相关线程试图在紧密循环中获取锁。 Yielding 只是一个补丁,而不是解决方案——我们想旋转一小段时间,然后进入休眠状态,直到锁被释放。使用用户模式自旋锁,我们可能会浪费整个线程时间片来尝试锁定自旋锁并屈服。

老实说,最近的 C++ 标准确实让我们能够在原子上休眠,等待它改变它的值。所以我们可以用一种非常蹩脚的方式实现我们自己的“真正的”锁,尝试自旋一段时间然后休眠直到锁被释放。但是,如果您不是并发专家,则几乎不可能实现正确且高效的锁。

我自己的哲学观点,在2021年,开发者应该很少处理那些非常底层的并发话题。将这些事情留给内核人员。 使用一些高级并发库并专注于您想要开发的产品,而不是微优化您的代码。这就是并发性,正确性 >>> 效率。

A related rant by Linus Torvalds