自旋锁的实现原理
- 自旋锁的介绍
- 自旋锁和互斥锁比较相似,都是为了实现保护共享资源而提出的一种锁机制,在任何一个时刻,只有一个执行单元可以获取该锁,如果该锁已经被别的单元占用,那么调用者便会进行CPU空转消耗并且时刻关注该所是否已经被释放,直到自己获取该锁为止。
- 自旋锁的特点
- 相对于互斥锁,自旋锁是一种轻量级的锁,在有别的线程获取了该锁,需要进行自旋等待的时候,CPU依然占用着该线程资源不放,不会切换到其他线程去执行,因此,在等待时间较长的时候是不适用自旋锁的,这会拜拜消耗大量的CPU性能。
- 而互斥锁,在需要进行等待的时候,是不会一直空转消耗CPU的,它会阻塞并且切换到别的线程执行,发生一个上下文切换,这也是一个较为耗时的操作,在重新再切换回该线程执行时,就已经发生了两次上下文切换。
- 总的来说,在锁的竞争不繁忙,和该锁保持的代码执行时间较短的情况下,是可以使用自旋锁的,这样不会因为等待时间过长而白白浪费了大量的CPU性能。在C#中,SpinWait和SpinLock是基于自旋的等待操作,而Lock、Mutex和各种信号量,都是基于阻塞的等待操作。
- C#中的自旋操作
- 如上所说,SpinWait和SpinLock是C#中的自旋操作,由于自旋锁的自旋操作带来了一定的风险性,比如活锁,CPU一直进行空转等待,所以在C#中的自旋操作是一种自适应的自旋操作,其部分源代码如下:
internal const int YIELD_THRESHOLD = 10;
//作上下文切换操作的阈值
internal const int SLEEP_0_EVERY_HOW_MANY_TIMES = 5;
//每自旋5次sleep(0)一次
internal const int SLEEP_1_EVERY_HOW_MANY_TIMES = 20;
//每自旋20次sleep(1)一次
private int m_count;
//自旋次数[__DynamicallyInvokable]
public int Count
{
[__DynamicallyInvokable]
get
{
return m_count;
}
}[_DynamicallyInvokable]
public bool NextSpinWillYield
{
[__DynamicallyInvokable]
get
{
if (m_count <= 10)//当自旋次数不超过10次时,单核CPU则返回true,
{
return PlatformHelper.IsSingleProcessor;
}
return true;
//自旋次数超过10次也将返回true
}
}[__DynamicallyInvokable]
public void SpinOnce()
{
if (NextSpinWillYield)//如果下次操作将产生上下文切换
{
CdsSyncEtwBCLProvider.Log.SpinWait_NextSpinWillYield();
//只有单核CPU才会m_count不大于10,多核CPU从10以后开始进行计数
int num = (m_count >= 10) ? (m_count - 10) : m_count;
if (num % 20 == 19)//10以后的计数值除以20后的余数为19则触发一次sleep(1)操作
{
Thread.Sleep(1);
}
else if (num % 5 == 4)//10以后的计数值除以5后的余数为4则触发一次sleep(0)操作
{
Thread.Sleep(0);
}
else//上述条件都不满足则进行Yield操作
{
Thread.Yield();
}
}
else
{
Thread.SpinWait(4 << m_count);
//如果不发生上下文切换,这次自旋操作将导致线程等待4*2的m_count的时间
}
//当m_count到达int类型计数最大值时重新赋值为10,否则m_count加一
m_count = ((m_count == 2147483647) ? 10 : (m_count + 1));
}[__DynamicallyInvokable]
public static bool SpinUntil(Func condition, int millisecondsTimeout)//该方法将导致线程在一定时间内自旋等待条件完成
{
if (millisecondsTimeout < -1)//超时时间不能为负
{
throw new ArgumentOutOfRangeException("millisecondsTimeout", millisecondsTimeout, Environment.GetResourceString("SpinWait_SpinUntil_TimeoutWrong"));
}
if (condition == null)//条件不能为空
{
throw new ArgumentNullException("condition", Environment.GetResourceString("SpinWait_SpinUntil_ArgumentNull"));
}
uint num = 0u;
if (millisecondsTimeout != 0 && millisecondsTimeout != -1)
{
num = TimeoutHelper.GetTime();
//获取当前时间
}
SpinWait spinWait = default(SpinWait);
while (!condition())//条件不满足时,将执行自旋等待,超时时间等于0或者确实超时了将会返回false
{
if (millisecondsTimeout == 0)
{
return false;
}
spinWait.SpinOnce();
//执行自旋操作
if (millisecondsTimeout != -1 && spinWait.NextSpinWillYield && millisecondsTimeout <= TimeoutHelper.GetTime() - num)
{
return false;
}
}
return true;
//在指定超时时间内条件满足则返回false
}
- 在SpinWait中,在自旋次数超过10之后,每次进行自旋便会触发上下文切换的操作,在这之后每自旋5次会进行一次sleep(0)操作,每20次会进行一次sleep(1)操作。
- 通过查看SpinOnce的源码,可以看到,在多核CPU的情况下,在自旋次数10次以内,每次自旋会导致该线程等待4*2的m_count的时间,自旋次数超过10次之后,每20次自旋的第19次会进行sleep(1)一次,每5次自旋的第4次会进行sleep(0)一次,其余都是yield()操作。
- 对于该线程来说,yield()会导致CPU强制将当前线程切换为当前已经准备好的另外一个线程,即使这个线程的优先级更低,sleep(0)则是将当前线程重新放回该优先级的队列,重新进行一次线程调度,如果没有更高优先级或者相同优先级的就绪线程,CPU可能会再次调回该线程,而sleep(1)会使该线程在未来的1ms的时间内不会成为就绪状态,将不参与当前CPU的竞争。这三种方式都会直接强制该线程放弃剩余的当前的时间片,重新进行线程调度。
- 再来看SpinUntil的源码,这个方法允许在一个指定的时间内,等待某条件的完成,首先它的指定时间不能为负,条件不能为null,接下来,便使用一个While循环,判断该条件是否满足,每判断一次条件,会判断时间是否已经到达,也会执行一次自旋操作SpinOnce(),若时间到达条件还没能满足则会返回false,在指定时间内满足了则返回true。这也是使用SpinOnce()的一种标准的操作,在C#的其他很多地方中对SpinOnce()方法的使用也是这样的。