内核对象(kernel object):
windows操作系统提供的最近本同步机制,这些对象是构建并发程序和基本并发数据结构的基础。事实上,无论在代码中是否直接使用了这些对象,在软件的某个层次中都肯定会依赖它们。直接使用内核对象将会带来代价很高的内核切换操作,因为内核对象通常是在内核内存中分布的,因此只有在内核态中运行的代码才能访问他们。用户态抽象层通常使用内核对象来实现等待和出发操作,但同时包含了一些机制保证了尽量避免调用内核对象,避免进行真正的等待。
为什么要使用内核对象?
1.内核对象可以实现进程间的同步,也就是说同步线程的时候,线程可以来自多个进程。
2.内核对象可以用于在非托管代码和托管代码之间实现互操作。
内核对象的分类:
1.内核对象有很多种,有5种是专门用于同步的。其他的诸如I/O完成端口后面会有介绍。
分别是:Mutex, Semaphore, Auto-Reset Event , Manual-Reset Event , Waitable Timer
...
DWORD WINAPI WaitForSingleObject(hMutant , IFINITE);
2.这几种内核对象就是对不同情形的分类。
从状态来说,这几种内核对象都分为Signaled和Nonsignaled状态。简单来说,就是“是” 和“否”允许进入临界域。因为创建这几种内核对象本身是对不同情况的分类,所以这几种内核对象切换”是“和”否“所需要的条件肯定是不同的。
如同前面所说,在等待某件事情发生时,采用自旋操作往往伴随着对CPU运行周期的一种抢夺和浪费,有时候我们就需要线程”什么事都不干“,也就是线程阻塞。
“阻塞”和停止的区别:
只有Windows操作体统内核才能停止一个线程的运行。在用户态模式中运行的线程可能会被操作系统抢占,但线程会以最快的速度再次调度。
因此理想的锁应该是在没有竞争的情况下可以快速的执行不会阻塞,但是如果出现了多个线程竞争,那么就应该被操作系统内核阻塞,这样就可以避免CPU时间的浪费。
线程发生阻塞的原因有多种,比如: 执行I/O操作 、睡眠、挂起等。另外一个常见的原因就是等待内核对象切换成Signaled状态。
其实说白了,为了维护次序肯定不能一拥而上,关键就是要“等”。当然等这个抽象概念大家都知道,更具体一点来说就是等“一个”还是等“多个”?有可能等待一个条件的成熟,也有可能等待多个条件的成熟。再更具体点,在等多个的时候,是这多个中的任意一个呢,还是要等全部?
当线程阻塞时,CLR将使用一个通用的等待函数,而并不考虑这个等待是不是由调用了WaitHandler类的 WaitOne、 WaitAny、WaitAll等,或是在用户态的混合锁上的任何阻塞调用,如Monitor,ReadWriteLockSlim等。
内核对象API
当一个线程获得了一个指向内核对象的引用,也就是说在代码中new出来或者其他方式获得一个内核对象时,我们可以调用.NET中的等待API在这个对象等待。多个线程可以同时等待一个内核对象,这样多个线程也许都会进入阻塞状态。根据不同的内核对象,唤醒阻塞的线程时也会有不同的情况,有的内核对象在状态切换时,只唤醒多个等待线程中的一个,比如Pulse,有的是全部都唤醒,比如PulseAll。这两种方式各有自己的好处和不足。比如PulseAll安全,不会造成唤醒遗失现象,但是会造成唤醒的线程重复等待。具体情况后面会有详细说明。
既然说了各种内核对象只是为了应对不同的状况而被人们分类的时候,我们先讨论这几种状况:
1.当我们只允许一个线程进入临界区的时候:
Mutex
在win32中,等待一个Mutex对象的API如下:
DWORD WINAPI WaitForSingleObject(HANDLE hHandle , DWORD dwMilliseconds)
使用方式是:
HANDLE hMutex = CreateMutex(...);...
DWORD WINAPI WaitForSingleObject(hMutant , IFINITE);
由于方法参数用的是一个句柄,因此也可以用该方法在其他内核对象上等待。换句话说,就是用方法来抽象了"等待"的概念。
.NET中对应的方法如下
Mutex mutex = new Mutex();
...
mutex.WaitOne();
...
mutex.WaitOne();
类的继承关系如下:
WaitHandle
EventWaitHandle
AutoResetEvent
ManualResetEvent
Semaphore
Mutex
EventWaitHandle
AutoResetEvent
ManualResetEvent
Semaphore
Mutex
是用对象间的关系来表达相应的逻辑。用一个WaitHandle 类来抽象出”等待“的概念。
是用方法参数来抽象还是用类的继承来抽象,可以看做这是C++中过程式风格和C#面向对象风格的区别
public abstract class WaitHandle : MarshalByRefObject, IDisposable { public virtual void Close(); public void Dispose(); public virtual Boolean WaitOne(); public virtual Boolean WaitOne(Int32 millisecondsTimeout); public static Int32 WaitAny(WaitHandle[] waitHandles); public static Int32 WaitAny(WaitHandle[] waitHandles, Int32 millisecondsTimeout); public static Boolean WaitAll(WaitHandle[] waitHandles); public static Boolean WaitAll(WaitHandle[] waitHandles, Int32 millisecondsTimeout); public static Boolean SignalAndWait(WaitHandle toSignal, WaitHandle toWaitOn); public static Boolean SignalAndWait(WaitHandle toSignal, WaitHandle toWaitOn, Int32 millisecondsTimeout, Boolean exitContext) public SafeWaitHandle SafeWaitHandle { get; set; } // Returned from WaitAny if a timeout occurs public const Int32 WaitTimeout = 0x102; }
2.当我们只允许指定数量的线程进入临界区的情况以及应用
Semaphore
和mutex不同的是,Semaphore不应该被认为是由某个特定的线程所“拥有”。例如,一个线程可以在信号量上执行插入操作,而另一个线程可以在同一个线程上执行取走操作。通常,信号量用于保护在容量上有限的资源。比如一个固定大小的数据库连接池需要被定期访问,这样同时请求的连接数量不应该超过可用连接的数量。同样,还可能有一个共享的内存缓冲区,缓冲区的大小是变动的,但要确保同时访问缓冲区的线程数量与缓冲区中的可用项数量一样多。
因为允许多个线程进入临界区,因此信号量并不能避免并发带来的危害,通常需要配合其他的数据同步机制。Semaphore本质上就是内核维护的一个计数器,计数值大于1的Semaphore并不能保证互斥行为。
public sealed class SimpleWaitLock : IDisposable { private Semaphore m_AvailableResources; public SimpleWaitLock(Int32 maximumConcurrentThreads) { m_AvailableResources = new Semaphore(maximumConcurrentThreads, maximumConcurrentThreads); } public void Enter() { // Wait efficiently in the kernel for resource access, then return m_AvailableResources.WaitOne(); } public void Leave() { // This thread doesn’t need access anymore; another thread can have it m_ AvailableResources.Release(); } public void Dispose() { m_AvailableResources.Close(); } }
阻塞列队
在我们操作一个列队的时候,最先想到的就是从列队中提取一个元素的时候如果没有元素应该怎么办。我们可以用一个等待条件使得列队中有元素了再提取,否则就阻塞。看样子就应该这样的
public class BlockingQueueWithAutoResetEvents <T> { private Queue <T> m_queue = new Queue <T>(); private Mutex m_mutex = new Mutex (); private AutoResetEvent m_event = new AutoResetEvent (false ); public void Enqueue(T obj) { // Enter the critical region and insert into our queue. m_mutex.WaitOne(); try { m_queue.Enqueue(obj); } finally { m_mutex.ReleaseMutex(); } // Note that an item is available, possibly waking a consumer. m_event.Set(); } public T Dequeue() { // Dequeue the item from within our critical region. T value; bool taken = true ; m_mutex.WaitOne(); try { // If the queue is empty, we will need to exit the // critical region and wait for the event to be set. while (m_queue.Count == 0) { taken = false ; WaitHandle .SignalAndWait(m_mutex, m_event); m_mutex.WaitOne(); taken = true ; } value = m_queue.Dequeue(); } finally { if (taken) { m_mutex.ReleaseMutex(); } } return value; } }
阻塞/有界列队
但是如果对于一个动态的生产者/消费者的情况中,这样的只对消费者限制,对生产者没限制的列队,随着时间的推移,生产者和消费者之间的比率就会不平衡。
我们想要实现的功能是:如果试图从一个空的队列中取数据,那么线程将阻塞,知道有新的元素加入。试图将数据放入到一个容量已满的列队同样也会导致线程阻塞,直到有空间腾出来。
现在我们采用Semaphore+Mutex的方式实现这个数据结构。Mutex用来实现临界域的互斥行为,确保对状态的修改能够安全的进行;Semaphore用于实现控制同步。Semaphore使得这个工作变得相对容易,因为对容量有限的资源进行保护正是当初为什么要划分出这么一个类的原因。值得注意的是,管理Semaphore和Mutex产生的内核切换开销可能是这个结构最大的性能问题。WaitOne会使得计数器减1,当计数为0时,再调用WaitOne就会阻塞。
public class BlockingBoundedQueue<T> { private Queue<T> m_queue = new Queue<T>(); private Mutex m_mutex = new Mutex(); private Semaphore m_producerSemaphore; private Semaphore m_consumerSemaphore; public BlockingBoundedQueue(int capacity) { m_producerSemaphore = new Semaphore(capacity, capacity);
m_consumerSemaphore = new Semaphore(0, capacity); } public void Enqueue(T obj) { m_producerSemaphore.WaitOne(); try { m_queue.Enqueue(obj); } finally { m_mutex.ReleaseMutex(); } m_consumerSemaphore.Release(); } public T Dequeue() { m_consumerSemaphore.WaitOne(); T value; m_mutex.WaitOne(); try { value = m_queue.Dequeue(); } finally { m_mutex.ReleaseMutex(); } m_producerSemaphore.Release(); return value; } }
因为其实Semaphore说白了就是一个计数器,因此我们用一个int类型来计数+mutex也同样能实现这个功能。后面我会只用混合构造锁Monitor这一个类来实现。
其实在一些情况下,如AutoResteEvent和计数为1的Semaphore, 感觉起来就和Mutex差不多。
如果单纯的使用内核对象作为同步手段因为需要付出内核态切换的代价所以并不是一个好的选择。
我们接下来就看看结合自旋机制和内核对象的抽象层度更高的混合构造锁。
多线程之旅
4.多线程之旅之四——浅谈内存模型和用户态同步机制
5.多线程之旅之五——线程池与I/O完成端口
6.多线程之旅六——异步编程模式,自己实现IAsyncResult
7.多线程之旅七——再谈内存模型
8.多线程之旅八——多线程下的数据结构