非原子时,C++ 成员更新关键部分内的可见性

Posted

技术标签:

【中文标题】非原子时,C++ 成员更新关键部分内的可见性【英文标题】:C++ member update visibility inside a critical section when not atomic 【发布时间】:2019-01-13 21:00:37 【问题描述】:

我偶然发现the following Code Review StackExchange 并决定阅读它以进行练习。在代码中,有以下内容:

注意:我不是在寻找代码审查,这只是链接中代码的复制粘贴,因此您可以专注于手头的问题而无需其他代码干扰。我对实现“智能指针”不感兴趣,只是了解内存模型:

// Copied from the link provided (all inside a class)

unsigned int count;
mutex m_Mutx;

void deref()

    m_Mutx.lock();
    count--;
    m_Mutx.unlock();
    if (count == 0)
    
        delete rawObj;
        count = 0;
    

看到这让我立即想到“如果两个线程进入时 count == 1 并且都没有看到彼此的更新?最终都可以看到 count 为零和双重删除吗?两个线程是否有可能导致count 变为-1,然后删除永远不会发生?

互斥锁将确保一个线程进入临界区,但这是否保证所有线程都将正确更新? C++ 内存模型告诉我什么,所以我可以说这是不是竞争条件?

我查看了Memory model cppreference page 和std::memory_order cppreference,但是后一页似乎处理了原子参数。我没有找到我正在寻找的答案,或者我可能误读了它。谁能告诉我我说的是对还是错,这段代码是否安全?

如果代码损坏,请更正代码:

将计数转换为原子成员的正确答案是什么?或者这是否有效,并且在释放互斥锁上的锁后,所有线程都会看到该值?

我也很好奇这是否会被认为是正确答案:

注意:我不是在寻找代码审查并试图看看这种解决方案是否能解决与 C++ 内存模型有关的问题。 p>

#include <atomic>
#include <mutex>

struct ClassNameHere 
    int* rawObj;
    std::atomic<unsigned int> count;
    std::mutex mutex;

    // ...

    void deref()
    
        std::scoped_lock lockmutex;
        count--;
        if (count == 0)
            delete rawObj;
    
;

【问题讨论】:

您部分要求进行代码审查,您知道有一个更好的地方。此外,您的 ClassNameHere 类型包含一堆公共成员。另外,不要在标题中添加标签,这就是标签的用途。最后,您在这里提出了多个(相关)问题。一般的经验法则是只问一个。 @UlrichEckhardt 我根本不想要代码审查。这是旨在编译和快速说明问题的临时代码。我不确定在结构/类中是否意味着与全局命名空间中的变量不同的东西,所以我只是将它包装在一个结构中。 我只想知道是 C++ 内存模型是否确认或否认这里存在问题。我编写的代码是一个起点,有助于说明手头的问题。我鼓励其他阅读本文的人不要进行代码审查,而是专注于内存模型。我将编辑我的帖子以使其更清晰。 【参考方案1】:

“如果两个线程在 count == 1 时进入怎么办”——如果发生这种情况,其他的事情就很可疑了。智能指针背后的想法是引用计数绑定到对象的生命周期(范围)。当对象(通过堆栈展开)被销毁时会发生递减。如果有两个线程触发,则引用计数不可能只有 1,除非存在另一个错误。

但是,当count = 2 时,可能会发生两个线程输入此代码的情况。在这种情况下,减量操作被互斥锁锁定,因此它永远不会达到负值。同样,这假定其他地方没有错误代码。由于所有这些都是删除对象(然后冗余地将计数设置为零),因此不会发生任何不好的事情。

但可能发生的是双重删除。如果count = 2 的两个线程减少计数,那么之后它们都可以看到count = 0。只需确定是否删除互斥锁内的对象作为一个简单的修复。将该信息存储在局部变量中,并在释放互斥锁后进行相应处理。

关于您的第三个问题,将计数变成原子并不能神奇地解决问题。此外,原子背后的要点是您不需要需要互斥体,因为锁定互斥体是一项昂贵的操作。使用原子,您可以组合诸如递减和检查零之类的操作,这类似于上面提出的修复。原子通常比“普通”整数慢。不过,它们仍然比互斥锁快。

【讨论】:

我不确定您所说的只是检查互斥锁内的count == 1 是否是一个简单的修复方法。支票仍必须针对0。也许你的意思是在递减之前这样做? (但那会是一种奇怪的写法)。 此外,如果两个线程确实进入count == 1作为另一个失败的结果,那么rawObj可能被删除。我什至希望有这种可能性,初始化/递增计数器的代码反映了对锁定的同样有缺陷的理解。【参考方案2】:

在这两种情况下都存在数据竞争。线程 1 将计数器减为 1,并且就在 if 语句之前发生线程切换。线程 2 将计数器减为 0,然后删除该对象。线程1恢复,看到count为0,再次删除对象。

unlock() 移动到函数的末尾。或者,最好使用std::lock_guard 进行锁定;即使删除调用抛出异常,它的析构函数也会解锁互斥锁。

【讨论】:

【参考方案3】:

如果两个线程可能*同时进入deref(),那么,无论count 的先前或先前预期值如何,都会发生数据竞争,并且您的整个程序 em>,即使是您希望按时间顺序排列的部分,也具有 未定义的行为,如 C++ standard in [intro.multithread/20] (N4659) 中所述:

如果

,两个动作可能是并发的

(20.1) 它们由不同的线程执行,或者

(20.2) 它们是无序的,至少有一个由信号处理程序执行,并且它们不是由同一个信号处理程序调用执行。

如果程序的执行包含两个潜在的并发冲突操作,则该程序的执行包含数据竞争,其中至少一个是 不是原子的,也不会发生在另一个之前,除了下面描述的信号处理程序的特殊情况。任何此类数据竞争都会导致未定义的行为。

在这种情况下,潜在的并发操作当然是在锁定部分之外读取count,并在其中写入count

*) 也就是说,如果当前输入允许的话。

更新 1:您引用的部分描述了原子内存顺序,解释了原子操作如何相互同步以及与其他同步原语(例如互斥锁和内存屏障)同步。换句话说,它描述了如何将原子用于同步,以使某些操作不是数据竞争。它不适用于这里。该标准在这里采取了一种保守的方法:除非标准的其他部分明确说明两个冲突的访问不是并发的,否则您会遇到数据竞争,因此 UB(其中冲突意味着相同的内存位置,并且至少其中一个不是) t 只读)。

【讨论】:

【参考方案4】:

您的锁可以防止count-- 在不同线程中同时执行时陷入混乱。但是,它不能保证count 的值是同步的,因此在单个关键部分之外的重复读取将承担数据竞争的风险。

你可以改写如下:

void deref()

    bool isLast;
    m_Mutx.lock();
    --count;
    isLast = (count == 0);
    m_Mutx.unlock();
    if (isLast) 
        delete rawObj;
    

因此,锁确保对count 的访问是同步的并且始终处于有效状态。此有效状态通过局部变量(无竞争条件)传递到非关键部分。因此,关键部分可以保持相当短。

更简单的版本是同步完整的函数体;如果您想做比delete rawObj更精细的事情,这可能会不利:

void deref()

    std::lock_guard<std::mutex> lock(m_Mutx);
    if (! --count) 
        delete rawObj;
    

顺便说一句:std::atomic allone 不会解决这个问题,因为这只会同步每个单独的访问,而不是“事务”。因此,您的 scoped_lock 是必要的,并且 - 因为这涵盖了完整的功能 - std::atomic 变得多余。

【讨论】:

不需要未初始化的整数。只需在一行中使用--count;,在下一行中使用bool last = (count == 0);。解锁互斥锁后,检查该标志。如果需要,您也可以在递减后存储计数器的副本。但是,条件应该是不必要的:如果剩下的代码是健全的,deref()不能在count = 0时调用,以免你得到双重删除。 @Ulrich Eckhardt;对;想让代码更接近那个 OP,但我同意:只传输基本信息,即isLast-flag,更好。 仍然没有必要在不初始化变量的情况下声明它。 ;) @Uldrich Eckhardt 哪个条件是不必要的?不管你怎么做,你都必须分支。 @UlrichEckhardt 啊,你的意思是他之前在临界区中的三元组。谢谢,干杯。

以上是关于非原子时,C++ 成员更新关键部分内的可见性的主要内容,如果未能解决你的问题,请参考以下文章

深入理解Atomic原子操作和volatile非原子性

volatile关键字原子性和可见性

volatile关键字与内存可见性&原子变量与CAS算法

线程安全的三大特性(原子性可见性有序性)volatile关键字

java并发特性:原子性可见性有序性

volatile关键字作用