std::condition_variable 的布尔谓词是否应该在 C++ 中是可变的?

Should a boolean predicate for std::condition_variable be volatile in C++?

提问人:Ronen 提问时间:1/27/2022 更新时间:1/30/2022 访问量:555

问:

我听到了这么多相互矛盾的答案,现在我不知道该怎么想。 公认的知识是,为了在 C++ 中以线程安全的方式共享内存,需要将 volatile 与 std::mutex 一起使用。

基于这种理解,我一直在编写这样的代码:

volatile bool ready = false;
std::condition_variable cv;
std::mutex mtx;
std::unique_lock<std::mutex> lckr{ mtx };
cv.wait(lckr, [&ready]() -> bool { return ready; });

但后来我在 CppCon 上看到了 Chandler Carruth 的演讲,他说(作为旁注)在这种情况下不需要 volatile,我基本上不应该使用 volatile。

然后我在 Stack Overflow 中看到了其他答案,说永远不应该使用挥发性,它不够好,它根本不能保证原子性。

钱德勒·卡鲁斯(Chandler Carruth)是对的吗?我们俩都错了吗?

现在我有 3 个选择:

  1. 必须使用 volatile 或 std::atomic
  2. 任何布尔值都可以
  3. 必须是 std::atomic

我想知道 C++14 ISO 标准是否允许我编写这样的代码:

#include <condition_variable>
#include <mutex>
#include <iostream>
#include <future>
#include <functional>

struct sync_t
{
    std::condition_variable cv;
    std::mutex mtx;
    bool ready{ false };
};
static void threaded_func(sync_t& sync)
{
    std::lock_guard<std::mutex> lckr{ sync.mtx };
    sync.ready = true;
    std::cout << "Waking up main thread" << std::endl;
    sync.cv.notify_one();
}
int main()
{
    sync_t sync;
    {
        std::unique_lock<std::mutex> lckr{ sync.mtx };
        sync.ready = false;
        std::future<void> thread =
            std::async(std::launch::async, threaded_func, std::ref(sync));
        std::cout << "Preparing to sleep" << std::endl;
        sync.cv.wait(lckr, [&sync]() -> bool { return sync.ready; });
        thread.get();
    }
    std::cout << "Done program execution" << std::endl;
    return 0;
}

当我成功时会发生什么:

volatile bool ready{ false };

当我成功时会发生什么:

std::atomic<bool> ready{ false };
C++ 多线程 11 未定义行为 C++-标准库

评论

1赞 rturrado 1/27/2022
Scott Meyer 的《有效的现代 C++》中有两个与此相关的项目可能会引起您的兴趣(39。考虑一次性事件通信的无效期货,以及 40.使用 std::atomic 表示并发,使用 volatile 表示特殊内存。根据我的理解,应该只用于特殊记忆;对于条件变量的并发性,正常就足够了;但是,还有比条件变量加布尔组合更好的方法:例如,计数为 1 的计数信号量或未来值。volatilebool
0赞 n. m. could be an AI 1/27/2022
“商定的知识”?哪里?你有什么参考资料吗?
0赞 François Andrieux 1/27/2022
请注意,只有当条件在互斥锁上具有锁时,才会计算 的谓词。“公认的知识是,为了在C++中以线程安全的方式共享内存,需要将volatile与std::mutex一起使用。我不知道你从哪里得到这种印象,但它并不准确。 与并发完全无关,不能用于解决任何并发问题。condition_variablevolatile
0赞 Ronen 1/27/2022
@FrançoisAndrieux 编译器怎么知道不“优化”我的普通布尔值?仅仅是因为有一个 std::mutex 被锁定并且它知道布尔值可能被另一个线程修改了吗?或者我应该不信任编译器并明确声明布尔值是可变的?
2赞 François Andrieux 1/27/2022
@Ronen与并发性完全无关。基本上没有与多线程相关的问题可以通过添加 来解决。锁定互斥锁充当栅栏,编译器必须假设任何可能从外部引用的内容都可能已被更改。volatilevolatile

答:

3赞 Eduard Rostomyan 1/27/2022 #1

不,volatile 是令人困惑的关键字,但它与并发性无关,不像 C# 或 Java 那样保证顺序一致性。这里只是提示编译器不要优化变量。

评论

3赞 Adrian Mole 1/27/2022
这不仅仅是一个“提示”——对于单个线程来说,它保证了访问的副作用不会被优化掉。
5赞 Adrian Mole 1/27/2022 #2

限定符对从不同线程访问对象没有必需的影响 - 它仅保证编译器不会优化单个线程中修改的副作用。来自 cppreference (粗体强调我的):volatile

  • volatile object - 类型为 volatile-qualified 的对象,或者是 volatile 对象的子对象,或者是 常量-易失性对象。每次访问(读或写操作,成员 函数调用等)通过 GLvalue 表达式 挥发性限定类型被视为 优化目的(即,在 执行时,无法优化或重新排序易失性访问 具有另一个可见的副作用,该副作用在之前排序 - 或 在易失性访问之后排序。这使得易失性对象 适合与信号处理程序通信,但不适用于 另一个执行线程,参见 std::memory_order)。任何尝试 通过非易失性类型的 glvalue 引用易失性对象 (例如,通过引用或指向非易失性类型的指针)导致 未定义的行为。

为了防止在从多个线程访问对象时出现未定义的行为,应使用对象。同样,来自 cppreferencestd::atomic

std::atomic 模板的每个实例化和完全专用化 定义原子类型。如果一个线程写入原子对象,而 另一个线程从中读取,行为定义明确(参见内存 模型,了解有关数据争用的详细信息)。

此外,对原子对象的访问可以建立线程间 同步和排序非原子内存访问,如 std::memory_order。

评论

1赞 pptaszni 1/27/2022
在这种情况下,即使旗帜被保护,仍然需要吗?std::atomicbool readystd::mutex
2赞 Adrian Mole 1/27/2022
@pptaszni 可能不需要,只要所有访问都以 .但OP也提出了一个更普遍的问题,即什么是。lock_guardvolatile
0赞 Ronen 1/27/2022
在我的问题中描述的情况中,我会使用哪种 std::memory_order?在这种情况下,我什至需要使用 std::atomic<bool>(因为它由 std::mutex 保护)?
0赞 Adrian Mole 1/27/2022
@Ronen 请参阅我之前的评论。
0赞 Moshe Levy 1/27/2022 #3

volatile 只需告诉编译器,即使您不知道是谁,有人可能会更改此值,例如,它可能是某些硬件、信号甚至其他线程。 一个著名的例子是:

bool flag
 foo()
 {
   flag = true;
   while(flag)
   {
   }
 }

优化的编译器将看到 flag 为 true,并且由于它只是一个普通的全局变量,它可以假设除了当前线程之外没有人可以更改它,因此编译器可以假设 flag 始终为 true,因此将 切换到 to 以形成无限循环。 但是,如果将标志变量声明为 volatile,则编译器不能假定只有当前线程触及此值,因此代码将保持不变。while(flag)while(1)

现在对于您的问题,volatile 将帮助我们通知编译器其他人可能会使用此值,但这对于多线程来说是不够的,因为它不会阻止数据竞争,这是 C 语言中未定义的行为,因此我们需要将 bool 标志声明为 std::atomic。

请注意,编译器从 std::atomic 声明中理解的一件事是另一个线程可能会使用此值,因此我们无法进行上述优化。

对于您的示例,正如我们所解释的,volatile 是不够的,但您也不需要 std::atomic,因为您有一个锁,所以如果您的锁工作正常,那么当您在关键部分内时,没有其他线程可能会触及该值,因此 std::atomic 是多余的。

std::atomic主要用于关键部分,当所有关键部分都是因为原子操作时,所以我们可以使用std::atomic来代替一个较慢的锁(情况并非总是如此,这取决于流程)。

评论

0赞 Ronen 1/28/2022
你的答案是我以前的想法。这个线程中的几乎所有其他人都回答说,如果我正确使用 std::mutex,那么我可以使用常规布尔值,而编译器不允许对其进行优化。这就是为什么我在这里问这个问题,我对 C++ 中的并发性了解很多,但我不知道何时允许编译器优化我的纯布尔值的规则。
0赞 Moshe Levy 1/30/2022
关于并发性,如果你不使用诸如 Atomic 或 Volatile 之类的声明,编译器会假设没有人接触这个变量,所以他可以对变量进行任何他想要的优化(将其保存在寄存器中,删除变量的重新读取,再次重新读取变量等等)这就是为什么即使您知道运行代码的平台,将变量声明为 std atomic 也很重要(x86例如,你可以说简单的 MOV 是原子的),如果你不在乎内存顺序,你可能会说没关系,bool 是原子的,但它是 C 中的数据竞争
0赞 Moshe Levy 1/30/2022
因此,编译器可以在互斥锁中优化您的代码!你甚至可以尝试我在这里用互斥锁展示的 while 循环,看看如果你用 O2 编译它,编译器会做一个无限循环,但这并不重要,因为有一个锁,理论上没有人会改变标志值,所以它是无限循环!
0赞 Ronen 1/30/2022
Moshe,你应该看看我的回答 stackoverflow.com/a/70909085/7753444 Chandler Carruth 确实说过,他花了一段时间才说服他在 Google 的同事,谓词不应该是不稳定的。他们说:“但我们多年来一直这样做!
0赞 Moshe Levy 1/30/2022
我没有说应该,但是编译器不优化互斥关键部分内的变量是不对的,你可以检查一下。
1赞 Ronen 1/30/2022 #4

自从我问了这个问题以来,我现在知道了更多。 答案是 Chandler Carruth 是正确的,一个常规的布尔值(带有 std::mutex)就足够了。不需要原子,也不需要挥发性。 “volatile”只应在处理信号处理程序时使用,如下所示:

volatile std::sig_atomic_t

与流行的看法相反,当您使用 std::mutex 时,C++ 编译不允许只是“优化”从布尔值中读取。这是因为锁定互斥锁就像一个“栅栏”,在锁定互斥锁后,编译器必须假设变量可能已经更改。编译器仍然可以优化它可以证明没有更改的局部变量,但是在我使用布尔值作为谓词的示例中,我通过引用另一个函数来发送布尔值:

std::future<void> thread =
        std::async(std::launch::async, threaded_func, std::ref(sync));

布尔值存在于“sync”内部,因此现在不允许编译器假定该值保持不变。编译仍然可以将布尔值存储在寄存器中,但是当我锁定 std::mutex 时,它将被迫重新加载该值,因为它可能已经更改。 当然(根据标准)函数 std::condition_variable::wait 在返回时保持 std::mutex 锁定,因此当 std::mutex 被锁定时,它将始终检查布尔谓词,认为整个事情是安全的。

总而言之:多线程永远不需要 volatile。std::mutex 就足够了。

我的问题的答案是,我用普通布尔值编写的代码是安全的,所有三个选项也是安全的。使用 volatile 是安全的,使用 std::atomic 是安全的。但是在这种情况下,使用常规布尔值是最正确和最有效的。 事实上,如果你有一个锁(std::mutex),你就永远不需要std::atomic。 需要注意的是,如果我每次从谓词读取时都没有努力使用 std::mutex,那么 std::atomic 将是必需的。

我从这里对我的问题给出的各种答案中获取了这些知识,并且我还在 Compiler Explorer 中使用 Clang13 对其进行了测试。看到C++14标准的证明会很有趣。