昨天我发布了this question关于如何编写快速自旋锁的信息。感谢Cory Nelson,我似乎找到了一个比我的问题中讨论的其他方法更优越的方法。我使用CMPXCHG
指令来检查锁是否为0并因此是空闲的。 CMPXCHG
在'BYTE',WORD
和DWORD
上运行。我认为该指令在BYTE
上运行得更快。但我写了一个实现每种数据类型的锁:
inline void spin_lock_8(char* lck)
{
__asm
{
mov ebx, lck ;move lck pointer into ebx
xor cl, cl ;set CL to 0
inc cl ;increment CL to 1
pause ;
spin_loop:
xor al, al ;set AL to 0
lock cmpxchg byte ptr [ebx], cl ;compare AL to CL. If equal ZF is set and CL is loaded into address pointed to by ebx
jnz spin_loop ;jump to spin_loop if ZF
}
}
inline void spin_lock_16(short* lck)
{
__asm
{
mov ebx, lck
xor cx, cx
inc cx
pause
spin_loop:
xor ax, ax
lock cmpxchg word ptr [ebx], cx
jnz spin_loop
}
}
inline void spin_lock_32(int* lck)
{
__asm
{
mov ebx, lck
xor ecx, ecx
inc ecx
pause
spin_loop:
xor eax, eax
lock cmpxchg dword ptr [ebx], ecx
jnz spin_loop
}
}
inline spin_unlock(<anyType>* lck)
{
__asm
{
mov ebx, lck
mov <byte/word/dword> ptr [ebx], 0
}
}
然后使用以下伪代码测试锁(请注意,lcm指针始终指向可被4分割的地址):
<int/short/char>* lck;
threadFunc()
{
loop 10,000,000 times
{
spin_lock_8/16/32 (lck);
spin_unlock(lck);
}
}
main()
{
lck = (char/short/int*)_aligned_malloc(4, 4);//Ensures memory alignment
start 1 thread running threadFunc and measure time;
start 2 threads running threadFunc and measure time;
start 4 threads running threadFunc and measure time;
_aligned_free(lck);
}
我已经在具有2个物理内核的处理器上以msecs测量了以下结果,可以运行4个线程(Ivy Bridge)。
1 thread 2 threads 4 threads
8-bit 200 700 3200
16-bit 200 500 1400
32-bit 200 900 3400
数据表明所有功能都需要相同的时间来执行。但是当多个线程必须检查使用16位的lck == 0
是否会明显更快。这是为什么?我不认为它与lck
?
提前致谢。
答案 0 :(得分:2)
想象一下,有1234个线程和16个CPU。一个线程获取自旋锁,然后操作系统执行任务切换。现在你有16个CPU,每个运行剩余的1233个线程中的一个,所有这些都以非常无意义的方式旋转,因为操作系统将CPU时间提供给可以释放自旋锁的唯一线程。这意味着整个操作系统基本上可以锁定(所有CPU平稳)几秒钟。这是严重迟钝的;那你怎么解决它?
您通过不在用户空间中使用自旋锁来修复它。只有当/可以禁用任务开关时,才应使用自旋锁;并且只有内核应该能够禁用任务切换。
更具体地说,您需要使用互斥锁。现在,互斥体可能会在放弃之前最初旋转并使线程等待锁定,并且(对于典型/低争用情况)这确实有帮助,但它仍然是互斥锁并且不是自旋锁。
下一步;对于理智的软件,重要的(性能)是避免锁争用,然后确保无争用的情况是快的(如果没有争用,好的互斥锁不会导致任务切换)。您正在测量争用/无关的案例。
最后;你的锁很糟糕。为了避免过度使用lock
前缀,您应该测试是否可以在没有任何lock
前缀的情况下获取,并且只有在您可以获得时才应使用lock
前缀。英特尔(可能还有许多其他人)称这种策略为“测试;然后(测试和设置)”。此外,你还没有理解pause
(或“rep nop”对于那些非常糟糕且不支持10年前指令的汇编程序的目的)。
半个不错的自旋锁可能看起来像:
acquire:
lock bts dword [myLock],0 ;Optimistically attempt to acquire
jnc .acquired ;It was acquired!
.retry:
pause
cmp dword [myLock],0 ;Should we attempt to acquire again?
jne .retry ; no, don't use `lock`
lock bts dword [myLock],0 ;Attempt to acquire
jc .retry ;It wasn't acquired, so go back to waiting
.acquired:
ret
release:
mov dword [myLock],0 ;No lock prefix needed here as "myLock" is aligned
ret
另请注意,如果您未能充分降低锁争用的可能性,那么您需要关心“公平性”,而不应使用自旋锁。 “不公平”自旋锁的问题在于,某些任务可能是幸运的并且总是获得锁定,而某些任务可能不幸并且永远不会获得锁定,因为幸运的任务总是得到它。对于竞争激烈的锁定而言,这一直是一个问题,但对于现代NUMA系统来说,这已成为一个更可能出现问题的问题。在这种情况下,您至少应该使用票证锁。
故障锁定的基本思想是确保任务按照到达的顺序获取锁定(而不是某些“可能非常糟糕”的随机顺序)。为完整起见,故障锁定可能如下所示:
acquire:
mov eax,1
lock xadd [myLock],eax ;myTicket = currentTicket, currentTicket++
cmp [myLock+4],eax ;Is it my turn?
je .acquired ; yes
.retry:
pause
cmp [myLock+4],eax ;Is it my turn?
jne .retry ; no, wait
.acquired:
ret
release:
lock inc dword [myLock+4]
ret
tl; dr; 您不应该使用错误的工具开始工作(自旋锁);但如果你坚持使用错误的工具,那么至少要正确实施错误的工具......: - )
答案 1 :(得分:1)
从我记得锁定在一个单词(2个字节)上工作。它是在第一次引入486时以这种方式编写的。
如果你携带一个不同大小的锁,它实际上产生相当于2个锁(锁定字A和字B为双字)。对于一个字节,它可能必须防止锁定第二个字节,有点类似于2个锁...
因此,您的结果与CPU优化一致。