有CSDN的朋友问一个问题,“Lock关键字不是有获取锁、释放锁的功能吗?...为什么还需要执行Pulse?”
也有朋友有些疑点,“用lock就不要用monitor了”,“Monitor.Wait完全没必要”,“为什么Pulse和Wait方法必须从同步的代码块内调用?”
这些疑问很自然。在大部分情况下,lock确实能基本达到我们要求资源同步的目的,加上配合其他同步工具,比如事件(AutoResetEvent)等的应用,日常工作中确实没有太多机会需要用到Monitor.Wait和Pulse。不过,虽然较少机会用到,事实上 Wait和Pulse跟lock完全不是一回事。他们提供了更细腻的同步功能,能达到lock作不来的功能。
当一个线程尝试着lock一个同步对象的时候,该线程就在 就绪队列中排队。一旦没人拥有该同步对象,就绪队列中的线程就可以占有该同步对象。这也是我们平时最经常用的lock方法。
为了其他的同步目的,占有同步对象的线程也可以暂时放弃同步对象,并把自己流放到 等待队列中去。这就是Monitor.Wait。由于该线程放弃了同步对象,其他在就绪队列的排队者就可以进而拥有同步对象。
比起就绪队列来说,在等待队列中排队的线程更像是二等公民:他们不能自动得到同步对象,甚至 不能自动升舱到就绪队列。而Monitor.Pulse的作用就是开一次门,使得一个正在等待队列中的线程升舱到就绪队列;相应的Monitor.PulseAll则打开门放所有等待队列中的线程到就绪队列。
比如下面的程序:
从时间线上来分析:
由于Monitor.Wait的暂时放弃和Monitor.Pulse的开门机制,我们可以用Monitor来实现更丰富的同步机制,比如一个事件机(ManualResetEvent):
我们看到了该玩具MyManualEvent实现了类库中的ManulaResetEvent的功能,但却更加的轻便 - 类库的ManulaResetEvent使用了操作系统内核事件机制,负担比较大(不算竞态时间,ManulaResetEvent是微秒级,而lock是几十纳秒级)。
例子的WaitOne中先在lock的保护下判断是否信号绿灯,如果不是则进入等待。因此可以有多个线程(比如例子中的AB)在等待队列中排队。
当调用Set的时候,在lock的保护下信号转绿,并使用PulseAll开门放狗,将所有排在等待队列中的线程放入就绪队列,A或B(比如A)于是可以重新获得同步对象,从Monitor.Wait退出,并随即退出lock区块,WaitOne返回。随后B或A(比如B)重复相同故事,并从WaitOne返回。
线程C在myManualEvent.Set()后才执行,它在WaitOne中确信信号灯早已转绿,于是可以立刻返回并得以执行随后的命令。
该玩具MyManualEvent可以用在需要等待初始化的场合,比如多个工作线程都必须等到初始化完成后,接到OK信号后才能开工。该玩具MyManualEvent比起ManulaResetEvent有很多局限,比如不能跨进程使用,但它演示了通过基本的Monitor命令组合,达到事件机的作用。
现在是回答朋友们的疑问的时候了:
Q: Lock关键字不是有获取锁、释放锁的功能... 为什么还需要执行Pulse?
A: 因为Wait和Pulse另有用途。
Q: 用lock 就不要用monitor了(?)
A: lock只是Monitor.Enter和Monitor.Exit,用Monitor的方法,不仅能用Wait,还可以用带超时的Monitor.Enter重载。
Q: Monitor.Wait完全没必要 (?)
A: Wait和Pulse另有用途。
Q: 什么Pulse和Wait方法必须从同步的代码块内调用?
A: 因为Wait的本意就是“[暂时]释放对象上的锁并阻止当前线程,直到它重新获取该锁”,没有获得就谈不到释放。
我们知道lock实际上一个语法糖糖,C#编译器实际上把他展开为Monitor.Enter和Monitor.Exit,即:
但是,这种实现逻辑至少理论上有一个错误:当Monitor.Enter(lockObj);刚刚完成,还没有进入try区的时候,有可能从其他线程发出了Thread.Abort等命令,使得该线程没有机会进入try...finally。也就是说lockObj没有办法得到释放,有可能造成程序死锁。这也是Thread.Abort一般被认为是邪恶的原因之一。
DotNet4开始,增加了Monitor.Enter(object,ref bool)重载。而C#编译器会把lock展开为更安全的Monitor.Enter(object,ref bool)和Monitor.Exit:
现在Monitor.TryEnter在try的保护下,“加锁”成功意味着“放锁”将得到finally的保护。
注释和引用:
Monitor.Wait 方法
http://msdn.microsoft.com/zh-cn/library/79fkfcw1.aspx
Monitor.TryEnter 方法
http://msdn.microsoft.com/zh-cn/library/dd289679.aspx
请问,多线程Monitor类
http://topic.csdn.net/u/20111206/15/744c70de-49dc-4694-a09e-180438d7f8f0.html
请问,这个关于多线程的代码不懂
http://topic.csdn.net/u/20111208/23/64671dd4-7fdc-4d76-b3b9-1fd18087e6e0.html
最后输出的三句话
C exit...
B exit...
A exit...
顺序不是固定的,而是要结合CPU等系统资源的调配
也有朋友有些疑点,“用lock就不要用monitor了”,“Monitor.Wait完全没必要”,“为什么Pulse和Wait方法必须从同步的代码块内调用?”
这些疑问很自然。在大部分情况下,lock确实能基本达到我们要求资源同步的目的,加上配合其他同步工具,比如事件(AutoResetEvent)等的应用,日常工作中确实没有太多机会需要用到Monitor.Wait和Pulse。不过,虽然较少机会用到,事实上 Wait和Pulse跟lock完全不是一回事。他们提供了更细腻的同步功能,能达到lock作不来的功能。
为更好的回答和解释这些疑问,该帖将首先介绍Wait和Pulse的用途,通过一个简单例子逐条分析同步的过程;然后提供一个用轻量级的lock,Wait和Pulse来实现一个事件通知的实例;最后谈谈DotNet4对lock编译展开的一点有趣变化。
让我们首先看看MSDN对Monitor.Wait的解释(链接见注释):
释放对象上的锁并阻止当前线程,直到它重新获取该锁。...
该解释的确很粗糙,很难理解。让我们来看看它下面的备注:
同步的对象包含若干引用,其中包括对当前拥有锁的线程的引用、对就绪队列的引用和对等待队列的引用。
这个多少还给了点东西,现在我们脑海中想像这么一幅图画:
|- 拥有锁的线程 lockObj -> |- 就绪队列(ready queue) |- 等待队列(wait queue)
当一个线程尝试着lock一个同步对象的时候,该线程就在 就绪队列中排队。一旦没人拥有该同步对象,就绪队列中的线程就可以占有该同步对象。这也是我们平时最经常用的lock方法。
为了其他的同步目的,占有同步对象的线程也可以暂时放弃同步对象,并把自己流放到 等待队列中去。这就是Monitor.Wait。由于该线程放弃了同步对象,其他在就绪队列的排队者就可以进而拥有同步对象。
比起就绪队列来说,在等待队列中排队的线程更像是二等公民:他们不能自动得到同步对象,甚至 不能自动升舱到就绪队列。而Monitor.Pulse的作用就是开一次门,使得一个正在等待队列中的线程升舱到就绪队列;相应的Monitor.PulseAll则打开门放所有等待队列中的线程到就绪队列。
比如下面的程序:
class Program
{
static void Main(string[] args)
{
new Thread(A).Start();
new Thread(B).Start();
new Thread(C).Start();
Console.ReadLine();
}
static object lockObj = new object();
static void A()
{
lock (lockObj) //进入就绪队列
{
Thread.Sleep(1000);
Monitor.Pulse(lockObj);
Monitor.Wait(lockObj); //自我流放到等待队列
}
Console.WriteLine("A exit...");
}
static void B()
{
Thread.Sleep(500);
lock (lockObj) //进入就绪队列
{
Monitor.Pulse(lockObj);
}
Console.WriteLine("B exit...");
}
static void C()
{
Thread.Sleep(800);
lock (lockObj) //进入就绪队列
{
}
Console.WriteLine("C exit...");
}
}
从时间线上来分析:
T 线程A
0 lock( lockObj )
1 {
2 //... 线程B 线程C
3 //... lock( lockObj ) lock( lockObj )
4 //... { {
5 //... //...
6 //... //...
7 Monitor.Pulse //...
8 Monitor.Wait //...
9 //... Monitor.Pulse
10 //... } }
11 }
时间点0,假设线程A先得到了同步对象,它就登记到同步对象lockObj的“拥有者引用”中。
时间点3,线程B和C要求拥有同步对象,他们将在“就绪队列”排队:
|--(拥有锁的线程) A
|
3 lockObj--|--(就绪队列) B,C
|
|--(等待队列)
时间点7,线程A用Pulse发出信号,允许第一个正在"等待队列"中的线程进入到”就绪队列“。但由于就绪队列是空的,什么事也没有发生。
时间点8,线程A用Wait放弃同步对象,并把自己放入"等待队列"。B,C已经在就绪队列中,因此其中的一个得以获得同步对象(假定是B)。B成了同步
对象的拥有者。C现在还是候补委员,可以自动获得空缺。而A则被关在门外,不能自动获得空缺。
|--(拥有锁的线程) B
|
8 lockObj--|--(就绪队列) C
|
|--(等待队列) A
时间点9,线程B用Pulse发出信号开门,第一个被关在门外的A被允许放入到就绪队列,现在C和A都成了候补委员,一旦同步对象空闲,都有机会得它。
|--(拥有锁的线程) B
|
9 lockObj--|--(就绪队列) C,A
|
|--(等待队列)
时间点10,线程B退出Lock区块,同步对象闲置,就绪队列队列中的C或A就可以转正为拥有者(假设C得到了同步对象)。
|--(拥有锁的线程) C
|
10 lockObj--|--(就绪队列) A
|
|--(等待队列)
随后C也退出Lock区块,同步对象闲置,A就重新得到了同步对象,并从Monitor.Wait中返回...
最终的执行结果就是:
B exit...
C exit...
A exit...
由于Monitor.Wait的暂时放弃和Monitor.Pulse的开门机制,我们可以用Monitor来实现更丰富的同步机制,比如一个事件机(ManualResetEvent):
class MyManualEvent
{
private object lockObj = new object();
private bool hasSet = false;
public void Set()
{
lock (lockObj)
{
hasSet = true;
Monitor.PulseAll(lockObj);
}
}
public void WaitOne()
{
lock (lockObj)
{
while (!hasSet)
{
Monitor.Wait(lockObj);
}
}
}
}
class Program
{
static MyManualEvent myManualEvent = new MyManualEvent();
static void Main(string[] args)
{
ThreadPool.QueueUserWorkItem(WorkerThread, "A");
ThreadPool.QueueUserWorkItem(WorkerThread, "B");
Console.WriteLine("Press enter to signal the green light");
Console.ReadLine();
myManualEvent.Set();
ThreadPool.QueueUserWorkItem(WorkerThread, "C");
Console.ReadLine();
}
static void WorkerThread(object state)
{
myManualEvent.WaitOne();
Console.WriteLine("Thread {0} got the green light...", state);
}
}
我们看到了该玩具MyManualEvent实现了类库中的ManulaResetEvent的功能,但却更加的轻便 - 类库的ManulaResetEvent使用了操作系统内核事件机制,负担比较大(不算竞态时间,ManulaResetEvent是微秒级,而lock是几十纳秒级)。
例子的WaitOne中先在lock的保护下判断是否信号绿灯,如果不是则进入等待。因此可以有多个线程(比如例子中的AB)在等待队列中排队。
当调用Set的时候,在lock的保护下信号转绿,并使用PulseAll开门放狗,将所有排在等待队列中的线程放入就绪队列,A或B(比如A)于是可以重新获得同步对象,从Monitor.Wait退出,并随即退出lock区块,WaitOne返回。随后B或A(比如B)重复相同故事,并从WaitOne返回。
线程C在myManualEvent.Set()后才执行,它在WaitOne中确信信号灯早已转绿,于是可以立刻返回并得以执行随后的命令。
该玩具MyManualEvent可以用在需要等待初始化的场合,比如多个工作线程都必须等到初始化完成后,接到OK信号后才能开工。该玩具MyManualEvent比起ManulaResetEvent有很多局限,比如不能跨进程使用,但它演示了通过基本的Monitor命令组合,达到事件机的作用。
现在是回答朋友们的疑问的时候了:
Q: Lock关键字不是有获取锁、释放锁的功能... 为什么还需要执行Pulse?
A: 因为Wait和Pulse另有用途。
Q: 用lock 就不要用monitor了(?)
A: lock只是Monitor.Enter和Monitor.Exit,用Monitor的方法,不仅能用Wait,还可以用带超时的Monitor.Enter重载。
Q: Monitor.Wait完全没必要 (?)
A: Wait和Pulse另有用途。
Q: 什么Pulse和Wait方法必须从同步的代码块内调用?
A: 因为Wait的本意就是“[暂时]释放对象上的锁并阻止当前线程,直到它重新获取该锁”,没有获得就谈不到释放。
我们知道lock实际上一个语法糖糖,C#编译器实际上把他展开为Monitor.Enter和Monitor.Exit,即:
lock(lockObj)
{
//...
}
相当于(.Net4以前):
Monitor.Enter(lockObj);
try
{
//...
}
finally
{
Monitor.Exit(lockObj);
}
但是,这种实现逻辑至少理论上有一个错误:当Monitor.Enter(lockObj);刚刚完成,还没有进入try区的时候,有可能从其他线程发出了Thread.Abort等命令,使得该线程没有机会进入try...finally。也就是说lockObj没有办法得到释放,有可能造成程序死锁。这也是Thread.Abort一般被认为是邪恶的原因之一。
DotNet4开始,增加了Monitor.Enter(object,ref bool)重载。而C#编译器会把lock展开为更安全的Monitor.Enter(object,ref bool)和Monitor.Exit:
lock(lockObj)
{
//...
}
相当于(DotNet 4):
bool lockTaken = false;
try
{
Monitor.Enter(lockObj,ref lockTaken);
//
}
finally
{
if (lockTaken) Monitor.Exit(lockObj);
}
现在Monitor.TryEnter在try的保护下,“加锁”成功意味着“放锁”将得到finally的保护。
注释和引用:
Monitor.Wait 方法
http://msdn.microsoft.com/zh-cn/library/79fkfcw1.aspx
Monitor.TryEnter 方法
http://msdn.microsoft.com/zh-cn/library/dd289679.aspx
请问,多线程Monitor类
http://topic.csdn.net/u/20111206/15/744c70de-49dc-4694-a09e-180438d7f8f0.html
请问,这个关于多线程的代码不懂
http://topic.csdn.net/u/20111208/23/64671dd4-7fdc-4d76-b3b9-1fd18087e6e0.html
另外测试代码:
static object lockObj = new object();
static void A()
{
lock (lockObj) //进入就绪队列
{
//因为A所在的线程Priority高,所以会先进来
Console.WriteLine("Into A lock block");
Thread.Sleep(1000);
//这里调用了Monitor.Pulse(lockObj),会通知lockObj的等待队列
//但现在lockObj的等待队列没有线程在等待,所以通知不被处理
Monitor.Pulse(lockObj);
//自我流放到等待队列
Monitor.Wait(lockObj);//同时此线程在此停止
//线程此时的状态是WaitSleepJoin
//虽然在Monitor.Wait之前有Monitor.Pulse通知等待队列,但是过期无效。
//无论之前有多少个Monitor.Pulse
//本线程在等待队列后接受到Monitor.Pulse的通知时,线程回到就绪队列
//从就绪队列中出来的时候,线程在此恢复
}
Console.WriteLine("A exit...");
}
static void B()
{
lock (lockObj) //进入就绪队列
{
//B和C都在就绪队列,因为B所在的线程Priority比C高,所以会先进来
Console.WriteLine("Into B lock block");
//这里调用了Monitor.Pulse(lockObj),会通知lockObj的等待队列
//于此同时,A 已经在等待队列了
Monitor.Pulse(lockObj);
Console.WriteLine("B Call Pulse");
}
Console.WriteLine("B lock block exit...");
Thread.Sleep(1);
Console.WriteLine("B exit...");
}
static void C()
{
//如果CPU是低负载理想的情况,此时的就绪队列中会有两个线程
//除了C还有接收到信号的A,C排在前面理想情况会先进来 但不是绝对,有时会是A
lock (lockObj) //进入就绪队列
{
Console.WriteLine("Into C lock block");
}
Console.WriteLine("C exit...");
}
static void Main(string[] args)
{
//设置Priority,确保按代码顺序执行
Thread a = new Thread(A);
a.Priority = ThreadPriority.Highest;
a.Start();
Thread b = new Thread(B);
b.Priority = ThreadPriority.Normal;
b.Start();
Thread c = new Thread(C);
c.Priority = ThreadPriority.Lowest;
c.Start();
Console.ReadLine();
}
最后输出的三句话
C exit...
B exit...
A exit...
顺序不是固定的,而是要结合CPU等系统资源的调配