其实,在开发过程中,无论是用户模式的同步构造还是内核模式,都应该尽量避免。因为线程同步都会造成阻塞,这就影响了我们的并发量,也影响整个应用的效率。不过有些情况,我们不得不进行线程同步。
内核模式
window提供了几个内核模式构造来同步线程。内核模式的构造比用户模式构造慢得多,一个原因是他们要求windows操作系统自身的配合,另一个原因是在内核对象上调用的每个方法都造成调用线程从托管代码转为本机用户模式代码,再转换为本机内核模式代码。然后,还要朝相反的方向一路返回。这些转换需要大量cpu时间。
但内核模式IDE构造剧本基元用户模式构造所不具备的优点。
1 内核模式的构造检测到一个资源上的竞争时,windows会阻塞输掉的线程,使他不占着一个cpu自旋。
2 内核模式的构造可实现本机和托管线程相互之间的同步
3 内核模式的构造可同步在同一台机器的不同进程中运行的线程。
4 内核模式的构造可应用安全性设置,防止未经授权的账户访问他们。
5 线程可一直阻塞,知道集合中所有内核模式构造都可用,或知道集合中的任何内核模式构造可用。
6 在内核模式的构造上阻塞的线程可指定超时值;指定时间内访问不到希望的资源,线程接可以解除阻塞并执行其他任务
时间和信号量是两种基元内核模式线程同步构造。至于其他内核模式构造,比如互斥体,则是在这两个基元构造上构建的。
Thread命名空间提供了一个名为WaitHandle抽象基类,他的唯一作用就是包装一个windows内核对象句柄,他的派生类层次如下
在一个内核模式的构造上调用的每个方法都代表一个完整的内存栅栏(调用这个方法之前的任何变量写入都必须在这个方法调用之前发生;而这个调用之后的任何变量读取在这个调用之后发生)。
这些方法有几点需要注意:
1 可以调用waithandle的waitOne方法让调用线程等待底层内核对象收到信号。如果对象收到信号,返回的bool是true,超时返回的false。
2 可以调用waithandle的静态方法WaitAll,让调用线程等待waithandle[]中指定的所有内核对象都收到信号。如果所有对象都收到信号,返回true;超时返回false
3 可以调用waithandle的静态方法WaitAny方法让调用线程等待waithandle[]中任意的内核对象收到信号。返回的int是与收到信号的内核对象对应的数组元素索引;如果在等待期间没有对象收到信号,则返回waithandle.waitTimeOut(258)。
4 在传给waitAny和waitAll方法的数组中,包含的元素数不能超过64个,否则方法会抛出错误。
5 可以调用WaitHandle的Dispose方法来关闭底层内核对象句柄。这个方法在内部调用win32 CloseHandle函数。不过这个功能并不建议使用,你要确定没有别的线程要使用内核对象才能显示调用,而且这也会对性能造成影响。应该让垃圾回收期(GC)去完成清理工作。
不接受超时参数的那些版本方法返回的都是void,因为除了true,他们什么都不会返回。
内核模式构造的一个场景用途是创建在任何时刻只允许它的一个实例运行的应用程序。这种单实例应用程序的例子包括office Outlook,windows media player等。下面展示如何实现一个单实例应用:
static void Main(string[] args) { Boolean createdNew; using (new Semaphore(0,1,"SomeUniqueStringIdentifyingMyApp",out createdNew)) { if (createdNew) { //这个线程创建了内核对象,所以肯定灭有这个应用程序的其他实例正在运行。在这里运行应用程序的其余部分 Console.WriteLine(11); } else { //这个线程打开了一个具有相同字符串名称的、现有的内核对象;表明肯定正在运行这个应用程序的另一个实例。这里没什么可以做的事情,所以main返回。终止应用程序 } } Console.WriteLine("Hello World!"); }
上述代码使用的是Semaphore,但换成EventWaitHandle或Mutex一样可以,因为我并没有真正使用对象提供的线程同步行为,但我利用了再创建任何种类的内核对象时由windows内核提供的一些线程同步行为。
假定这个进程的两个实例同时启动,两个线程都尝试创建具有相同字符串名称的semaphore。windows内核确保只有一个线程实际地创建具有指定名称的内核对象;创建对象的线程会将它的createdNew变量设为true。
Event构造
事件(event)其实只是由内核维护的boolean变量。事件为false,在事件上等待的线程就会阻塞:事件为true,就解除阻塞。有两种时间,即自动重置事件和手动重置事件。当一个自动重置事件为true时,它只唤醒一个阻塞线程,因为在解除第一个线程的阻塞后,内核将将时间自动重置回false。而当手动重置事件为true时,它解除正在等待它的所有线程的阻塞。
可用自动重置事件轻松创建线程同步锁。他的行为和前面展示的simpleSpinlock类相似。
EventWaitHandle是System.Threading命名空间下的 internal sealed class SimpleWaitLock:IDisposable { private readonly AutoResetEvent m_available; public SimpleWaitLock() { m_available = new AutoResetEvent(true);//最开始可自由使用 } public void Enter() { //在内核中阻塞,直到资源可用 m_available.WaitOne(); } public void Leave() { //让另一个线程访问资源 m_available.Set(); } public void Dispose() { m_available.Dispose(); } }
可采取和使用simpleSpinLock时完全一样的方式使用这个simpleWaitLock。事实上,外部行为是完全相同的;不过,两个锁的性能截然不同。锁上面没有竞争的时候,waitlock比spinlock慢得多,因为对enter和leave方法的每一台词调用都强迫调用线程从托管代码转换为内核代码,再转换回来。但是存在竞争的时候,输掉的线程会被内核阻塞,而不会自旋,这是simpleSpinLock的优势。
class Program { static void Main(string[] args) { Int32 x = 0; const Int32 iterations = 10000000; Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < iterations; i++) { x++; } Console.WriteLine("incrementing x:{0:N0}", sw.ElapsedMilliseconds); //x递增1000万次,要花多长时间 sw.Restart(); for (int i = 0; i < iterations; i++) { M();x++; M(); } Console.WriteLine("incrementing x in M:{0:N0}", sw.ElapsedMilliseconds); //x递增1000万次,加上调用一个无精症的spinLock SpinLock s1 = new SpinLock(false); sw.Restart(); bool taken; for (int i = 0; i < iterations; i++) { //这里每一次调用,都会把ref taken改为true,所以每次都要重新复制 taken = false; s1.Enter(ref taken);x++;s1.Exit(); } Console.WriteLine("incrementing x in SpinLock:{0}", sw.ElapsedMilliseconds); using (SimpleWaitLock sw1 = new SimpleWaitLock()) { sw.Restart(); for (int i = 0; i < iterations; i++) { sw1.Enter(); x++; sw1.Leave(); } } Console.WriteLine("incrementing x in SimpleWaitLock:{0}", sw.ElapsedMilliseconds); Console.WriteLine("Hello World!"); } [MethodImpl(MethodImplOptions.NoInlining)] private static void M() { } }
运行后得到结果
由此可以看出,线程同步能避免就尽量避免。如果一定要进行线程同步,就尽量使用用户模式的构造。
Semaphore构造
信号量(semaphore)其实就是由内核维护的int32变量。信号量为0时,在信号量上等待的线程会阻塞;信号量大于0是解除阻塞。在信号量上等待的线程解除阻塞时,内核自动从信号量的计数中减1。信号量还管理了一个最大int32值,当前计数绝不允许超过最大计数。
多个线程在一个信号量上等待时,释放信号量导致releaseCount个线程被解除阻塞。
自动重置事件在行为上和最大计数为1的信号量非常相似。两者的区别在于,可以在一个自动重置事件上连续多次调用set,同时仍只有一个线程解除阻塞。相反,在一个信号量上连续多次调用release,会使他内部计数一直递增,这可能解除大量线程的阻塞。
可像下面这样用信号量重新实现SimpleWaitLock,允许多个线程并发访问一个资源(如果所有线程以只读方式访问资源,就是安全的):
public sealed class SimpleWaitLock:IDisposable { private Semaphore m_available; public SimpleWaitLock(Int32 maxConcurrent) { m_available = new Semaphore(maxConcurrent, maxConcurrent); } public void Enter() { //在内核中阻塞直到资源可用 m_available.WaitOne(); } public void Leave() { //让其他线程访问资源 m_available.Release(1); } public void Dispose() { m_available.Close(); } }
Mutex构造
互斥体(mutex)代表一个互斥的锁。他的工作方式和AutoResetEvent(或者计数为1的Semaphore相似,三者都是一次只释放一个正在等待的线程)。下面展示了Mutex类的样子:
互斥体有一些额外的逻辑,这造成它们比其他构造更复杂。首先,mutex对象会查询调用线程的int32 id,记录是哪个线程线程获得了它。
其次,mutex对象维护着一个递归计数(recursion count),指出拥有该mutex线程拥有了多少次,只有计数变为0,另一个线程才能成为该mutex的所有者。
大多数人都不喜欢这个额外的逻辑,这些功能是有代价的,使锁变得更慢。
internal class SomeClass:IDisposable { private readonly Mutex m_lock = new Mutex(); public void Method1() { m_lock.WaitOne(); //随便做什么事情 Method2(); m_lock.ReleaseMutex(); } public void Method2() { m_lock.WaitOne(); //随便做什么事情 m_lock.ReleaseMutex(); } public void Dispose() { m_lock.Dispose(); } }
由于mutex对象支持递归,所以线程会获取两次锁,然后释放它两次。在此之后,另一个线程才能拥有这个Mutex。如果someClass使用一个AutoResetEvent而不是互斥体,线程在调用Method2的Waitone方法时会阻塞。
AutoResetEvent的递归锁问题可以由以下代码解决
internal sealed class RecursiveAutoResetEvent:IDisposable { private AutoResetEvent m_lock = new AutoResetEvent(true); private int m_owingThreadId = 0; private int m_recursionCount = 0; public void Enter() { //获取调用线程的唯一id int currentThreadId = Thread.CurrentThread.ManagedThreadId; //如果调用线程用有所,就地址递归技术 if (m_owingThreadId==currentThreadId) { m_recursionCount++; return; } //调用线程不拥有锁,就等待它 m_lock.WaitOne(); //调用线程现在拥有了锁,初始化拥有线程id和递归计数 m_owingThreadId = currentThreadId; m_recursionCount = 1; } public void Leave() { if (m_owingThreadId!=Thread.CurrentThread.ManagedThreadId) { throw new InvalidOperationException; } //从递归技术中减1 if (--m_recursionCount==0) { m_owingThreadId = 0; m_lock.Set();//唤醒一个正在等待的线程(如果有的话) } } public void Dispose() { m_lock.Dispose(); } }
RecursiveAutoResetEvent类的行为和mutex类完全一样,但是在一个线程视图递归地获取锁时,他的性能会好得多,因为现在跟踪线程所有权和递归的都是托管代码。只有在第一次获取autoresetEvent,或者最后把他放弃给其他线程时,线程才需要从托管代码转换为内核代码。