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

C++ member update visibility inside a critical section when not atomic

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

注意:我不是在寻找代码审查,这只是 link 中代码的复制粘贴,因此您可以在没有其他代码干扰的情况下专注于手头的问题。我对实现一个'smart pointer'不感兴趣,只是了解内存模型:

// 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 and std::memory_order cppreference,但是后一页似乎处理了原子参数。我没有找到我要找的答案,或者我看错了。谁能告诉我我说的是错还是对,这段代码安全不安全?

如果代码损坏请更正:

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

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

注意:我不是在寻找代码审查,也不是想看看这种解决方案是否能解决与 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;
    }
};

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

unlock()移到第function.or的末尾,更好,用std::lock_guard做锁;即使删除调用抛出异常,它的析构函数也会解锁互斥体。

"what if two threads enter when count == 1" -- 如果发生这种情况,则说明其他事情有问题。智能指针背后的想法是引用计数绑定到对象的生命周期(范围)。递减发生在对象(通过堆栈展开)被销毁时。如果两个线程触发它,除非存在另一个错误,否则引用计数不可能只是 1。

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

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

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

您的锁可防止该操作 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 不会解决这个问题,因为它只同步每个单独的访问,而不是 "transaction"。因此,您的 scoped_lock 是必要的,并且 - 因为这跨越了整个功能 - std::atomic 变得多余。

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

Two actions are potentially concurrent if

(20.1) they are performed by different threads, or

(20.2) they are unsequenced, at least one is performed by a signal handler, and they are not both performed by the same signal handler invocation.

The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior.

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

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

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