上一篇文章提到了不加锁实现线程之间的同步,今天参考了其他的资料,所以做一点补充,以便今后参考。
(一)关于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)。
人的思维模式还是更接近单线程模式,所以看似我们用了很多多线程的技术,在程序执行起来其实基本都是顺序执行的,并没有充分利用多线程的好处。
关于这一点,我们也只能通过学习他人的思想以及在实践中慢慢长进了。