自旋锁的实现原理

自旋锁的实现原理

  1. 自旋锁的介绍
  • 自旋锁和互斥锁比较相似,都是为了实现保护共享资源而提出的一种锁机制,在任何一个时刻,只有一个执行单元可以获取该锁,如果该锁已经被别的单元占用,那么调用者便会进行CPU空转消耗并且时刻关注该所是否已经被释放,直到自己获取该锁为止。
  1. 自旋锁的特点
  • 相对于互斥锁,自旋锁是一种轻量级的锁,在有别的线程获取了该锁,需要进行自旋等待的时候,CPU依然占用着该线程资源不放,不会切换到其他线程去执行,因此,在等待时间较长的时候是不适用自旋锁的,这会拜拜消耗大量的CPU性能。
  • 而互斥锁,在需要进行等待的时候,是不会一直空转消耗CPU的,它会阻塞并且切换到别的线程执行,发生一个上下文切换,这也是一个较为耗时的操作,在重新再切换回该线程执行时,就已经发生了两次上下文切换。
  • 总的来说,在锁的竞争不繁忙,和该锁保持的代码执行时间较短的情况下,是可以使用自旋锁的,这样不会因为等待时间过长而白白浪费了大量的CPU性能。在C#中,SpinWait和SpinLock是基于自旋的等待操作,而Lock、Mutex和各种信号量,都是基于阻塞的等待操作。
  1. 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()方法的使用也是这样的。

    推荐阅读