浅谈.NET下的多线程和并行计算(四)线程同步基础下
回顾一下上次,我们讨论了lock/AutoResetEvent/ManualResetEvent以及Semaphore。这些用于线程同步的结构叫做同步基元。同步基元从类型上可以分为锁定/通知/联锁三种。lock显然锁定方式,而且是独占锁定,也就是在锁释放之前不能由其它线程获得。Semaphore也是一种锁定,只不过不是独占锁,可以指定多少个线程访问代码块。AutoResetEvent和ManualResetEvent当然就是通知方式了,前者在通行之后自动重置,后者需要手动重置。我们还看到了即使使用同步机制不一定能确保线程按照我们规划的去执行,因为从根本上来说,操作系统的线程调度我们是没有办法预测的,除非使用阻塞或锁等待等方式,否则我们很难去预测两个无关的线程究竟哪个先得到执行(即使设置了优先级),而且在使用这些同步机制的时候我们也要考虑到性能问题,如果多线程程序做的不好的话很可能会比单线程执行效率还低,比如我们开启了多个线程相互阻塞等待并没有任何的并行运算,比如在一个多线程环境汇总我们锁的范围很大,导致多线程环境变为了一个单线程环境,有关性能问题以后再讨论,这次我们来看看其它的一些同步基元。
本文的例子基于上文定义的一些基本静态对象:
static int result = 0; static object locker = new object(); static EventWaitHandle are = new AutoResetEvent(false); static EventWaitHandle mre = new ManualResetEvent(false);
使用lock保护共享资源不被多个线程同时修改是常见的做法,其实lock本质上基于Monitor,而使用Monitor本身可以带来更丰富的特性,比如可以设置超过某个等待时间段就不继续等待:
for (int i = 0; i < 10; i++) { new Thread(() => { if (Monitor.TryEnter(locker, 2000)) { Thread.Sleep(1000); Console.WriteLine(DateTime.Now.ToString("mm:ss")); Monitor.Exit(locker); } }).Start(); }
在这段代码中我们开启10个线程尝试申请locker独占锁,通过输出结果可以看出,由于我们设置了2秒超时,程序只输出了三次:
在第一个线程获取锁之后,一秒后释放,第二个线程获取,一秒后又释放,第三个线程最后获取到,之后的线程都超过了2秒等待时间,TryEnter返回false,线程结束。
除了TryEnter之外,Monitor还有一组有用的方法,Wait和Pulse(PulseAll)。一般情况下,我们的线程占据了独占锁之后进行一些线程安全的资源访问,然后退出锁。使用Wait我们可以让当前线程阻塞并且暂时释放锁,一直到有其它线程通知(Pulse)阻塞的(一个或者多个)线程锁状态已改变为止:
for (int i = 0; i < 2; i++) { Thread reader = new Thread(() => { Console.WriteLine(string.Format("reader #{0} started", Thread.CurrentThread.ManagedThreadId)); while (true) { lock (locker) { if (data.Count == 0) { Console.WriteLine(string.Format("#{0} can not get result, wait", Thread.CurrentThread.ManagedThreadId)); Monitor.Wait(locker); Console.WriteLine(string.Format("#{0} get result: {1}", Thread.CurrentThread.ManagedThreadId, data.Dequeue())); } } } }); reader.Start(); } Thread writer = new Thread(() => { Console.WriteLine(string.Format("writer #{0} started", Thread.CurrentThread.ManagedThreadId)); while (true) { lock (locker) { int s = DateTime.Now.Second; Console.WriteLine(string.Format("#{0} set result: {1}", Thread.CurrentThread.ManagedThreadId, s)); data.Enqueue(s); Console.WriteLine("notify thread"); Monitor.Pulse(locker); } Thread.Sleep(1000); } }); writer.Start();
在这里,data定义如下:
static Queue<int> data = new Queue<int>();
输出结果如下:
在这里,我们模拟了两个读取线程和一个写入线程,写入线程每一秒写入当前的秒到队列,读取线程不断从队列读取一个值。读取线程中判断如果队列没值的话就让出独占锁并且阻塞当前线程,然后写入线程拿到了独占锁写入值,并且发出通知,让排队的第一个读取线程得到恢复,由于使用了Pulse()只能通知一个线程,所以可以发现两个读取线程依次有一次机会从队列中读取值。
在本文一开始提到了,同步基元还有一种叫做联锁(互锁)的结构,可以以比较高的性能,对线程共享的变量进行原子操作,它比使用lock来的性能高而且简洁:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { for (int j = 0; j < 500; j++) { Interlocked.Increment(ref result); Thread.Sleep(10); } }); Thread t2 = new Thread(() => { for (int j = 0; j < 500; j++) { Interlocked.Add(ref result, 9); Thread.Sleep(10); } }); t1.Start(); t2.Start(); t1.Join(); t2.Join(); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
运行结果如下:
第一个线程进行了500次累加操作,第二个线程进行了500次加法操作,使得最后result的值为10*500=5000,总共消耗的时间为5秒多一点。
上次我们介绍了AutoResetEvent和ManualResetEvent,WaitHandle提供了两个静态方法WaitAll和WaitAny可以让我们实现等待多个EventWaitHandle都完成或等待其中任意一个完成:
Stopwatch sw = Stopwatch.StartNew(); ManualResetEvent[] wh = new ManualResetEvent[10]; for (int i = 0; i < 10; i++) { wh[i] = new ManualResetEvent(false); new Thread((j) => { int d = ((int)j + 1) * 100; Thread.Sleep(d); Interlocked.Exchange(ref result, d); wh[(int)j].Set(); }).Start(i); } WaitHandle.WaitAny(wh); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
程序输出如下:
在这里我们使用了10个ManualResetEvent关联到10个线程,这些线程的执行时间分别为100毫秒/200毫秒一直到1000毫秒,由于主线程只等其中的一个信号量发出,所以在100毫秒后就输出了结果(但是注意到程序在1秒后完成,因为这些线程默认都是前台线程)。如果我们把WaitAny改为WaitAll的话结果如下:
上文中我们用ManualResetEvent实现过发出信号让多人响应,这次我们实现的是多个信号让单人响应。
在实际应用的多线程环境中,我们通常会有很多的线程来读取缓存中的值,但是只会有1/10甚至1/10000的线程去修改缓存,对于这种情况如果我们使用lock不分读写都对缓存对象进行锁定的话,相当于多线程环境在用缓存的时候变为了但线程,做个实验
(假设我们定义了static List<int> list = new List<int>();):
Stopwatch sw = Stopwatch.StartNew(); ManualResetEvent[] wh = new ManualResetEvent[30]; for (int i = 1; i <= 20; i++) { wh[i - 1] = new ManualResetEvent(false); new Thread((j) => { lock (locker) { var sum = list.Count; Thread.Sleep(100); wh[(int)j].Set(); } }).Start(i - 1); } for (int i = 21; i <= 30; i++) { wh[i - 1] = new ManualResetEvent(false); new Thread((j) => { lock (locker) { list.Add(1); Thread.Sleep(100); wh[(int)j].Set(); } }).Start(i - 1); } WaitHandle.WaitAll(wh); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(list.Count);
输出结果如下:
我们同时开了30个线程,其中20个读10个写,主线程等待它们全部执行完毕后输出时间,可以发现这30个线程用了3秒,写线程用了10秒独占锁可以理解,但是读线程并没有任何并发。.NET提供了现成的读写锁ReaderWriterLockSlim类型使得读操作可以并发
(假设我们定义了static ReaderWriterLockSlim rw = new ReaderWriterLockSlim();):
Stopwatch sw = Stopwatch.StartNew(); ManualResetEvent[] wh = new ManualResetEvent[30]; for (int i = 1; i <= 20; i++) { wh[i - 1] = new ManualResetEvent(false); new Thread((j) => { rw.EnterReadLock(); var sum = list.Count; Thread.Sleep(100); wh[(int)j].Set(); rw.ExitReadLock(); }).Start(i - 1); } for (int i = 21; i <= 30; i++) { wh[i - 1] = new ManualResetEvent(false); new Thread((j) => { rw.EnterWriteLock(); list.Add(1); Thread.Sleep(100); wh[(int)j].Set(); rw.ExitWriteLock(); }).Start(i - 1); } WaitHandle.WaitAll(wh); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(list.Count);
输出结果就像我们想的一样完美:
读线程并没有过多等待基本都并发访问。
最后,我们来介绍一下一种比较方便的实现线程同步方法的办法:
[MethodImpl(MethodImplOptions.Synchronized)] static void M() { Thread.Sleep(1000); Console.WriteLine(DateTime.Now.ToString("mm:ss")); } [MethodImpl(MethodImplOptions.Synchronized)] void N() { Thread.Sleep(1000); Console.WriteLine(DateTime.Now.ToString("mm:ss")); }
M方法是静态方法,N方法是实例方法,我们都为它们应用了MethodImpl特性并且指示它们是同步方法(只能被一个线程同时访问)。然后我们写如下代码验证:
for (int i = 0; i < 10; i++) { new Thread(() => { M(); }).Start(); }
程序输出结果如下:
可以发现,虽然有10个线程同时访问M方法,但是每次只能有一个线程执行。
再来测试一下N方法:
Program p = new Program(); for (int i = 0; i < 10; i++) { new Thread(() => { p.N(); }).Start(); }
程序输出结果和上次一样:
但是我们要注意的是本质上,为M静态方法标注同步是对类进行加锁,而为N实例方法标注同步是对类的实例进行加锁,也就是说如果我们每次都用新的实例来调用N的话不能实现同步:
for (int i = 0; i < 10; i++) { new Thread(() => { new Program().N(); }).Start(); }
结果如下:
通过这两篇文章我们基本介绍了线程同步的一些方法和结构,可以发现我们的手段很多,但是要随心所欲让各种线程同时执行然后汇总数据然后通知其它线程继续计算然后再汇总数据等等并不是很简单。弄的不好让多线程变成但线程弄的不好也可能让数据变脏。其实在.NET 4.0中有一些新特性简化这些行为甚至编写多线程程序都不需要知道这些同步基元,但是这些基础了解一下还是有好处的。