非原子时,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++ 成员更新关键部分内的可见性的主要内容,如果未能解决你的问题,请参考以下文章