SpinWait类和Monitor类的一些补充介绍

上一篇文章提到了不加锁实现线程之间的同步,今天参考了其他的资料,所以做一点补充,以便今后参考。

(一)关于SpinWait的介绍(摘自这里

(1)关于如何在多线程环境下保证程序的性能。

不要让线程从用户模式转到内核模式。在Windows上,这是个非常昂贵的操作。比如大家熟知的Critical Section(在.NET上我们叫它Monitor)在等待持有锁时,便在用户模式先旋转等待一段时间,实在不行才转入内核模式。

尽可能用尽量少的线程来完成任务。当超过四个(CPU的数量)线程的时候,操作系统就会以每隔几毫秒~几十毫秒的频率调度线程到CPU。在Windows上,线程上下文切换需要几千个时钟周期。如果进行切换的线程属于另一个进程,开销还会更大,因为操作系统还要切换虚拟地址空间。如果可运行的线程数量等于或小于CPU数量的话,操作系统只运行这些线程,而不会发生上下文切换。在这种情况下,当然性能最佳。

(2)一般的锁(比如Mutex)当线程无法持有时,便会进入内核等待,而Critical Section和Monitor(相当于C#中的lock语句与VB中的SyncLock语句)会先旋转等待(在多处理器的机器上),如果等待超时就会使用信号量进入内核等待。

(3)SpinWait在用户模式下旋转等待(单处理器的时候使用无意义,反而更浪费CPU)。我们为什么要使用SpinWait呢?因为它运行在用户模式下。某些情况下,我们想要在退回到真正等待状态前先旋转一段时间。看起来这似乎很难理解。这根本在做无用功嘛。大多数人在一开始就避免旋转等待。我们知道线程上下文切换需要花费几千个周期(在Windows上确实如此)。我们暂且称其为C。假如线程所等待的时间小于2C(1C用于等待自身,1C用于唤醒),则旋转等待可以降低等待所造成的系统开销和滞后时间,从而提升算法的整体吞吐量和可伸缩性。

SpinWait仅适合在线程等待时间较短,且在SMP或多核环境下。单处理器系统使用没有意义。

(二)关于通常的加锁方式(Monitor类)(参考自这里

(1)使用.NET中提供的各种同步机制的开销对比

用途是否支持多处理器开销*
lock (Monitor.Enter / Monitor.Exit)保证同一时间只有一个线程访问临界资源。不支持20ns
Mutex支持1000ns
SemaphoreSlim (introduced in Framework 4.0)保证同一时间不超过一定数量的线程可以访问临界资源。不支持200ns
Semaphore支持1000ns
ReaderWriterLockSlim (introduced in Framework 3.5)在同一时刻下,保证只有一个写入线程,但可以有多个读取线程。不支持40ns
ReaderWriterLock (effectively deprecated)不支持100ns

*Time taken to lock and unlock the construct once on the
same thread (assuming no blocking), as measured on an Intel Core i7 860.

所以,在绝大多数情况下,推荐使用Monitor类进行多线程的同步(第一部分说了Monitor类的执行规则)。

警告:使用锁进行原子操作时,如果发生异常有可能丢失数据:

decimal _savingsBalance, _checkBalance;

void Transfer (decimal amount)
{
  lock (_locker)
  {
    _savingsBalance += amount;
    _checkBalance -= amount + GetBankFee();
  }
}

如果GetBankFee发生异常,那么银行有部分钱就不见了,需要采取其他的措施(比如回滚操作或者使用事物)来避免。

(2)如果加锁的时间太长,就会削弱多线程带来的好处(因为大部分时间只有一个线程可以执行),同时会增加死锁的可能性。

发生死锁时,SQL Server会强制终止并回滚其中的一个事物并抛出异常;而.NET不会,死锁发生时这些线程将永远处于阻塞状态(除非在加锁时指定了超时时间)。

(3)可以考虑使用不可变的对象(immutable)来避免死锁(比如string对象)。

(4)尽量不要简单依靠volatile修饰符来达到线程同步的目的。

总之多线程同步是一个技术活,非常考验搬砖工的身手。就我看到的很多程序,多线程的好处并没有体现出来(比如锁定很长时间或干脆直接把一个很大的对象锁掉,或者在不必要的地方也使用大量的锁,在执行数据操作时使用巨长的TransactionScope)。

人的思维模式还是更接近单线程模式,所以看似我们用了很多多线程的技术,在程序执行起来其实基本都是顺序执行的,并没有充分利用多线程的好处。

关于这一点,我们也只能通过学习他人的思想以及在实践中慢慢长进了。

✏️ 有任何想法?欢迎发邮件告诉老夫:daozhihun@outlook.com