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

时间:2019-01-13 21:00:37

标签: c++ multithreading atomic

我偶然发现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 pagestd::memory_order cppreference,但是后一页似乎处理了atomic的参数。我没有找到想要的答案,或者我误读了。谁能告诉我我说的是对还是错,以及这段代码是否安全?

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

将计数转换为原子成员是否正确?还是这样做有效,并且在释放互斥锁之后,所有线程都会看到该值?

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

注意:我不是在寻找代码审查,而是试图查看这种解决方案是否可以解决C ++内存模型方面的问题。

#include <atomic>
#include <mutex>

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

    // ...

    void deref()
    {
        std::scoped_lock lock{mutex};
        count--;
        if (count == 0)
            delete rawObj;
    }
};

4 个答案:

答案 0 :(得分:4)

“如果在计数== 1时有两个线程进入,该怎么办”-如果发生这种情况,则其他问题变得很棘手。智能指针背后的想法是,引用计数绑定到对象的生命周期(作用域)。当对象(通过堆栈展开)被破坏时,将发生递减。如果有两个线程触发该事件,则除非存在另一个错误,否则引用计数可能不能仅为1。

但是,可能发生的情况是两个线程在count = 2时输入此代码。在这种情况下,递减操作将被互斥锁锁定,因此它永远不会达到负值。再次,这假设其他地方没有未使用的代码。由于这样做只是删除对象(然后将count冗余地设置为零),所以不会发生任何不良情况。

虽然可能会发生两次删除。如果位于count = 2的两个线程减少计数,则它们随后都可以看到count = 0。只需确定是否要删除互斥锁内的对象即可,这是一个简单的解决方法。将该信息存储在本地变量中,并在释放互斥锁后进行相应处理。

关于您的第三个问题,将计数转换为原子数不会神奇地解决问题。同样,原子背后的观点是您不需要互斥锁,因为锁定互斥锁是一项昂贵的操作。使用原子,您可以结合减量和检查零等操作,这与上面提出的解决方案类似。原子通常比“普通”整数慢。但是它们仍然比互斥锁更快。

答案 1 :(得分:2)

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

unlock()移动到函数的末尾。或者更好的方法是使用std::lock_guard进行锁定;即使在delete调用引发异常时,它的析构函数也将解锁互斥锁。

答案 2 :(得分:1)

您的锁可以防止在不同线程中同时执行操作 yourCollection.ToList()[index] = newValue; 时造成混乱。但是,它不能保证var socket = io.connect('Your:/url/of/windowlocation/whileonsocket/here')的值是同步的,因此,在单个关键部分之外重复读取将承担数据争用的风险。

您可以将其重写如下:

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变得多余。

答案 3 :(得分:1)

如果两个线程潜在地*同时输入deref(),则无论count的先前或先前期望值如何,都会发生数据争用,并且您的整个程序,甚至包括您希望按时间顺序排列的部分,都具有未定义的行为,如C++ standard in [intro.multithread/20](N4659)中所述:

  

如果两个动作可能同时发生,

     

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

     

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

     

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

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

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

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