其实,如果线程都是很独立的,不涉及到任何资源访问的,那么这些毫无干扰的线程不会产生什么问题。但是在实际应用中我们的线程总是涉及到资源访问的,而且往往涉及到共享资源的访问,那么就产生了线程同步的问题。一直觉得线程同步这个名词很奇怪,字面上看同步就是使得步调一致,线程同步是不是就是让线程步调一致的访问资源呢?事实上反了,线程同步恰巧是让线程不同时去访问资源而是去按照我们期望的顺序依次访问资源(是同步资源访问的行为而不是同步同时访问资源)。一句话,多个线程(不仅仅局限于相同进程)如果需要访问相同的可变资源的话就可能需要考虑到线程同步的手段。还有两个常见的名词是线程安全和线程冲突,所谓线程冲突就是由于多线程访问共享资源带来的问题,某个操作是线程安全就是表明这个操作没有线程冲突问题,要达到线程安全就要用线程同步的手段来解决。在MSDN类库中可以看到方法都注明了是不是线程安全的,如果不是那么我们在多线程程序总使用这个方法的话就要考虑是否要线程同步了。
既然要让线程的步调一致,那么我们首先可以想到的是,如果一个线程没有完成我们就等,一直等到它完成:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; }); t1.Start(); Thread.Sleep(500); while (t1.IsAlive) ; Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
假设线程在完成后会把结果写入result这么一个静态的变量,主线程在启动了新线程之后只花了500毫秒就做好了自己的事情,接下去一定要等待线程计算完成之后才能进行后续的操作,这个时候我们通过不断询问线程是不是还存在来得知线程是不是完成了计算,500毫秒后返回结果:
由于我们主线程是采用循环来等待的,对于处理器来说可不是等待,它足足运算了500毫秒,也就是浪费了500毫秒处理器资源。其实,我们可以在循环中让线程休眠一小段时间,让出处理器时间片,然后再去轮询检查,这样虽然不能在线程一完成后就得到结果,但是却节省了处理器资源:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; }); t1.Start(); Thread.Sleep(500); while (t1.IsAlive) Thread.Sleep(500); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
结果如下:
除了轮询傻等还有一种方式就是使用Join来阻塞主线程一直到次线程的计算任务完成:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; }); t1.Start(); Thread.Sleep(500); t1.Join(); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
结果如下:
通过刚才两个例子我们再次回顾了Sleep和Join的用法,现在我们来看看采用锁机制进行同步的方法:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { lock (locker) { Thread.Sleep(1000); result = 100; } }); t1.Start(); Thread.Sleep(100); lock (locker) { Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result); }
我想即使是ASP.NET应用,我们都经常会需要用到lock来锁定资源。IIS本身是一个多线程环境,如果在我们的应用程序中使用了全局的缓存,对于一个网站程序来说非常有可能在同一时间会有多个请求去修改这个缓存,在这个时候我们通常就会采用lock来锁定一个引用类型的对象。这个对象代表了锁的范围,同一时间只有一个进程能获取到这个锁对象,也就是其中包含的代码只能由一个线程同时执行。上面的例子中我们在开启线程后让主线程等待了一小段时间,然后对象就被锁定了,这个过程持续一秒,然后主线程在一秒后才能再次获得这个locker并输出结果:
在这个例子中我们的locker定义如下:
static object locker = new object();
有关lock之后会有更详细的讨论。之前我们看了等待和锁定的同步方法,不管怎么说这是一种被动的方法,是不是能让线程在完成之后主动通知其它等待的线程呢?答案是肯定的:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; are.Set(); }); t1.Start(); are.WaitOne(); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
来看看这样一段代码,暂缺不管are是什么对象,但是看这个逻辑可以理解到,主线程在启动了新线程后就开始等待了,然后线程在一秒后完成了结果之后,调用了Set(),这句话的意思就是说我完成了,所有等我的人可以继续了,are对象定义如下:
static EventWaitHandle are = new AutoResetEvent(false);
AutoResetEvent从名字上可以看出它是一个自动Reset的事件通知方式。我举一个例子,您可能一下子就明白了,我们可以把它比作地铁的闸机,插入了票,闸机放行一个人。AutoResetEvent的构造方法中传入的false代表,这个闸机默认是关闭的,只有调用了一次Set之后才能通过,如果您把它改为true的话可以看到主线程根本没有等待,直接输出了结果。所谓的Reset就是闸机关闭的操作,也就是说对于AutoResetEvent,一个人用一个票过去之后闸机就关闭了,除非还有后面的人插入了票(Set)。这个程序的运行结果如下:
和AutoResetEvent对应的还有一个ManualResetEvent
static EventWaitHandle mre = new ManualResetEvent(false);
它们唯一的区别就是这个闸机在插入一张票之后还是可以通行,除非有人手动去设置了一下Reset来关闭闸机,我们来看一个简单的例子:
Stopwatch sw = Stopwatch.StartNew(); Thread t1 = new Thread(() => { Thread.Sleep(1000); result = 100; mre.Set(); }); Thread t2 = new Thread(() => { mre.WaitOne(); Console.WriteLine("get result" + result + " and write to file"); }); t1.Start(); t2.Start(); mre.WaitOne(); Console.WriteLine(sw.ElapsedMilliseconds); Console.WriteLine(result);
比如说我们开一个线程来计算,不但主线程等待这个结果来显示给用户,另外一个线程也等待这个结果来写入日志文件,这样我们就可以利用ManualResetEvent的特性,由于t2或主线程在通过闸机之后并不会导致它自动关闭,所以两者都可以通过,输出结果如下:
前面说过,AutoResetEvent像一个地铁闸机,一票一人,那么我们来看一个更形象的例子:
are.Set(); for (int i = 0; i < 10; i++) { new Thread(() => { are.WaitOne(); Console.WriteLine(DateTime.Now.ToString("mm:ss")); Thread.Sleep(1000); are.Set(); }).Start(); }
我们先调用了AutoResetEvent的Set方法来让闸机默认是可以通行的。然后开10个线程,排队等待进入,如果能进入了,花一秒的时间来通行然后关闭闸机,程序输出结果如下:
一秒过一个人,就像我们预期的那样。ManualResetEvent在闸机过人之后不会自动关闭,这就相当于很多旅游景点的团队检票方式,导游把100张团队票交给检票人员,检票人员数了人数之后一批放行,然后关闭闸机(散客的话就用AutoResetEvent咯),想一下我们怎么写程序来模拟这种批量进入闸机的方式:
首先要使用一个变量来统计进入的人数,达到一定人数之后放行:
static int group = 0;
然后我们来分析一下这段程序:
for (int i = 0; i < 10; i++) { new Thread(() => { lock (locker) { Thread.Sleep(1000); // 一秒来一个人 group++; if (group == 5) // 好了,旅游团5人到齐 { mre.Set(); // 检票员说你们进去吧 are.Set(); // 检票员打开闸机 group = 0; } } mre.WaitOne(); // 大家等旅游团到齐(等检票员数人数) Console.WriteLine(DateTime.Now.ToString("mm:ss")); // 进去了,团体进入时间 Thread.Sleep(100); // 让大家都走完 mre.Reset(); // 进去之后检票员停止放行 are.WaitOne(); // 第二波人等闸机再次打开 }).Start(); }
这个例子使用了ManualResetEvent和AutoResetEvent模拟这种团体检票的情景,不过这里有一个很有趣的问题,不知您有没有想过,在mre.Reset()之前我们让线程休眠了100毫秒,目的是为了让阻塞的线程都得到恢复然后输出当前的时间。如果我们注释这句话的话就发现很有可能在5个线程没有全部恢复的情况下就有某一个线程先调用了mre.Reset()导致闸机关闭,那么这个情况就很难预测了。这相当于在同步线程时出现的线程同步问题。其实.NET内置了另外一个结构来实现这种操作:
static Semaphore s = new Semaphore(2, 2);
for (int i = 0; i < 10; i++) { new Thread(() => { s.WaitOne(); Thread.Sleep(1000); Console.WriteLine(DateTime.Now.ToString("mm:ss")); s.Release(); }).Start(); }
结果如下:
本节我们走马观花看了很多同步结构,下节继续来讨论同步的一些基础结构。