我读了自旋锁代码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);
}
答案 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操作)。