ManualResetEvent的简单使用
有什么不明白的地方,扫描右方二维码加我微信交流。
ManualResetEvent类是一个比较常用的线程间通信工具,可以手动阻塞线程,所以在线程同步方面非常有用。
主要使用3个方法:
- WaitOne
- Set
- Reset
先引入一个概念,信号。因为官方解释中使用的是signaled,所以在这里翻译为信号。那么信号是什么,接着往下看。
WaitOne
调用WaitOne时会导致线程阻塞,但此时是否阻塞是有条件的,要看此时是否有信号。有信号就不阻塞,无信号就阻塞。那么是否有信号怎么判断?不用判断,这个我们不用关心,我们需要做的是控制什么时候有信号,什么时候无信号。
Set
调用Set方法,此时为有信号状态。
Reset
调用Reset方法,此时为无信号状态。
拿生产者和消费者都举例:
生产者线程:我又造出10个产品,快来消费吧!(调用Set接口,切换为有信号状态,通知消费者线程可以开始消费了)。
消费者线程:收到通知,消费这10个产品,消费完成后,又处于等待状态。(调用Reset接口,切换为无信号状态,再调用WaitOne接口,阻塞消费线程)
下面上代码:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace ThreadTest { class Program { static void Main(string[] args) { new ProductAndCostTester(); } } /// <summary> /// 生产消费模型 /// </summary> public class ProductAndCostTester { /// <summary> /// 生产线1线程 /// </summary> private Thread _producterThread1; /// <summary> /// 生产线2线程 /// </summary> private Thread _producterThread2; /// <summary> /// 消费线线程 /// </summary> private Thread _costerThread; /// <summary> /// 产品列表 /// </summary> private List<int> _goodList; /// <summary> /// ManualResetEvent实例 /// </summary> private ManualResetEvent _mre; public ProductAndCostTester() { _goodList = new List<int>(); _mre = new ManualResetEvent(false);//false初始化状态为无信号,将使WaitOne阻塞 _producterThread1 = new Thread(Product1); _producterThread1.Name = "Productor1"; _producterThread1.Start(); _producterThread2 = new Thread(Product2); _producterThread2.Name = "Productor2"; _producterThread2.Start(); _costerThread = new Thread(Cost); _costerThread.Name = "Costor"; _costerThread.Start(); } /// <summary> /// 生产线1 /// </summary> void Product1() { while (true) { Console.WriteLine(Thread.CurrentThread.Name + ":" + DateTime.Now.ToString("HH:mm:ss")); for (int i = 0; i < 3; i++) { _goodList.Add(1); } _mre.Set();//表示有信号了,通知WaitOne不再阻塞 Thread.Sleep(8000); } } /// <summary> /// 生产线2 /// </summary> void Product2() { while (true) { Console.WriteLine(Thread.CurrentThread.Name + ":" + DateTime.Now.ToString("HH:mm:ss")); for (int i = 0; i < 6; i++) { _goodList.Add(1); } _mre.Set();//表示有信号了,通知WaitOne不再阻塞 Thread.Sleep(10000); } } /// <summary> /// 消费线 /// </summary> void Cost() { while (true) { if (_goodList.Count > 0) { Console.WriteLine("Cost " + _goodList.Count + " at " + DateTime.Now.ToString("HH:mm:ss")); _goodList.Clear(); _mre.Reset();//重置为无信号了,使WaitOne可以再次阻塞 } else { Console.WriteLine("No cost at " + DateTime.Now.ToString("HH:mm:ss")); _mre.WaitOne();//如果没有可消费的产品,即无信号,则会阻塞 } } } } }
以上代码可以直接拷贝到脚本中执行,亲测。
与ManualResetEvent
类似的还有AutoResetEvent
,ManualResetEventSlim
等等。AutoResetEvent
与ManualResetEvent
都集成自EventWaitHandle
,二者原理相同,用法不同,AutoResetEvent
直接调用WaitOne
阻塞,无需调用Reset
。下面着重说下ManualResetEventSlim
与ManualResetEvent
的区别。
ManualResetEvent
:调用Reset
,WaitOne
后,会阻塞线程,阻塞的实现是release线程,线程此时不消耗任何资源,适合长时间等待的线程阻塞。
ManualResetEventSlim
:调用Reset
,Wait
后,会阻塞线程,阻塞的实现是让线程跑一个for循环,for循环结束后再使用lock,lock住线程,此时线程不会释放,但会占用一定的CPU,适合短时间的线程阻塞。
如何理解长时间,短时间?看下ManualResetEventSlim
类里Wait
的实现:代码太长了,点击链接自行查看。
SpinCount
可以在创建ManualResetEventSlim
实例时设置,默认为10,最大为2047(代码中没有明确说明,自测出结果),在for循环中调用了Thread.Yield()
和Thread.SpinWait(4 << i)
,还有Thread.Sleep(0)
,Thread.Sleep(1)
。
方法耗时:
(机器配置不同,耗时可能不同,但效果是一样的,我的配置为2 GHz 四核Intel Core i5,16 GB,512SSD)
Thread.Yeild
:0.1ms/个;
Thread.SpinWait(3960)
:0.2242ms(SpinCount超过10时,SpinWait方法共消耗时间);
Thread.Sleep(0)
:0.14ms;
Thread.Sleep(1)
:1.38ms;
Thread.Yeild
该方法是在 .Net 4.0 中推出的新方法,它对应的底层方法是 SwitchToThread
。Yield 的中文翻译为 “放弃”,这里意思是主动放弃当前线程的时间片,并让操作系统调度其它就绪态的线程使用一个时间片。但是如果调用 Yield,只是把当前线程放入到就绪队列中,而不是阻塞队列。如果没有找到其它就绪态的线程,则当前线程继续运行。
优势:比 Thread.Sleep(0)
速度要快,可以让低于当前优先级的线程得以运行。可以通过返回值判断是否成功调度了其它线程。
劣势:只能调度同一个处理器的线程,不能调度其它处理器的线程。当没有其它就绪的线程,会一直占用 CPU时间片,造成 CPU 100%占用率。
Thread.SpinWait(int n)
执行等待时,会进行自旋。所谓自旋就是在CPU运转的周期内,如果条件满足了,就不会再进入内核等待(即暂停该线程,等待一段时间后,再继续运行该线程),如果条件不满足,才进入内核等待。这样一来,SpinWait会比Thread.Sleep
多运行一次的CPU周期,再进入等待。因为CPU周期是很短的(现在一般的电脑都有2.1GHZ以上),所以这个等待对时间影响不大,却可以提升很大的性能。
Thread.Sleep(0)
Sleep 的意思是告诉操作系统自己要休息 n 毫秒,这段时间就让给另一个就绪的线程吧。当 n=0 的时候,意思是要放弃自己剩下的时间片,但是仍然是就绪状态,其实意思和 Yield
有点类似。但是 Sleep(0)
只允许那些优先级相等或更高的线程使用当前的CPU,其它线程只能等着挨饿了。如果没有合适的线程,那当前线程会重新使用 CPU时间片。
优势:相比 Yield
,可以调度任何处理器的线程使用时间片。
劣势:只能调度优先级相等或更高的线程,意味着优先级低的线程很难获得时间片,很可能永远都调用不到。当没有符合条件的线程,会一直占用 CPU时间片,造成 CPU100%占用率。
Thread.Sleep(1)
该方法使用 1 作为参数,这会强制当前线程放弃剩下的时间片,并休息 1 毫秒(因为不是实时操作系统,时间无法保证精确,一般可能会滞后几毫秒或一个时间片)。但因此的好处是,所有其它就绪状态的线程都有机会竞争时间片,而不用在乎优先级。
优势:可以调度任何处理器的线程使用时间片。无论有没有符合的线程,都会放弃 CPU时间,因此 CPU占用率较低。
劣势:相比 Thread.Sleep(0)
,因为至少会休息一定时间,所以速度要更慢。
SpinCount,0-9,调用Thread.Yield()
和Thread.SpinWait(4 << i)
;循环下来,4<<1+4<<2+4<<3+4<<4+4<<6+4<<7+4<<8+4<<9=3960,一共SpinWait了8次,共3960,Thread.SpinWait
是多长时间?我在电脑上测试,时间大概为0.0002242s,即0.2242ms,外加2个Thread.Yield()
,共耗时:0.2242+2*0.1=0.4242ms。
SpinCount,10-19,调用8Thread.Yield()
和2个Thread.Sleep(0)
,共耗时:8*0.1+2*0.14=1.04ms。
SpinCount,20-2047,调用Thread.Yield()
、Thread.Sleep(0)
和Thread.Sleep(1)
;每20个数里,调用1次Thread.Sleep(1)
,3次Thread.Sleep(0)
,16次Thread.Yield()
,粗略算下101个循环,共耗时:(1.38+3*0.14+16*0.1)*101=340ms
如果SpinCount为2047,则总耗时为:总耗时:0.4242+1.04+340=341.4642ms。
这期间,进行了大量的Thread.Yield()
、Thread.Sleep(0)
和Thread.Sleep(1)
3个方法调用,不可能没有消耗,所以当时间超过500ms甚至超过300ms时,我觉得使用ManualResetEventSlim
就不再合适了。
由此得出结论,短时间就是很短的时间,可以忽略的时间,但凡等待超过1s,那都是很长的时间了。