易失性与内存栅栏

时间:2015-08-11 14:41:13

标签: c++ multithreading gcc volatile memory-fences

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

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始终为true,即使数组中的所有值都已设置为false。这只发生在-O3

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

  1. 声明_requested_volumes不稳定 (编辑:此解决方案确实有效。请参阅下面的编辑)
  2. 许多专家说volatile与线程同步无关,它只应用于低级硬件访问。但是在互联网上存在很多争议。我理解它的方式,volatile是阻止编译器优化对当前作用域之外的内存访问的唯一方法,无论并发访问。从这个意义上说,即使我们不同意并发编程的最佳实践,volatile 也应该做到这一点。

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

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

      我可以尝试第三个解决方案,其中互斥锁保护对_requested_volumes的每次访问,但即使这样可行,我也想了解原因,因为据我所知,这都是关于内存栅栏的。因此,无论是通过互斥锁明确地还是隐式地完成它都应该没有区别。

      编辑:我的假设是错误的,而解决方案1实际上 工作。但是,我的问题仍然是为了澄清volatile与内存栅栏的使用。如果volatile是如此糟糕的事情,那绝不应该用于多线程编程,我还应该在这里使用什么呢?内存栅栏是否也会影响编译器优化?因为我认为这些是两个正交问题,因此也就是正交解决方案:在多个线程中提供可见性的范围,以及用于防止优化的易失性。

2 个答案:

答案 0 :(得分:4)

  

许多专家说volatile与线程同步无关,它只能用于低级硬件访问。

  

但是在互联网上存在很多争议。

一般而言,不是专家之间的#34;

  

我理解它的方式,volatile是阻止编译器优化对当前作用域之外更改的内存访问的唯一方法,无论并发访问如何。

不。

非纯,非constexpr非内联函数调用(getters / accessors)也必然具有此效果。不可否认,链接时优化会混淆哪些函数可能真正被内联的问题。

在C中,通过扩展C ++,volatile会影响内存访问优化。 Java使用了这个关键字,因为它不能(或者不能)执行C首先使用volatile的任务,所以改变它以提供内存栅栏。

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

  

从这个意义上说,即使我们不同意并发编程的最佳实践,volatile也应该可以解决问题。

不,可能具有所需的效果,具体取决于它与您平台的缓存硬件的交互方式。这很脆弱 - 它可能会在您升级CPU或添加另一个CPU或更改您的调度程序行为时随时更改 - 而且它当然不可移植。

如果您真的只是跟踪有多少工作者仍然在工作,那么理智的方法可能是信号量(同步计数器),或互斥+ condvar +整数计数。两者都可能比忙于循环睡眠更有效。

如果您已经忙于忙碌循环,您仍然可以合理地拥有一个单个计数器,例如std::atomic<size_t>,由wakeup_cleaners设置并递减每个清洁工完成。然后你可以等它达到零。

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

答案 1 :(得分:1)

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

但是,volatile没有定义围绕它执行语句的顺序。这就是为什么你需要memory order,以防你在设置了标志后需要对数据执行其他操作。 对于线程间通信,使用std::atomic是合适的,特别是,您需要将_requested_volumes[vol]重构为std::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