多线程之旅

来源:互联网 发布:java常用算法手册pdf 编辑:程序博客网 时间:2024/06/05 03:10

多线程之旅——从概念开始

对概念的理解是我们做任何事情的基础,因此我们从概念开始吧

程序执行顺序是按照串行执行的假设:

比如我们读诗词,默认从上到下

1.床 前 明 月 光,
2.疑 是 地 上 霜。
3.举 头 望 明 月,
4.低 头 思 故 乡。

而多线程以后,就很有可能变成

3.举 头 望 明 月,

1.床 前 明 月 光,
1.床 前 明 月 光,
4.低 头 思 故 乡。

一个列队中元素的数量必须小于或者等于存储元素的数组长度。如果这个列队只是在串行程序中使用,那么只要在所有共有方法的入口点和出口点保持这个不变性就足以保证程序的正确性。

一旦缺乏这种假设存在的前提条件,除非使用了某种特殊的方法,不然也许每行程序也许都要确保并发执行的时候不会出错,这将会让事情变得非常复杂。

可能一行代码被执行多次,导致你假设中的算法流程被破坏。

临界域是保证程序能串行执行的一种方式。

 

临界域(Critical Regiion)的概念:

信号量是规定了一定线程数可以执行的概念,这在实现对集合资源的保护时很有用。记住,信号量不属于某单一线程。

可以认为临界域是信号量的一个特例,规定了只有一个线程可以执行,也就是互斥。

信号量和临界域可以结合起来使用,以后会有例子说明。

常见的同义词诸如“加锁/释放”,“进入/退出","开始/结束"等,同义词虽多,可是表示的都是一个相同的概念。只要所有执行临界域代码的线程都按照一直的方式来访问数据,那么就可以避免发生数据间竞争的问题。

用伪代码表示就是:

EnterCriticalRegion();DoSomeThings();LeaveCriticalRegion();

有些临界域可以支持共享模式,例如read/write锁,这种方式能够使得多个线程并发的读取共享数据。

当然临界域这一概念的实现有很多种算法,我们先不讨论这个。因为操作系统对临界域这一概念有不少的既有实现。

不过相应的需求如下:

1.保证互斥性。

2.在临界域中的操作要进得来也要出得去。不能有线程因为死锁或者活锁问题导致无限期的停留在临界域中。

3.提供某种程度的公平性。

4.最好实现是低开销的。因为底层系统会频繁地使用临界域,进进出出。

 

可重入函数:

重入表示在一次函数执行的过程中,没有执行完时,又再次进入同一函数。冲入现象可能由同一线程造成,比如说递归;也有可能由多线程并发调用函数造成。

一个函数可重入表示这个函数的结果不会因为重入而产生变化,是稳定的。

 

粒度问题:

a++是用来说明多线程容易造成问题的最常用的例子。但是实现这种低级别的同步还是相对简单的,很多cpu内置提供支持。

但是更高级别的同步,比如说一个对象方法,包含很多个步骤,那么要保证其线程安全,就没那么简单。

在程序中通常包含一组子系统以及各种复合的数据结构,并且这些数据结构可能被多个线程并发访问。有两种方式来组织临界域:

粗粒度:通过只使用一个锁来保护子系统以及复合数据结构中的各个部分。优点:易于管理使用。缺点:伸缩性不强。

细粒度:对每一部分分别加锁。优点:高伸缩高并发。缺点:锁太多,难以合理划分和组织。误用时会产生很多问题。

 

线程:比喻为执行函数的虚拟处理器。

线程的状态:

1.假设线程没有状态的情况下,采用最简单也是最直观的“忙等待”(自旋)方式。下面这段程序中谓词(predicate ,也就是判断条件)保护了DoSomething的执行。

while(!p)DoSomething();

直到p为true,也就是获得进入临界区的资格后,DoSomething才执行。否则就一直原地打转,不停的检查P的值。

但是忙等待依然消耗CPU的运行周期,直到它的时间片用完或者系统抢占把cpu资源分配到其他线程。在自旋过程中会阻止了p为true时其他线程的运行,也就是说已经进入临界区准备执行DoSomething的线程也必须等待该自旋线程释放CPU资源。这个执行DoSomething的线程估计会郁闷,心想终于轮到我执行了,CPU运行周期却被自旋线程用来不停对P求值了。

因为把CPU周期浪费在了不停检查p这个共享内存的状态上,所以忙等待在大部分情况下都不是一种好的做法。这种大量使用CPU时钟周期和执行内存访问的操作,会导致频繁的总线通信以及能源消耗(特别是对于移动设备)

所以我们需要其他的手段来表示线程等待,最好干脆就是操作系统帮我们封装好了。这样我们只要在对线程说"hold 住",该线程就变成等待状态,不再占用CPU运行周期。

 

系统内置对线程状态的支持:

Windows操作系统通过各种内核对象来提供真正的等待功能。

当线程等待时,它将进入等待状态(与运行状态相对应),这将触发上下文切换操作以将这个线程立即从这个处理器上移走,并且确保Windows线程调度器不会将它作为下一个将要运行的线程。这避免了CPU计算能力以及能源的 浪费,并允许系统中其他线程的执行。假设有系统函数Wait,这个函数可以使得线程进入等待状态,上面的忙等待代码就变成

if(!p)    Wati();DoSomeThing();

现在,临界域中的线程不仅要使得P变为true,而且必须要考虑其他线程也可能处于等待状态。用一个WakeUp方法唤醒等待中的一个或多个线程

p = true;WakeUP();

 

线程安全方面:

数据的状态

在面向对象的编程系统中,一个典型的对象由保存状态的字段和操作状态的方法组成,状态被破坏意味着会产生不可预计的后果。

1.共享状态

当状态被共享时,多个线程对状态的并发访问将会在时间上发生重叠;当这些线程在访问共享状态发生重叠时,那么彼此之间的操作将会相互干扰。

.NET框架的类型安全在一定程度上保证了私有状态,因为如果程序中能够生成一个指向进程地址空间中任意位置的指针,那么整个地址空间中的数据都是共享状态的。

共享状态具有可传递性。

new 出来的对象只有创建该对象的线程可以访问,所以是线程安全的,不过一但被共享状态所引用(如静态变量),那么就不再是线程安全的。

2.私有状态

方法栈,私有变量,参数

5.readonly一定程度上保障了数据的不可变性。虽然可以多次赋值。

 

多线程之旅二——线程与进程简介

进程:分配系统资源的一种数据结构。

简而言之,就像一个储物箱一样,特点是为了保证安全,一个进程中的指针被设计为不可访问其他进程的地址。就像你的钥匙正常情况下不能随便打开任何一个人的储物箱一样,你只能打开自己的储物箱。如果打开别人的储物箱,不是无意的编码疏忽,就是恶意的攻击其他程序。

 

线程:分配CPU运行周期的一种数据结构
线程其实是任务的抽象概念。创建一个线程,相当于告诉CPU我有一个新任务需要你分配时间来处理。多线程和一个线程执行多个任务的区别就在于创建多个线程就是要求cpu要同时处理一些事情,而不是有先后顺序。在unix下其实就没有线程这种概念,就只有任务的概念。一个任务就相当于只有一个线程的进程,任务之间可以组合,可以指明那个任务的内存空间可以共享,来达到类似于多线程的目的。
一个方法需要CPU进行计算,那么这个方法的参数,局部变量等自然就会保存在线程当中,线程这个数据结构中包含了一个栈结构用来保存这些信息,通过入栈出栈来表示先后的执行。可以说,栈是属于线程的。
死锁:一个任务的执行条件永远不满足,一直不唤醒线程所以cpu一直不分配执行时间给这个任务。
活锁:就是不停的花费cpu时间检测这个任务是否能被执行。
无论是死锁还是活锁,我们都要浪费保留了参数,局部变量等这些信息的内存空间。可以看出,活锁比死锁问题更严重,因为除了浪费内存空间外,还浪费了CPU。
 
进程和线程没有什么组成的关系,两者是不同的抽象概念。进程不是由一个或者当个线程组成。没有线程,进程资源就不能被CPU所利用。所以一个进程至少应该有一个线程,否则要进程又有何用? 在Win32中,进程并不执行什么,它只是占据应用程序所使用的地址空间。简而言之,线程是调用进程内资源的方式。
 
但一个线程栈的内存地址的范围是什么呢?这个线程栈的地址范围就是在该线程所在的进程所占用地址中的某一块。
 
以上就是线程和进程的关系。虽然线程栈在进程的地址范围内的一段连续的内存,但是你可以认为线程栈是属于线程的,和进程没有什么关系。栈内保存的数据只是表明了cpu接下来要做的事情。
 
从某种意义上看,线程可以被视作一个虚拟处理器。每个线程都运行程序中的部分代码,并且表现得好像它与系统中的其他虚拟处理器毫不相干。
 

多线程之旅之三——Windows内核对象同步机制

 
内核对象(kernel object):
windows操作系统提供的最近本同步机制,这些对象是构建并发程序和基本并发数据结构的基础。事实上,无论在代码中是否直接使用了这些对象,在软件的某个层次中都肯定会依赖它们。直接使用内核对象将会带来代价很高的内核切换操作,因为内核对象通常是在内核内存中分布的,因此只有在内核态中运行的代码才能访问他们。用户态抽象层通常使用内核对象来实现等待和出发操作,但同时包含了一些机制保证了尽量避免调用内核对象,避免进行真正的等待。
 
为什么要使用内核对象?
1.内核对象可以实现进程间的同步,也就是说同步线程的时候,线程可以来自多个进程。
2.内核对象可以用于在非托管代码和托管代码之间实现互操作。
 


内核对象的分类:
1.内核对象有很多种,有5种是专门用于同步的。其他的诸如I/O完成端口后面会有介绍。
分别是:Mutex, Semaphore, Auto-Reset Event , Manual-Reset Event , Waitable Timer

2.这几种内核对象就是对不同情形的分类。
从状态来说,这几种内核对象都分为SignaledNonsignaled状态。简单来说,就是“是” 和“否”允许进入临界域。因为创建这几种内核对象本身是对不同情况的分类,所以这几种内核对象切换”是“和”否“所需要的条件肯定是不同的。
如同前面所说,在等待某件事情发生时,采用自旋操作往往伴随着对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();
 
 
类的继承关系如下:
 WaitHandle 
      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产生的内核切换开销可能是这个结构最大的性能问题。
 
复制代码
        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)            {                // Ensure the buffer hasn't become full yet. If it has, we will                 // be blocked until a consumer takes an item.                 m_producerSemaphore.WaitOne();                // Now 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_consumerSemaphore.Release();            }            public T Dequeue()            {                // This call will block if the queue is empty.                 m_consumerSemaphore.WaitOne();                // Dequeue the item from within our critical region.                 T value;                m_mutex.WaitOne();                try { value = m_queue.Dequeue(); }                finally { m_mutex.ReleaseMutex(); }                // Note that we took an item, possibly waking producers.                 m_producerSemaphore.Release();                return value;            }        }
复制代码

 

 
因为其实Semaphore说白了就是一个计数器,因此我们用一个int类型来计数+mutex也同样能实现这个功能。后面我会只用混合构造锁Monitor这一个类来实现。
其实在一些情况下,如AutoResteEvent和计数为1的Semaphore, 感觉起来就和Mutex差不多。
 
如果单纯的使用内核对象作为同步手段因为需要付出内核态切换的代价所以并不是一个好的选择。
我们接下来就看看结合自旋机制和内核对象的抽象层度更高的混合构造锁。

多线程之旅之四——浅谈内存模型和用户态同步机制

 用户态下有两种同步结构的
volatile construct: 在简单数据类型上原子性的读或者写操作
 
interlocked construct:在简单数据类型上原子性的读写操作
(在这里还是要再啰嗦一句,记住只有操作系统才有办法阻止一个线程执行,通过无论是I/O中断还是线程阻塞等方式。)
 
为了达到原子性操作,上面两种结构都需要内存地址正确对齐,简单来说就是对变量有要求,需要变量所在内存地址分别是1、2和4的倍数。正常情况下CLR中的变量类型都是字段对齐的,所以这里不展开来说。
我想还是从非常重要的interlocked类开始说起。
 
System.Threading.Interlocked

Interlocked类中的每个方法都执行一次原子性的读取以及写入操作,其中publicstatic Int32 Increment(ref Int32 location)方法是最常用到的方法,后面我在自定一个混合结构锁的时候就会用到。
下面是这个类的方法的签名,注释部分说明的是相对应的同步方法。
 
复制代码
public static class Interlocked {     // return (++location)     public static Int32 Increment(ref Int32 location);       // return (--location)     public static Int32 Decrement(ref Int32 location);       // return (location1 += value)     public static Int32 Add(ref Int32 location1, Int32 value);       // Int32 old = location1; location1 = value; return old;     public static Int32 Exchange(ref Int32 location1, Int32 value);       // Int32 old = location1;     // if (location1 == comparand) location1 = value;    // return old;     public static Int32 CompareExchange(ref Int32 location1,         Int32 value, Int32 comparand);     ...  }
复制代码

 

自己实现简单的Spin Lock,不阻塞线程,但是同时又只有一个线程可以进入临界域操作。那么其他的线程干什么了呢?肯定没有阻塞,因为我们没有使用到内核对象,但是为了不让他们干扰到我们工作,只能让它们在“原地打转”了。

假如有多个线程调用了Enter方法,那么只有一个线程能满足条件进入while的内部,其他线程都因为不满足条件而在不断的判断while条件。

exchange方法确保第一个调用的线程将m_ResourceInUse变为1,并且原始值为0.而其他线程将会使得m_ResourceInUse从1变为1,也就是原始值为1,不满足条件。

复制代码
class SimpleSpinLock {    private Int32 m_ResourceInUse; // 0=false (default), 1=true     public void Enter() {       // Set the resource to in-use and if this thread        while (Interlocked.Exchange(ref m_ResourceInUse, 1) != 0) {       }    }     public void Leave() {       Thread.VolatileWrite(ref m_ResourceInUse, 0);    } }
复制代码

如何使用这个类呢?很简单
复制代码
public sealed class SomeResource {    private SimpleSpinLock m_sl = new SimpleSpinLock();     public void AccessResource() {       m_sl.Enter();       // Only one thread at a time can get in here to access the resource...       m_sl.Leave();    } }
复制代码

exchange是原子判断true和false的一个常用办法。

 
原子性
写到这里我觉得还可能有人对原子性不清楚,举个例子来说吧,非常经典以及常用的++操作符号
int a = 0;a++;

当编译器把这行C#语句编译成汇编代码的时候,将会包含多条指令,如:

MOV  EAX,    [a]
INC   EAX
MOV  [a],  EAX
第一条指令获得变量a的地址,第二条指令把以这个地址开头的接下来的4个字节复制到寄存器EAX中。接下来的汇编指令将递增EAX中的值,最后将递增后的值从EAX复制回a指向的地址。
 
遗憾的是,我们从源代码中根本无法看到++运算符中所包含的这些步骤。如果使用多个变量,那么就可以更清楚的看到这些步骤。事实上,这些步骤类似于将代码写成下面这样:
int  a  = 0; int  tmp  =   a;tmp++;a  = tmp;
虽然加载寄存器和保存寄存器等指令本身都是原子的,但将加载、递增以及保存这三条指令放在一起组成的操作组合却就不再是原子的了
任何需要多条汇编指令的运算都是非原子的,因此++和--等操作符都是非原子的。这意味着我们需要采取额外的步骤来保证并发的安全性,下面我们来具体说下:
 
假设有三个线程t1、t2、t3同时编译后生成下面的汇编代码:

 

注意,纵向是时间线,#n表示当前时候a的值。

我们的原本想法应该是这样执行:

 

但是由于抢占式操作系统线程的推进是不可预测的,真正执行的时候可能是这样

在上面的执行流程中,t1首先更新为1,然后t2更新为2.此时,从系统中其他线程的角度来看,似乎一切都正常。

然后,此时t3被唤醒继续执行,它将覆盖t1和t2的执行结果,重新将a的值设置为1.

这是一个典型的数据竞争问题,之所以称为“竞争”,是因为代码执行的正确性完全依赖于多个线程之间的竞争结果。每个线程都试图最先执行完代码,并且根据哪个线程最先执行完成的不同,会导致不同的结果。也就是相同的源代码,不同的执行结果。

 
Interlock的Increment帮我们解决了这个问题,它能保证原子的递增。
 
下面我们用它来实现简单的Hybird Lock
复制代码
class SimpleHybridLock : IDisposable {    private Int32 m_waiters = 0;    // The AutoResetEvent is the primitive kernel-mode construct    private AutoResetEvent m_waiterLock = new AutoResetEvent(false);     public void Enter() {       if (Interlocked.Increment(ref m_waiters) == 1) //what will happen if we use m_waiters++ in this place?         return; //return means we enter critical region// Another thread is waiting. There is contention, block this thread       m_waiterLock.WaitOne();  // Bad performance hit here       // When WaitOne returns, this thread now has the lock    }     public void Leave() {       // This thread is releasing the lock       if (Interlocked.Decrement(ref m_waiters) == 0)          return; // No other threads are blocked, just return        // Other threads are blocked, wake 1 of them       m_waiterLock.Set();  // Bad performance hit here    }     public void Dispose() { m_waiterLock.Dispose(); } }
复制代码

 我们用一个int私有字段来计数,确保只有一个线程调用该方法的时候不会调用到非常影响性能的内核对象。只有多个线程并发的访问这个方法的时候,才会初始化内核对象,阻塞线程。

 

我们可以给这个锁加入更多的功能,这时我们需要保存更多的信息,也就需要更多的字段,比如说保存哪个线程拥有这个锁,以及它拥有了多少次。在多个线程并发访问的时候,我们也可以推迟一段时间再创建内核对象,可以加入spin lock先自旋一段时间。

 

复制代码
internal sealed class AnotherHybridLock : IDisposable {    // The Int32 is used by the primitive user-mode constructs (Interlocked methods)    private Int32 m_waiters = 0;     // The AutoResetEvent is the primitive kernel-mode construct    private AutoResetEvent m_waiterLock = new AutoResetEvent(false);     // This field controls spinning in an effort to improve performance    private Int32 m_spincount = 4000;   // Arbitrarily chosen count     // These fields indicate which thread owns the lock and how many times it owns it    private Int32 m_owningThreadId = 0, m_recursion = 0;     public void Enter() {       // If calling thread already owns the lock, increment recursion count and return       Int32 threadId = Thread.CurrentThread.ManagedThreadId;       if (threadId == m_owningThreadId) { m_recursion++; return; }        // The calling thread doesn't own the lock, try to get it       SpinWait spinwait = new SpinWait();       for (Int32 spinCount = 0; spinCount < m_spincount; spinCount++) {          // If the lock was free, this thread got it; set some state and return          if (Interlocked.CompareExchange(ref m_waiters, 1, 0) == 0) goto GotLock;           // Black magic: give other threads a chance to run           // in hopes that the lock will be released          spinwait.SpinOnce();       }        // Spinning is over and the lock was still not obtained, try one more time       if (Interlocked.Increment(ref m_waiters) > 1) {          // Other threads are blocked and this thread must block too          m_waiterLock.WaitOne(); // Wait for the lock; performance hit          // When this thread wakes, it owns the lock; set some state and return       }     GotLock:       // When a thread gets the lock, we record its ID and        // indicate that the thread owns the lock once       m_owningThreadId = threadId; m_recursion = 1;    }     public void Leave() {       // If the calling thread doesn't own the lock, there is a bug       Int32 threadId = Thread.CurrentThread.ManagedThreadId;       if (threadId != m_owningThreadId)          throw new SynchronizationLockException("Lock not owned by calling thread");        // Decrement the recursion count. If this thread still owns the lock, just return       if (--m_recursion > 0) return;        m_owningThreadId = 0;   // No thread owns the lock now        // If no other threads are blocked, just return       if (Interlocked.Decrement(ref m_waiters) == 0)           return;        // Other threads are blocked, wake 1 of them       m_waiterLock.Set();     // Bad performance hit here    }     public void Dispose() { m_waiterLock.Dispose(); } }
复制代码

 

当然锁变复杂了,性能也会有相应的降低。有所得有所失去。

 

Sync block

堆上的每个对象都可以关联一个叫做Sync block(同步块)的数据结构。同步块包含字段,这些字段和上面我们实现的锁中的字段的作用是差不多的。具体地说,它为一个内核对象、拥有线程的ID、递归计数器、等待线程的计数提供了保存的地方。

Type类型对象 和普通对象一样都在托管堆上,都有指向同步块的指针。锁住任一个普通对象和锁住type对象是没有什么区别的,反正用到的只是同步块。用了不同的同步块会创建不同的临界域,不同的临界域当然就没有什么互斥的概念了。所以lock typeof(object)其实也只是说“兄弟,我要用到你所指向的同步块来保存我同步时所必须的数据了”。

照例配图一张,要不光看我文字描述不太容易懂:


因此同步块干啥子的?用来保存数据的呗……
如同上面我们自己实现的混合结构锁一样,monitor、mutex和event就保存了0,1还有一点其他数据,比如说什么线程ID的,用来实现允许递归;Semaphore就保存了1,2,3,4,5……等数据。
当然,同步块也不是一开始就上的,上面这张图隐藏了点信息。就是其实那个指向同步块的指针有2个指针大小的内存,还保存着hashcode的值还有一些其他 东西。如果块内存不足以保存这些信息,那么才会为这个对象分配一个共享内存池中的同步块。这就是Object Header Inflation现象。

懂得相同之处了,再来理解为什么锁type类型危险的,究其原因就是type能被很多地方访问,甚至能跨appdomain,这就很有可能你莫名其妙就和另一 个appdomain中的锁用到同一个同步块了。同样情况的类型还有于AppDomain无关的反射类型,比如说啥子MemberInfo之类的。
 
为了说明临界域互斥的问题,我写了一段代码,创建了2个不同的临界域。
其中[MethodImplAttribute(MethodImplOptions.Synchronized)] 编译后就相当于lock(this)
复制代码
class Program    {        static void Main(string[] args)        {            var syncTest = new SyncTest();            Thread t1 = new Thread(syncTest.LongSyncMethod); // critical region 1            t1.Start();            Thread t2 = new Thread(syncTest.NoSyncMethod);            t2.Start();            Thread t3 = new Thread(syncTest.LongSyncMethod);// critical region 1             t3.Start();            Thread t4 = new Thread(syncTest.NoSyncMethod);            t4.Start();            Thread t5 = new Thread(syncTest.NoSyncMethod);            t5.Start();            Thread t6 = new Thread(syncTest.SyncMethodUsingPrivateObject);// critical region 2            t6.Start();            Thread t7 = new Thread(syncTest.SyncMethodUsingPrivateObject);// critical region 2            t7.Start();        }    }    class SyncTest    {        private object _lock = new object();        [MethodImplAttribute(MethodImplOptions.Synchronized)]        public void LongSyncMethod()        {            Console.WriteLine("being asleep");            Thread.Sleep(10000);        }        public void NoSyncMethod()        {            Console.WriteLine("do sth");        }                public void SyncMethodUsingPrivateObject()        {            lock (_lock)            {                Console.WriteLine("another critical section");                Thread.Sleep(5000);            }        }    }
复制代码

 

 很多对概念不清楚的人都以为lock(this)后会把整个对象都锁住,什么方法都用不了。好一点的会认为同步方法用不了。懂得原因以后,就会明白lock(this)并没有什么特别的,只是通过this对象创建了一个临界域,我们同样可以lock其他对象创建不同的临界域,不同的临界域并不互斥
 
 
用Monitor来实现阻塞列队:

Monitor也是一种结合了自旋和内核对象的混合构造锁。我们通常会用Lock关键字去使用它,lock关键字保证了我们能按照正确的模式去使用Monitor类。
1.通过临时变量保证了进入和释放的都是同一个对象,就算你在Lock里面修改了所对象也一样。
2.保证锁只要获取了就能释放。
 
下面是.NET4以后Lock语法糖编译后的等价代码
复制代码
bool acquired = false;object tmp = listLock;try{   Monitor.Enter(tmp, ref acquired);   list.Add("item");} finally {   if (acquired)   {        Monitor.Release(tmp);    } }
复制代码

 

 
 
在《多线程之旅之三》中我们用两个内核对象实现了 有界阻塞列队,主要的开销就在于每次入队的时候两个内核对象之间发生的切换,下面我们尝试用混合锁Monitor来实现相应的数据结构。享受混合锁给我们带来的好处。
复制代码
    public class BlockingQueue<T>    {        private Queue<T> m_queue = new Queue<T>();        private int m_waitingConsumers = 0;        public int Count        {            get            {                lock (m_queue)                    return m_queue.Count;            }        }        public void Clear()        {            lock (m_queue)                m_queue.Clear();        }        public bool Contains(T item)        {            lock (m_queue)                return m_queue.Contains(item);        }        public void Enqueue(T item)        {            lock (m_queue)            {                m_queue.Enqueue(item);                // Wake   consumers  waiting  for  a  new  element.                 if (m_waitingConsumers > 0)                    Monitor.Pulse(m_queue);            }        }        public T Dequeue()        {            lock (m_queue)            {                while (m_queue.Count == 0)                {                    //Queue  is  empty,  wait  until  en  element  arrives. 644  Chapter 12:  Parallel Containers                     m_waitingConsumers++;                    try                    {                        Monitor.Wait(m_queue);                    }                    finally                    {                        m_waitingConsumers--;                    }                }                return m_queue.Dequeue();            }        }        public T Peek()        {            lock (m_queue)                return m_queue.Peek();        }    }
复制代码

 

 

1.多线程之旅——从概念开始

2.多线程之旅二——线程

3.多线程之旅之三——Windows内核对象同步机制

4.多线程之旅之四——浅谈内存模型和用户态同步机制






多线程之旅

1.多线程之旅——从概念开始

2.多线程之旅二——线程

3.多线程之旅之三——Windows内核对象同步机制

4.多线程之旅之四——浅谈内存模型和用户态同步机制

5.多线程之旅之五——线程池与I/O完成端口

6.多线程之旅六——异步编程模式,自己实现IAsyncResult

7.多线程之旅七——再谈内存模型

8.多线程之旅八——多线程下的数据结构



 

 

 

原创粉丝点击