对旋转锁感到困惑

时间:2009-03-27 07:02:54

标签: multithreading language-agnostic synchronization locking

我读了自旋锁代码from here,特别是这部分

inline void Enter(void)
{
    int prev_s;
    do
    {
        prev_s = TestAndSet(&m_s, 0);
        if (m_s == 0 && prev_s == 1)
        {
            break;
        }
        // reluinquish current timeslice (can only
        // be used when OS available and
        // we do NOT want to 'spin')
        // HWSleep(0);
    }
    while (true);
}

为什么我们需要测试两个条件m_s == 0&& prev_s == 1?我认为只测试prev_s == 1就足够了。有什么想法吗?

编辑:版本2.如果有错误,我们应该以这种方式修复吗?

inline void Enter(void)
{
    do
    {
        if (m_s == 0 && 1 == TestAndSet(&m_s, 0))
        {
            break;
        }
        // reluinquish current timeslice (can only
        // be used when OS available and
        // we do NOT want to 'spin')
        // HWSleep(0);
    }
    while (true);
}

编辑:版本3.我认为功能级别的版本3是正确的,但是每次我们需要编写时,性能都不够好,没有提前读取测试。我的理解是否正确?

inline void Enter(void)
{
    do
    {
        if (1 == TestAndSet(&m_s, 0))
        {
            break;
        }
        // reluinquish current timeslice (can only
        // be used when OS available and
        // we do NOT want to 'spin')
        // HWSleep(0);
    }
    while (true);
}

@dragonfly,这是我的错误修复版本4(修正了你指出的版本2中的错误),你能检查一下它是否正确吗?谢谢!

编辑:第4版。

inline void Enter(void)
{
    do
    {
        if (m_s == 1 && 1 == TestAndSet(&m_s, 0))
        {
            break;
        }
        // reluinquish current timeslice (can only
        // be used when OS available and
        // we do NOT want to 'spin')
        // HWSleep(0);
    }
    while (true);
}

5 个答案:

答案 0 :(得分:7)

在我看来,这是一次尝试优化略有错误。我怀疑它正在尝试“TATAS” - “测试和测试和设置”,如果它已经看到已经采取锁定,它甚至不会尝试执行TestAndSet。 / p>

post about spin locks for .NET中,Joe Duffy将此TATAS代码写为:

class SpinLock {
    private volatile int m_taken;

    public void Enter() {
        while (true) {
            if (m_taken == 0 &&
                    Interlocked.Exchange(ref m_taken, 1) == 0)
                break;
        }
    }

    public void Exit() {
        m_taken = 0;
    }
}

(注意,Joe使用1表示已锁定,0表示解锁,与代码项目示例不同 - 要么很好,要么两者之间不要混淆!)

请注意,此处对Interlocked.Exchange 的调用是以m_taken为0 为条件的。这减少了争用 - 在没有必要的情况下避免了相对昂贵的(我猜)测试和设置操作。我怀疑这是作者的目标,但是并没有完全正确。

在“重要优化”下的Wikipedia article about spinlocks中也提到了这一点:

  

减少CPU间的总线流量,何时   没有获得锁,代码   应该循环阅读而不试图   写任何东西,直到它读到   改变了价值。因为MESI缓存   协议,这会导致缓存行   锁定成为“共享”;然后   没有公共汽车交通   当CPU正在等待锁定时。   这种优化对所有人都有效   具有缓存的CPU架构   每个CPU,因为MESI是如此   无处不在。

“循环读取”正是while循环所做的 - 直到它看到m_taken更改,它才会读取。当它看到变化时(即当锁被释放时),它又有另一个锁定。

当然,我很可能错过了一些重要的东西 - 像这样的问题非常微妙。

答案 1 :(得分:2)

为什么两个条件?因为在这种情况下第二个线程也会获得锁定。 (编辑:但如果所有线程都遵循自旋锁协议,则不会发生这种情况。)

如果锁定可用(发出信号)m_s的值为1.当某个线程占用时,它的值为0.不允许其他值。

考虑一个想要锁定的线程,无论是否在名为Enter()的线程不重要的时刻发出信号。如果m_s为1,则允许锁定,并将其更改为0.第一次迭代将导致循环退出并且线程具有锁定。

现在考虑两个需要相同锁的线程。两者都在调用TestAndSet(),等待看到值1变为0.因为函数TestAndSet()是原子的,只有一个等待的线程可以看到值1.所有其他线程只看到m_s为0,必须继续等待。

在此线程中将m_s设置为0后,m_s为1的条件意味着在原子操作和条件之间发出了一些其他线程的信号。由于一次只有一个线程应该有锁,似乎不应该发生这是不可能发生的。

我猜这是为了满足自旋锁的不变承诺。 (编辑:我不再那么肯定,更多信息......)如果保留,m_s的值必须为零。如果没有,那就是一个。如果将它设置为零并没有“坚持”,那么就会发生一些奇怪的事情,并且最好不要假设当不变量不为真时,它现在被这个线程所持有。

编辑:Jon Skeet指出,此案例可能是原始实施中的一个缺陷。我怀疑他是对的。

受到保护的竞争条件是针对无权发出自旋锁信号的线程,无论如何都要发出自旋锁信号。如果您不能信任呼叫者遵守规则,那么自旋锁可能不是首选的同步方法。

编辑2:建议的修订版看起来好多了。它显然可以避免原始因为始终编写标记m_s而导致的多核缓存一致性交互。

在阅读了TATAS protocol(你可以每天学到新东西,如果你注意了......)以及它正在解决的多核缓存一致性问题之后,我很清楚原始代码正在尝试做类似的事情,但不理解它背后的微妙之处。确实是安全的(假设所有呼叫者都遵守规则)在写m_s时删除冗余检查。但是代码在每次循环迭代时都会写入{{1}},这会在每个内核缓存的真正多核芯片中造成严重破坏。

新的自旋锁仍然容易受到第二个线程的攻击而无法保持它。没有办法修复它。我之前关于信任呼叫者遵守协议的说法仍然适用。

答案 2 :(得分:1)

实际上有人可能会在TestAndSet(&m_s, 1)之后和Leave() TestAndSet(&m_s, 0)之前的if之前的另一个帖子中呼叫Enter(),即m_s。这样就不会获得锁定,0将不等于{{1}}。因此需要进行此类检查。

答案 3 :(得分:1)

除了2之外,您的所有版本都是正确的。另外,您对版本1中m_s==0的检查和版本3中的性能降低的评论是正确的。

减少的原因是T& S的实施方式,特别是它会导致每次通话都写入。这是因为写入(即使它实际上没有更改m_s的数据)或写入意图,导致其缓存行在其他CPU上无效,这意味着当另一个CPU(也在等待)相同的锁)测试m_s,它无法从其缓存中读取数据,但必须从以前拥有它的CPU获取数据。在与乒乓球比赛相似之后,这种现象被称为缓存乒乓球,其中球(在这种情况下是缓存线的数据)在球员(CPU)之间不断移动。如果你在T& S之前添加额外的测试,那么CPU将只读取,这意味着他们都可以在他们的缓存(即共享)中拥有数据,直到有人写入它。

在版本1和版本3中会发生这种情况,因为它们在循环的每次迭代中都运行T& S.

请注意,关于额外检查防止其他线程错误地释放它的备注是误导性的,不是因为其他线程不能这样做,而是因为这样的检查不能防止这种可能性,甚至远程。想象一下,另一个线程会这样做,但是在锁定线程执行检查之后。如果你真的想保护自己免受这种破坏,你应该添加另一个变量,例如。持有锁的线程ID,并用锁来检查正确的操作。

另一个问题是,在某些体系结构中,您需要内存屏障来确保良好的内存排序,特别是确保m_s测试每次实际读取值(此处某些编译器屏障应该足够)以及任何读取(并且如果你愿意,可以写)在关键部分内发生的不会“泄漏”,也就是说,在执行实际锁定之前不会由CPU执行。解锁必须以类似方式处理。请注意,Jon Skeet的版本在这方面是正确的,因为他使用Java(或C#?我不确定,但他们的语义应该相似)volatile

答案 4 :(得分:0)

这些示例中没有一个在订购不太严格的硬件上是完全正确的。 PowerPC和IA64就是两个这样的例子,在获得锁定的测试和设置操作上需要isync和.acq操作(在出口处类似lwsync和.rel操作)。