1

下面的代码用于将工作分配给多个线程,唤醒它们,并等待它们完成。在这种情况下,“工作”包括“清理卷”。这个操作到底做了什么与这个问题无关——它只是有助于上下文。该代码是一个庞大的事务处理系统的一部分。

void bf_tree_cleaner::force_all()
{
    for (int i = 0; i < vol_m::MAX_VOLS; i++) {
        _requested_volumes[i] = true;
    }
    // fence here (seq_cst)

    wakeup_cleaners();

    while (true) {
        usleep(10000); // 10 ms

        bool remains = false;
        for (int vol = 0; vol < vol_m::MAX_VOLS; ++vol) {
            // fence here (seq_cst)
            if (_requested_volumes[vol]) {
                remains = true;
                break;
            }
        }
        if (!remains) {
            break;
        }
    }
}

布尔数组中的值_requested_volumes[i]告诉线程是否i有工作要做。完成后,工作线程将其设置为 false 并重新进入睡眠状态。

我遇到的问题是编译器生成一个无限循环,其中变量remains始终为真,即使数组中的所有值都已设置为假。这只发生在-O3.

我尝试了两种解决方案来解决这个问题:

  1. 声明_requested_volumes易失性(编辑:此解决方案确实有效。请参阅下面的编辑)

很多专家表示,volatile与线程同步无关,应该只用在低级硬件访问中。但网上对此有很多争议。我理解它的方式是, volatile 是避免编译器优化对在当前范围之外更改的内存的访问的唯一方法,无论并发访问如何。从这个意义上说,volatile应该可以解决问题,即使我们对并发编程的最佳实践存在分歧。

  1. 引入内存栅栏

该方法在内部wakeup_cleaners()获取 apthread_mutex_t以便在工作线程中设置唤醒标志,因此它应该隐式生成适当的内存栅栏。但我不确定这些栅栏是否会影响调用方方法 ( force_all()) 中的内存访问。因此,我在上面注释指定的位置手动引入了栅栏。这应该确保工作线程执行的写入_requested_volumes在主线程中可见。

令我困惑的是,这些解决方案都不起作用,我完全不知道为什么。内存栅栏和易失性的语义和正确使用现在让我感到困惑。问题是编译器正在应用不需要的优化——因此是不稳定的尝试。但这也可能是线程同步的问题——因此是内存栅栏尝试。

我可以尝试第三种解决方案,其中互斥锁保护_requested_volumes对 . 因此,无论是通过互斥体显式还是隐式完成,都应该没有区别。


编辑:我的假设是错误的,解决方案 1确实有效。但是,我的问题仍然是为了澄清易失性与内存围栏的使用。如果 volatile 是一件坏事,那不应该在多线程编程中使用,我还应该在这里使用什么?内存栅栏也会影响编译器优化吗?因为我认为这是两个正交的问题,因此也是正交的解决方案:用于多线程可见性的栅栏和用于防止优化的 volatile。

4

2 回答 2

4

很多专家表示,volatile与线程同步无关,应该只用在低级硬件访问中。

是的。

但网上对此有很多争议。

一般来说,不是在“专家”之间。

我理解它的方式是, volatile 是避免编译器优化对在当前范围之外更改的内存的访问的唯一方法,无论并发访问如何。

没有。

非纯的、非 constexpr 的非内联函数调用(getter/accessors)也必然具有这种效果。诚然,链接时优化混淆了哪些函数可能真正内联的问题。

在 C 和扩展 C++ 中,volatile影响内存访问优化。Java 采用了这个关键字,并且由于它不能(或不能)volatile首先完成 C 使用的任务,因此对其进行了更改以提供内存栅栏。

在 C++ 中获得相同效果的正确方法是使用std::atomic.

从这个意义上说,volatile 应该可以解决问题,即使我们对并发编程的最佳实践存在分歧。

不,它可能会产生预期的效果,具体取决于它与您平台的缓存硬件的交互方式。这很脆弱——它可能会在你升级 CPU、添加另一个 CPU 或改变你的调度程序行为时发生变化——而且它肯定是不可移植的。


如果您真的只是在跟踪有多少工作人员仍在工作,那么理智的方法可能是信号量(同步计数器)或 mutex+condvar+integer 计数。任何一种都可能比忙于睡眠的循环更有效。

如果您对繁忙的循环很感兴趣,您仍然可以合理地拥有一个计数器,例如std::atomic<size_t>,它由wakeup_cleaners每个清理器完成设置并递减。然后你可以等待它达到零。

如果你真的想要一个繁忙的循环并且真的更喜欢每次都扫描数组,它应该是一个std::atomic<bool>. 这样您就可以决定每次加载所需的一致性,并且它将适当地控制编译器优化内存硬件。

于 2015-08-11T15:43:44.497 回答
1

显然,volatile您的示例是否必要。qualifier 本身的话题volatile太宽泛了:可以先搜索“ C++ volatile vs atomic ”等。网上有很多文章和问答,例如Concurrency: Atomic and volatile in C++11 memory model。简而言之,volatile告诉编译器禁用一些激进的优化,特别是在每次访问变量时读取变量(而不是将其存储在寄存器或缓存中)。有一些编译器做的更多,使volatile行为更像:请参阅此处std::atomic的Microsoft 特定部分。在您的情况下,禁用积极优化正是必要的。

但是,volatile没有定义围绕它的语句的执行顺序。这就是为什么您需要内存顺序,以防在设置检查的标志后需要对数据执行其他操作。对于线程间通信,它适合使用std::atomic,特别是,您需要重构_requested_volumes[vol]为 typestd::atomic<bool>甚至std::atomic_flaghttp ://en.cppreference.com/w/cpp/atomic/atomic 。

一篇不鼓励使用 volatile 并解释 volatile 只能在极少数特殊情况下使用(与硬件 I/O 连接)的文章:https ://www.kernel.org/doc/Documentation/volatile-considered-harmful.txt

于 2015-08-11T15:31:15.317 回答