C#中的线程同步
同步的本质:下面的列表总结了.NET同步线程的工具:
阻塞函数:
- Sleep:阻塞线程一定时间。
- Join:阻塞另一个线程至本线程完成。
加锁结构:
- lock:保证只有一个线程可以存取同一个资源,或操作一段代码。不能跨进程。速度快。
- Mutex:保证只有一个线程可以存取同一个资源,或操作一段代码。可以用来阻止一个程序启动多个线程。可以跨进程,速度一般。
- Semaphore:保证不超过某个数量的线程可以存取同一个资源,或操作一段代码。可以跨进程,速度一般。
信号结构:
- EventWaitHandle:允许一个线程等待,直到收到另一个线程的信号为止。可以跨进程,速度一般。
- Wait 和 Pulse:允许一个线程等待,直到遇到一个自定义阻塞情况。不能跨进程。速度一般。
非阻塞的同步结构:
- Interlocked:执行简单的非阻塞原子操作。可以跨进程,速度非常快。
- volatile:为了允许安全的非阻塞操作在锁外部存取单独的字段。可以跨进程,速度非常快。
阻塞:
当用上面提到的方式让线程暂停时,叫做让线程阻塞。一旦线程阻塞了,一个线程立即让出自己从CPU分配的时间,将WaitSleepJoin加入ThreadState属性,直到不再阻塞为止。阻塞的解除可能是以下4种方式之一(不算按电源键):
- 满足了阻塞条件
- 超时
- 被Thread.Interrupt打断
- 被Thread.Abort终止
一个通过Suspend函数暂停的线程不会被阻塞。
休眠和旋转:
调用Thread.Sleep在给定时间内阻塞当前线程(或直到被打断):
static void Main() { Thread.Sleep (0); // relinquish CPU time-slice Thread.Sleep (1000); // sleep for 1000 milliseconds Thread.Sleep (TimeSpan.FromHours (1)); // sleep for 1 hour Thread.Sleep (Timeout.Infinite); // sleep until interrupted }
更精确地说,Thread.Sleep将控制权归还给CPU。Thread.Sleep(0)意思是把自己的时间片都让给其他活跃线程执行。Thread类有一个SpinWait函数,这个函数不放弃任何CPU时间,而是让CPU地在给定次数的"无效地忙碌"中循环。50个迭代大概等于暂停一微秒左右。从技术上来说,SpinWait不是一个阻塞的方式:一个旋转-等待的线程不再有WaitSleepJoin的线程状态,且不能被其他线程打断。
SpinWait很少用,它主要的目的是等待一个能快速准备好的资源(1微秒)而不调用Sleep和CPU切换线程。虽然这个技术只能用在多核机器上,因为在单核机器上,没有机会在线程间旋转其时间片。而且SpinWait本身也很浪费CPU时间。
阻塞和旋转:
一个线程可以通过显式地轮询来等待:比如:while (!proceed);或while (DateTime.Now < nextStartTime);这种操作非常浪费CPU时间,线程在这种情况下不算阻塞,不像线程等待EventWaitHandle。变量有时在阻塞和旋转间混合:
while (!proceed) Thread.Sleep (x); // "Spin-Sleeping!"
x越大,CPU用的越多,这样会增加延迟,但除了延迟,这种组合旋转和休眠的方式运行的很好。
Join一个线程:
你可以用Join阻塞一个线程,直到另一个线程结束:
class JoinDemo { static void Main() { Thread t = new Thread (delegate() { Console.ReadLine(); }); t.Start(); t.Join(); // Wait until thread t finishes Console.WriteLine ("Thread t's ReadLine complete!"); } }
Join函数接收一个超时函数,毫秒单位,或一个TimeSpan,如果超时了则返回false,带超时的Joint很像Sleep,其实下面两行代码是几乎 一样的:
Thread.Sleep (1000); Thread.CurrentThread.Join (1000);
语义上的区别在于Join的意思是保持消息泵在阻塞时仍然活跃。Sleep暂停消息泵。
锁和线程安全:
只有一个线程可以锁住同步对象,如果有多个线程的话,那么其他线程会处在排队状态,并且是先到先得的原则。C#的lock其实相当于是Moniter.Enter和Moniter.Leave在try/catch语句里的简化。
class ThreadSafe { static object locker = new object(); static int val1, val2; static void Go() { lock (locker) { if (val2 != 0) Console.WriteLine (val1 / val2); val2 = 0; } } }
相当于
try { Monitor.Enter (locker); if (val2 != 0) Console.WriteLine (val1 / val2); val2 = 0; } finally { Monitor.Exit (locker); }
调用Monitor.Exit之前没有调用Monitor.Enter会抛出异常。Moniter还有一个TryEnter的函数可以指定一个超时时间,等当前线程超时后进入临界区。
选择一下同步对象:同步对象必须是引用类型,而不管你具体是什么引用类型。
嵌套锁:线程可以重复地锁同一个对象,可以通过Moniter.Enter和Moniter.Leave也可以通过lock。
static object x = new object(); static void Main() { lock (x) { Console.WriteLine ("I have the lock"); Nest(); Console.WriteLine ("I still have the lock"); } Here the lock is released. } static void Nest() { lock (x) { ... } Released the lock? Not quite! }
什么时候加锁:一个基本原则是只要存在多线程操作一个字段就应该加锁,哪怕是一个简单的赋值运算。
性能考上的考虑:加锁操作本身是很快的,也就几纳秒的事,但是线程间等待的时间可能很长。但如果使用不当会引起:
- 并发枯竭,当加锁区域的代码太长,引起其他线程等的太久时可能发生。
- 死锁,当两个线程同时等待对方时发生,但都不能继续执行时。通常是由于同步对象太多造成的。
- 锁竞争,当两个线程都有可能先获得一个锁,程序可能会因为错误的线程获得了锁而挂掉。
线程安全:线程安全的代码是在多线程的场景下没有不确定性,主要是用锁,减少线程间的交互来达到目的。
一个函数在任何场合下都线程安全称之为:可重入的。一般目的的类型很少是线程安全的,因为:
- 保证线程完全安全的开发负担很重,因为一个类一般有很多个字段。
- 线程安全需要付出性能成本。
- 线程安全的函数不一定需要以线程安全的方式使用。
线程安全是因为通常在多线程场景下开发的需要。有一些方法是用大得复杂的类来达到线程安全的目的。
一种方法是把大段的代码都包含在一个排它锁里面。
另一种方法是通过减少共享数据来达到减少线程间的交互,这种方法在无状态的中间件和网页服务器上是表现很好的,因为多个客户端请求的可能同时到达,每个请求都在一个单独的线程内(像ASP.NET,WEBSERVICE等),那么这些请求必须保证线程安全。无状态的设计也限制了线程间交互的可能性,因为类不可能在每个请求中保存数据。线程间的交互只限于静态字段,比如用来用户共用的内存数据,和提供身份验证服务。
.NET类型的线程安全:.NET的所有基元类型几乎都不是线程安全的。可以通过lock把线程不安全的代码变成线程安全的。
class ThreadSafe { static List <string> list = new List <string>(); static void Main() { new Thread (AddItems).Start(); new Thread (AddItems).Start(); } static void AddItems() { for (int i = 0; i < 100; i++) lock (list) list.Add ("Item " + list.Count); string[] items; lock (list) items = list.ToArray(); foreach (string s in items) Console.WriteLine (s); } }
在这里我们锁住了list本身,这是可以的,如果有两个互相交互的list,我们就应该锁一个通用的对象了。
在这里枚举list并转换成Array也不是线程安全的,任何可能对list做修改的操作都不是线程安全的。
静态成员在.NET里是线程安全的,而实例成员不是。
线程的Interrupt和Abort:可以通过Thread.Interrupt和Thread.Abort来中断和中止一个线程,但只能活动线程来操作,在等待的线程是无能为力的。
调用Interrupt来强制释放一个线程,抛出一个线程中断异常:
class Program { static void Main() { Thread t = new Thread (delegate() { try { Thread.Sleep (Timeout.Infinite); } catch (ThreadInterruptedException) { Console.Write ("Forcibly "); } Console.WriteLine ("Woken!"); }); t.Start(); t.Interrupt(); } }
输出:Forcibly Woken!
中断操作一个线程只会让这个线程从当前时间片释放等待进入下一个时间片,但不会终止。除非不处理ThreadInterruptedException异常。
如果在没有加锁的线程上调用Interrupt,线程会继续执行直到时间片结束。
随便中断一个线程是危险的,因为.NET或者第三方函数在调用栈里可能意外中断,所以你需要的是在锁上等待或同步资源。如果函数不是设计的可以中断的话,可能会引起不安全的代码以及资源不完全释放。除非你明确的知道线程的全部细节。
可以用Thread.Abort强制释放一个线程的时间片,效果和Interrupt差不多,只是这里需要处理的是ThreadAbortException异常,这个异常会在catch块的尾部被再次抛出,除非在catch里执行Thread.ResetAbort. 线程的状态变成了AbortRequested。
Interrupt和Abort最大的不同是在线程非阻塞的情况下调用后产生的后果,Interrupt会在下个时间片到来之前继续执行,而Abort会抛出一个异常