基于原子操作的自旋锁的 Unlock 可以直接将锁标志设置为零吗?

Posted

技术标签:

【中文标题】基于原子操作的自旋锁的 Unlock 可以直接将锁标志设置为零吗?【英文标题】:Can atomic ops based spin lock's Unlock directly set the lock flag to zero? 【发布时间】:2015-12-15 22:58:50 【问题描述】:

例如,我有一个独占的基于原子操作的自旋锁实现,如下所示:

bool TryLock(volatile TInt32 * pFlag)

   return !(AtomicOps::Exchange32(pFlag, 1) == 1);


void Lock (volatile TInt32 * pFlag) 
  
    while (AtomicOps::Exchange32(pFlag, 1) ==  1) 
        AtomicOps::ThreadYield();
    


void    Unlock (volatile TInt32 * pFlag)

    *pFlag = 0; // is this ok? or here as well a atomicity is needed for load and store    

AtomicOps::Exchange32 在 windows 上使用 InterlockedExchange 实现,在 linux 上使用 __atomic_exchange_n 实现。

【问题讨论】:

相关问题:***.com/questions/1383363/…***.com/questions/6810733/…***.com/questions/26307071/… 您能详细说明一下吗?为什么在这里,我需要一个内存屏障?如果我不使用,出了什么问题以及怎么办? Lock() 需要一个“获取屏障”以确保在自旋锁锁定时所做的所有更改将仅在 pFlag 更新后应用。 Unlock() 需要“释放屏障”以确保在自旋锁被锁定时所做的所有更改都将在 pFlag 更新之前应用。在此处查看详细信息:jfdube.wordpress.com/2012/03/08/understanding-memory-ordering。这是一种通用方法,但在 x86 上,您只需要编译器屏障,而不需要获取和释放屏障;看这里:preshing.com/20120913/acquire-and-release-semantics 我将在完整答案中添加更多详细信息。 另请参阅我对***.com/questions/20446982/… 的回答中关于锁的评论。 【参考方案1】:

在大多数情况下,为了释放资源,只需将锁​​重置为零(如您所做的那样)几乎可以(例如在英特尔酷睿处理器上),但您还需要确保编译器不会交换指令(请参阅下面,另见 gv 的帖子)。如果你想严谨(和便携),有两件事需要考虑:

编译器的作用:它可能会交换指令以优化代码,因此如果它不“意识到”代码的多线程性质,就会引入一些细微的错误。为避免这种情况,可以插入编译器屏障。

处理器的作用:某些处理器(如用于专业服务器的 Intel Itanium 或用于智能手机的 ARM 处理器)具有所谓的“宽松内存模型”。在实践中,这意味着处理器可以决定改变操作的顺序。同样,这可以通过使用特殊指令(加载屏障和存储屏障)来避免。例如,在 ARM 处理器中,指令 DMB 确保所有存储操作在下一条指令之前完成(并且需要插入到释放锁的函数中)

结论:如果您对这些功能有一些编译器/操作系统支持(例如,stdatomics.h 或 C++0x 中的std::atomic),使代码正确是非常棘手的,依赖它们比自己编写要好得多(但有时你别无选择)。在标准英特尔酷睿处理器的特定情况下,我认为您所做的是正确的,只要您在发布操作中插入编译器屏障(参见 g-v 的帖子)。

关于编译时与运行时内存排序,请参阅:https://en.wikipedia.org/wiki/Memory_ordering

我在不同架构上实现的一些原子/自旋锁的代码: http://alice.loria.fr/software/geogram/doc/html/atomics_8h.html (但我不确定它是否 100% 正确)

【讨论】:

“在标准英特尔酷睿处理器的具体情况下,我认为你所做的是正确的。” -- 正如 cmets 和我的回答中提到的,它是 not 没有编译器障碍(volatile 在这里还不够)。使用编译器屏障,它对于 x86 是正确的。【参考方案2】:

在自旋锁实现中需要两个内存屏障:

TryLock()Lock() 中的“获取障碍”或“导入障碍”。它强制在获取自旋锁时发出的操作仅在 pFlag 值更新后才可见。 Unlock() 中的“释放障碍”或“出口障碍”。它强制在释放自旋锁之前发出的操作在 pFlag 值更新之前可见。

出于同样的原因,您还需要两个编译器屏障。

详情请见this article。


这种方法适用于一般情况。在x86/64:

没有单独的获取/释放屏障,只有一个完整的屏障(内存屏障); 这里根本不需要内存屏障,因为这种架构是强排序的; 您仍然需要编译器障碍

更多详情请见here。


以下是使用GCC atomic builtins 的示例实现。它适用于 GCC 支持的所有架构:

它将在需要的架构上插入获取/释放内存屏障(如果不支持获取/释放屏障但架构是弱排序的,则插入完整屏障); 它将在所有架构上插入编译器屏障。

代码:

bool TryLock(volatile bool* pFlag)

   // acquire memory barrier and compiler barrier
   return !__atomic_test_and_set(pFlag, __ATOMIC_ACQUIRE);


void Lock(volatile bool* pFlag) 
  
    for (;;) 
        // acquire memory barrier and compiler barrier
        if (!__atomic_test_and_set(pFlag, __ATOMIC_ACQUIRE)) 
            return;
        

        // relaxed waiting, usually no memory barriers (optional)
        while (__atomic_load_n(pFlag, __ATOMIC_RELAXED)) 
            CPU_RELAX();
        
    


void Unlock(volatile bool* pFlag)

    // release memory barrier and compiler barrier
    __atomic_clear(pFlag, __ATOMIC_RELEASE);

对于“轻松等待”循环,请参阅this 和this 问题。

另请参阅 Linux kernel memory barriers 作为一个很好的参考。


在您的实施中:

Lock() 调用 AtomicOps::Exchange32() 已经包含编译器屏障,可能还有获取或完整内存屏障(我们不知道,因为您没有向 __atomic_exchange_n() 提供实际参数)。 Unlock() 错过了内存和编译器障碍,因此它被破坏了。

如果可以的话,也可以考虑使用pthread_spin_lock()

【讨论】:

“优化障碍”是指编译器障碍吗?答案已经提到编译器障碍总是需要的,但我会添加一个澄清。 如何保证“有效等待”循环最终会在没有获取障碍的情况下退出? @5gon12eder,volatile 还不够吗?在没有内存屏障的情况下,是否存在一个 CPU 的写入对另一个 CPU 不可见的架构? (所以它们不提供缓存一致性?) 我不知道这种架构是否存在,但就语言而言,我认为它不安全。假设this answer是正确的,看来Intel也不推荐了。 我发了一个问题:***.com/questions/32677667/…

以上是关于基于原子操作的自旋锁的 Unlock 可以直接将锁标志设置为零吗?的主要内容,如果未能解决你的问题,请参考以下文章

Linux驱动之并发与竞争

自旋锁,互斥锁,原子变量性能对比

CAS 自旋锁

CAS原子锁 高效自旋无锁的正确用法

聊聊高并发(十三)实现几种自旋锁

一文带你了解.Net自旋锁