练习:自己动手实现一个轻量级的信号量(二)
话说看了Angel Lucifer兄的留言之后,发现果然Microsoft在June CTP中实现了SemaphoreSlim,其中不但考虑了与旧的同步对象在接口上的一致性,还加入了Cancellation的检查。唉,怪我没有跟上形势!那么这篇就成班门弄斧了。不过,还应该坚持把它写完,善始善终,就当为大家整理思路。取笑罢了:-)
上一次说到用户态与内核态切换带来的开销平均水平是600个时钟周期!因此,如果在大部分时间内。各个线程对同步对象操作时间非常短暂的情况下,可以先自旋一段时间,避免直接进入内核模式,在自旋无果的情况下再进入真正的等待。(这里插一句:可能我说的不清楚,造成了大家的误解,实际上获得一个CriticalSection的操作在用户态就可以完成了,但是,一旦锁已经被其他线程持有而需要进入等待时,一定会切换到内核模式。这就是为什么说使用自旋可以极大限度的避免每次都切换到内核模式的原因)。Intel的《多核程序设计》一书中就提到,在多核处理器平台,可以使用
初始化CriticalSection对象,使其在进入真正等待之前进行短暂的自旋而不是直接进入等待状态,也是这个意思。
好的,首先看看我们在什么地方需要进行自旋等待。很显然就是在Wait刚刚开始,需要获得锁的时候。为了方便,将上一篇中的源代码摘抄过来。
2 {
3 // 参数检查工作是必须的
4 if (millisecondsTimeout < -1)
5 throw new ArgumentOutOfRangeException("millisecondsTimeout");
6 if (waitResNumber < 1)
7 throw new ArgumentOutOfRangeException("waitResNumber");
8 // 我们在后面要两次处理超时,因此我们需要一个计时器
9 System.Diagnostics.StopWatch watch = null;
10 int timeoutNum = millisecondsTimeout;
11 if (millisecondsTimeout != -1)
12 {
13 watch = System.Diagnostics.Stopwatch.StartNew();
14 }
15 // 现在我们来做第(1)步
16 if (!System.Threading.Monitor.TryEnter(this._globalLock))
17 {
18 if (millisecondsTimeout == 0)
19 return false;
20 /////////////////////////////////////////////////
21 // 就是在这里,我们需要先自旋一下,然后再进入真正的等待 //
22 /////////////////////////////////////////////////
23 if (!System.Threading.Monitor.TryEnter(this._globalLock, millisecondsTimeout))
24 {
25 return false;
26 }
27 }
28 // 现在我们已经获得锁了,我们要增加阻塞的线程数目,以便将来有人能够唤醒我们
29 ++this._waitingThreadCount;
30 try
31 {
32 // 如果当前的资源数目不够用的,我们就得等等了
33 while (this._currentResources - waitResNumber < 0)
34 {
35 if (millisecondsTimeout != -1)
36 {
37 // 看看是不是超时了
38 timeoutNum = UpdateTimeout(watch, millisecondsTimeout);
39 if (timeoutNum <= 0)
40 {
41 return false;
42 }
43 }
44 // 如果没有超时我们就再等一下
45 if (!System.Threading.Monitor.Wait(this._globalLock, timeoutNum))
46 {
47 return false;
48 }
49 // 好的我们被唤醒了,赶快回去检查检查有没有足够的资源了
50 }
51 // 很好,我们现在有足够的资源了,
52 // 并且没有什么人能够再更改资源数目了,因为锁在我们手里!
53 this._currentResources -= waitResNumber;
54 }
55 finally
56 {
57 // 很好,我们安全的退出了,减少阻塞的线程数目并且释放锁
58 --this._waitingThreadCount;
59 System.Threading.Monitor.Exit(this._globalLock);
60 }
61 return true;
62 }
63
64
直接在上面添加一个Thread.SpinWait并不很好,因为如果一次Spin的时间较长,就无谓的浪费了时间,而过短又不能避免进入真正的等待状态,所以最好是分几次来Spin,这样,在每一次的间歇我们还是可以检查是否我们已经获得了锁。自然,要多加上一个循环:
2 {
3 // 参数检查工作是必须的
4 if (millisecondsTimeout < -1)
5 throw new ArgumentOutOfRangeException("millisecondsTimeout");
6 if (waitResNumber < 1)
7 throw new ArgumentOutOfRangeException("waitResNumber");
8 // 我们在后面要两次处理超时,因此我们需要一个计时器
9 System.Diagnostics.Stopwatch watch = null;
10 int timeoutNum = millisecondsTimeout;
11 if (millisecondsTimeout != -1)
12 {
13 watch = System.Diagnostics.Stopwatch.StartNew();
14 }
15 // 现在我们来做第(1)步
16 // 这个值实际上很有讲究,可惜这个地方我没有做很多的测试
17 int spinThreshold = 20;
18 while (true)
19 {
20 if (!System.Threading.Monitor.TryEnter(this._globalLock))
21 {
22 // 如果不允许超时设置则一次获得失败就直接退出
23 if (millisecondsTimeout == 0)
24 return false;
25 // 如果不是无限等待每次循环都更新时间看看有没有超时
26 else if (millisecondsTimeout != -1)
27 {
28 timeoutNum = UpdateTimeout(watch, millisecondsTimeout);
29 if (timeoutNum <= 0)
30 return false;
31 }
32 if (spinThreshold <= 0)
33 {
34 // 如果自选完毕了就进入真正的等待
35 if (!System.Threading.Monitor.TryEnter(this._globalLock, millisecondsTimeout))
36 {
37 return false;
38 }
39 else
40 {
41 // 我们最终还是获得了锁
42 break;
43 }
44 }
45 else
46 {
47 // 退避自旋
48 System.Threading.Thread.SpinWait(21 - spinThreshold);
49 --spinThreshold;
50 }
51 }
52 else
53 {
54 // 很幸运,第一次就获得了锁!
55 break;
56 }
57 }
58 // 现在我们已经获得锁了,我们要增加阻塞的线程数目,以便将来有人能够唤醒我们
59 ++this._waitingThreadCount;
60 try
61 {
62 // 如果当前的资源数目不够用的,我们就得等等了
63 while (this._currentResources - waitResNumber < 0)
64 {
65 if (millisecondsTimeout != -1)
66 {
67 // 看看是不是超时了
68 timeoutNum = UpdateTimeout(watch, millisecondsTimeout);
69 if (timeoutNum <= 0)
70 {
71 return false;
72 }
73 }
74 // 如果没有超时我们就再等一下
75 if (!System.Threading.Monitor.Wait(this._globalLock, timeoutNum))
76 {
77 return false;
78 }
79 // 好的我们被唤醒了,赶快回去检查检查有没有足够的资源了
80 }
81 // 很好,我们现在有足够的资源了,
82 // 并且没有什么人能够再更改资源数目了,因为锁在我们手里!
83 this._currentResources -= waitResNumber;
84 }
85 finally
86 {
87 // 很好,我们安全的退出了,减少阻塞的线程数目并且释放锁
88 --this._waitingThreadCount;
89 System.Threading.Monitor.Exit(this._globalLock);
90 }
91 return true;
92 }
这样我们就针对锁的获得加入了自旋优化,充分避免直接进入等待锁的状态。
剩下的就是以秋风扫落叶之势编写余下的代码了!整体代码如下:
上面有一处小小的改动。就是_waitingThreadCount取消了volatile修饰,因为我们对于这个变量的更新以及获得均在lock中,已经包含了适当的barrier,因此没有必要将其声明为volatile了。相反的,_currentResourceCount 就需要声明为 volatile 以保证其有acquire语义。
回头再看一下微软实现的SemaphoreSlim,确实考虑的很周到,不但考虑了Cancellation(但是很奇怪,一个Semaphore一旦取消之后就貌似不可能重新恢复了,只有Dispose之后再用另一个!)而且考虑了与原有的同步对象的兼容性,方便代码移植。继承自WaitHandle,在需要使用内核对象时Lazy创建并使用内核对象,而在不使用WaitHandle的情况下,使用Monitor对Semaphore进行模拟。在这一点上,确实应当学习Microsoft,多从用户(对于Library,程序员就是用户啊)的角度考虑。毕竟得用户者得天下啊。(在东西不是很贵的前提下,哈哈:-D)