同步基本概念
多个线程同时访问共享资源时,线程同步用于防止数据损坏或发生无法预知的结果。对于仅仅是读取或者多个线程不可能同时接触到数据的情况,则完全不需要进行同步。
线程同步通常是使用同步锁来实现的。通过实现各种各样构造的锁,保证在一个特定的时间内,只有一个或有限个线程进入关键代码段访问资源。当线程进入代码段时,它获得锁,或将信号量减少1,当线程离开时,它释放锁,或将信号量增加1。锁也可以看成是一个信号量。
线程同步既繁琐又容易出错,而且对锁的获取和释放是需要时间的。锁的开销具体要损耗多少时间,取决于选择的锁的种类。锁可以分为自旋锁,互斥锁和混合锁。自旋锁通常由用户模式构造实现,互斥锁则由内核模式构造实现。
如果多个线程同时访问只读数据(例如具有不可变性的数据,如字符串),则是没有任何问题的,不需要进行同步。在使用值类型时,因为它们总是会被复制,所以每个线程操作的都是它自己的副本。线程安全不意味着一定会有锁的出现。
自旋锁,互斥锁和锁的递归调用
自旋锁和互斥锁的区别类似轮询和回调。前者不停请求,后者等待通知。自旋锁与互斥锁类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁还是自旋锁,在任何时刻,最多只能有一个保持者,但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态,等待之后被唤醒。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。由此我们可以看出,自旋锁是一种比较低级的保护数据结构或代码片段的原始方式,这种锁可能存在两个问题:活锁和过多占用cpu资源。
自旋锁比较适用于锁使用者保持锁时间比较短的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的。
互斥锁适用于锁使用者保持锁时间比较长的情况,它们会导致调用者睡眠。
另外格外注意一点:自旋锁不能递归使用。
某些互斥锁例如Mutex支持递归使用。如果一个锁可以递归使用,它需要维护一个整型变量,其意义为,拥有这个锁的线程拥有了它多少次。如果一个线程当前拥有一个递归锁,然后它又在这个锁上等待,那么它再次持有该锁,整型变量的值加一。当它释放锁时,整型变量的值减一,只有整型变量的值为0时,另一个线程才能够获得锁。你完全可以自己写一个支持递归的锁,而不是使用Mutex。
基元构造线程同步
Windows的线程同步方式可分为2种,用户模式构造和内核模式构造。通过C#,我们还可以创造出混合构造,它吸收了上面两种方式的优点,但Windows不具备产生混合构造锁的能力。
内核模式构造是由Windows系统本身使用,内核对象进行调度协助的。内核对象是系统地址空间中的一个内存块,由系统创建维护。内核对象为内核所拥有,而不为进程所拥有,所以不同进程可以访问同一个内核对象(所以内核模式构造的锁可以跨进程同步), 如WaitHandle,信号量,互斥量等都是Windows专门用来帮助我们进行线程同步的内核对象。
用户模式构造是由特殊CPU指令来协调线程,通常用于进行原子操作。volatile实现就是一种,Interlocked也是。用户模式构造的速度要显著快于内核模式的构造,这是因为他们使用了特殊CPU指令来协调线程,协调是在硬件中发生的。
混合构造兼具用户模式和内核模式的特点。
用户模式构造(User Mode Constructs)
用户模式构造使用的是自旋锁。它利用特殊的CPU指令,实现原子操作,所以它的速度远快于内核模式构造。它的缺点是:当一个线程在一个以用户模式构造创建的锁(以及获得锁的线程)上阻塞了,Windows不会知道这个情况的发生(操作系统只知道内核模式构造的锁中发生的事情)。所以,操作系统会认为这个线程正在良好的运行,从而不会将属于它的时间片分给其他线程。
在极端情况下,如果这个被阻塞的线程永远拿不到锁,它将永远自旋下去(轮询锁的状态),从而浪费CPU资源,这种现象称为活锁。活锁既浪费CPU又浪费内存(因为这个悲剧的线程本身也占用一定的内存)。和死锁相比,死锁更好一些,因为它不会浪费CPU。
.NET中为我们提供了两种用户模式构造:
- Thread.VolatileRead 和 Thread.VolatileWrite:易失构造,它在包含一个简单数据类型的变量上执行原子性的读或写操作。
- System.Threading.Interlocked:互锁构造,它在包含一个简单数据类型的变量上执行原子性的读和写操作。
对于易失构造,C#提供了volatile关键字,确保该关键字修饰的字段在读或写时,是原子的,也就是说一次只能有一个线程对其进行读写。当然,也可以通过互锁构造修改字段,此时不需要volatile关键字,只需要调用Interlocked的方法来修改即可,它保证了操作是原子的。Interlocked虽然只提供了Add方法,但是我们也可以实现诸如乘除等其他方式对值进行更改,可以参考CLR via C#的Interlocked Anything模式这一节,这里就略过。
这两种构造仅会读或写一个字段,而这是一个耗时很短的操作。所以对这种情况,使用用户模式构造是合理的,因为阻塞的线程只会自旋很短一段的时间,之后就可以正常工作。使用内核模式反而会过于臃肿,光是维护信号量或Event构造,发送通知的代价就远远大于自旋了。
使用用户模式构造的例子
最常见的例子,便是对整型变量不断累加了。首先是没有使用锁的做法。这种做法得到的结果是不稳定的。
number = 0; Stopwatch sw = Stopwatch.StartNew(); Parallel.For(0, 100000, (i) => { number++; }); Console.WriteLine("Result: " + number); Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);