博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
【C#】C#线程_混合线程的同步构造
阅读量:7057 次
发布时间:2019-06-28

本文共 12260 字,大约阅读时间需要 40 分钟。

目录结构:

contents structure
[+]

在之前的文章中,我们分析过C#线程的基元线程同步构造,在这篇文章中继续分析C#线程的混合线程的同步构造。

在之前的分析中,谈到了基元用户模式的线程构造与内核模式的线程构造的优缺点,文章做了关于这个问题的详细介绍。能够结合基元用户模式和内核模式的优点构建的新的线程,就被称为混合线程。

1.一个简单的混合锁

通过上面的介绍,我们知道了混合锁肯定要用两种锁(基元用户模式锁和内核模式锁)结合起来使用。

internal sealed class SimpleHybridLock : IDisposable {        //Int32由基元用户模式构造(Interlocked的方法)使用        private Int32 m_waiters = 0;        //AutoResetEvent 是基元内核模式构造        private AutoResetEvent m_waiterLock = new AutoResetEvent(false);        public void Enter() {            //指出这个线程想要获得的锁            if (Interlocked.Increment(ref m_waiters) == 1) {                return;//锁可以自由使用,无竞争,直接返回            }            //另一个线程拥有锁,使这个线程等待            m_waiterLock.WaitOne();//较大的性能影响        }        public void Leave() {            //这个线程准备释放锁            if (Interlocked.Decrement(ref m_waiters) == 0) {                //没有其他线程在等待,直接返回                return;            }            //有其他线程在阻塞,唤醒其中一个            m_waiterLock.Set();//较大的性能影响        }        public void Dispose() {            m_waiterLock.Dispose();//较大的性能影响        }    }

SimpleHybridLock类的性能是比较差的。解释一下上面的流程,当第一个线程进入Enter()方法的时候使用Interlocked基元用户模式类,对m_waiters加锁的时间很短;当第二个线程进入Enter()方法后,在前一个线程未释放锁前,第二个线程会在AutoResetEvent的WaitOne上阻塞,AutoResetEvent是内核模式类,在内核上阻塞,不会占用CPU的时间。因为AutoResetEvent在内核上阻塞,所以代码需要从用户模式转化为内核模式,这里会产生较大的性能影响,从内核模式转化为用户模式,也会产生较大的性能影响。

FCL中提供了丰富的优化过的混合锁。

2.FCL中的混合锁

FCL中自带了许多混合构造,使用这些构造能够提升程序的性能。有些构造直到首次有线程在一个构造上发生竞争时,才会创建内核模式的构造。如果线程一直不在构造上发生竞争,应用程序就可避免因创建对象而产生的性能损失,同时避免为对象分配内存。许多构造还支持使用一个CancellationToken,使一个线程强迫解除可能正在构造上等待的其他线程的阻塞。

2.1 ManualResetEventSlim类和SemaphoreSlim类

System.Threading.ManualResetEventSlim和System.Threading.SemaphoreSlim这两个类。这两个类的构造方式和对应的内核模式构造完全一致,只是他们都在用户模式中“自旋”,而且都推迟到第一次竞争时,才创建内核模式的构造。它们的Wait方法运行传递一个CancellationToken。

下面列出这两个类的一些重载方法,

ManualResetEventSlim类:

public class ManualResetEventSlim : IDisposable{    public ManualResetEventSlim(bool initialState, int spinCount);    public void Dispose();    public void Reset();    public void Set();    public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken);    public bool IsSet { get; }    public int SpinCount { get; }    public WaitHandle WaitHandle { get; }}

SemaphoreSlim类:

public class SemaphoreSlim : IDisposable{    public SemaphoreSlim(int initialCount, int maxCount);    public void Dispose();    public int Release(int releaseCount);    public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken);    public Task
WaitAsync(int millisecondsTimeout, CancellationToken cancellationToken); public int CurrentCount { get; } public WaitHandle AvailableWaitHandle { get; }}

2.2 Monitor类和同步块

或许最常用的混合型线程构造就是Monitor类了,它提供了支持自旋,线程所有权和递归的互斥锁。但是Monitor实际上是存在许多问题的。

堆中的每个对象都可关联一个名为同步块的数据结构,同步块包含字段,它为内核对象、拥有线程的ID、递归计数以及线程等待计数提供了相应的字段。Monitor是静态类,它的方法接受对任何堆对象的引用。这些方法对指定对象的同步块的字段进行操作。以下是Monitor最常用的方法:

public static class Monitor{public static void Enter(object obj);public static void Exit(object obj);public static bool TryEnter(object obj, int millisecondsTimeout);public static void Enter(object obj, ref bool lockTaken);public static void TryEnter(object obj, int millisecondsTimeout, ref bool lockTaken);}

下面是Monitor原本的使用方法:

internal sealed class Transaction{    private DateTime m_timeOfLastTrans;        public void PerformTransaction(){        Monitor.Enter(this);        //以下代码拥有对数据的独占访问权        m_timeOfLastTrans=DateTime.Now;        Monitor.Exit(this);    }        public DateTime LasTransaction{        get{            Monitor.Enter(this);            //以下代码拥有对数据的独占访问权            DateTime temp=m_timeOfLastTrans;            Monitor.Exit(this);            return temp;        }    }}

表面上看起来很简单,但实际却存在许多问题。现在的问题是,每个对象的同步块索引隐式为公共的,下面的代码演示了可能造成的影响:

static void DoSomeMethod() {    var t = new Transaction();    Monitor.Enter(t);//这个线程获取对象的公共锁    //让线程池线程显示LastTransaction时间    //注意:线程池线程会阻塞,知道DoSomeMethod调用了Monitor.Exit    ThreadPool.QueueUserWorkItem(o => {        Console.WriteLine(t.LastTransaction);    });    //这里执行一些其他代码    Monitor.Exit(t);}

DoSomeMethod调用Monitor.Enter获取到了对象的公共锁,线程池线程调用LastTransaction属性,在LastTransaction属性中会获取同一个对象的锁,所以会导致LastTransaction属性阻塞,直到DoSomeMethod的线程调用Monitor.Exit。要解决这个问题的话,需要使用私有锁,把Transaction改成如下就可以解决上面的问题:

internal sealed class Transaction{    private DateTime m_timeOfLastTrans;    private readonly Object m_lock=new Object();//现在每个Transaction对象都有私有锁        public void PerformTransaction(){        Monitor.Enter(m_lock);        //以下代码拥有对数据的独占访问权        m_timeOfLastTrans=DateTime.Now;        Monitor.Exit(m_lock);    }        public DateTime LasTransaction{        get{            Monitor.Enter(m_lock);            //以下代码拥有对数据的独占访问权            DateTime temp=m_timeOfLastTrans;            Monitor.Exit(m_lock);            return temp;        }    }}

再看下面这种情况,由于C#提供了lock关键字来提供一个简化的语法,如果像下面这样写:

public void DoSomeMethod(){    lock(this){        //...    }}

然后编译器编译为这样:

public void DomSomeMethod(){    Boolean lockTaken=false;    try{        //这里可能发生异常        Monitor.Enter(this,ref lockTaken);        //这里的代码拥有对数据的独占访问权    }finally{        if(lockTaken) Monitor.Exit(this);    }}

第一个问题是,C#团队认为他们在finally块中调用Monitor.Exit是帮了你一个大忙,因为这样一样,总是可以确保锁得以释放。然而这只是他们一厢情愿的想法,如果在Try块更改状态时候发生异常,那么另一个线程很可能继续操作损坏的数据,这样的结果难以预料,同时还有可能引发安全隐患。第二个问题是进入和离开try会发生性能影响。所以在代码中应该不要使用lock语句。

2.3 ReaderWriterLockSlim类

我们经常希望当多个线程读取数据时,可以并发读取。当有一个线程试图修改数据时,这个线程应该对数据进行独占式访问。System.Threading.ReaderWriterLockSlim封装了这种功能的逻辑。

1.一个线程向数据写入时,访问请求的其它所有线程都被阻塞。
2.一个线程从数据读取时,请求读取的其它线程允许继续执行,但请求写入的线程仍被阻塞。
3.向数据写入的线程结束后,要么解除一个写入线程的阻塞,使它能向数据写入。要么解除所有读取线程的阻塞,使它们能够并发访问数据。如果没有线程被阻塞,锁就进入可自由使用的状态,可供下一个reader或writer线程获取。
4.从数据读取的所有线程结束后,一个writer线程被解除阻塞,使其能够向数据写入。如果没有线程被阻塞,锁就进入可自由使用的状态,可供下一个writer或reader线程使用。
下面展示了这个类的部分方法:

public class ReaderWriterLockSlim : IDisposable{public ReaderWriterLockSlim(LockRecursionPolicy recursionPolicy);public void EnterReadLock();public bool TryEnterReadLock(int millisecondsTimeout);public void ExitWriteLock();public void EnterWriteLock();public bool TryEnterWriteLock(int millisecondsTimeout);public void ExitWriteLock();public bool IsReadLockHeld { get; }public bool IsWriteLockHeld { get; }public int CurrentReadCount { get; }public int RecursiveReadCount { get; }public int RecursiveWriteCount { get; }public int WaitingReadCount { get; }public int WaitingWriteCount { get; }public LockRecursionPolicy RecursionPolicy { get; }}

下面这个类演示了ReaderWriterLockSlim的用法:

internal sealed class Transaction : IDisposable {//构造ReaderWriterLockSlim实例,不支持递归加锁private readonly ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);private DateTime m_timeOfLastTrans;public void PerformTransaction() {    m_lock.EnterWriteLock();    //以下代码拥有对数据的独占访问权    m_timeOfLastTrans = DateTime.Now;    m_lock.ExitWriteLock();}public DateTime LastTransaction {    get {        m_lock.EnterReadLock();        DateTime temp = m_timeOfLastTrans;        m_lock.ExitReadLock();        return temp;    }}public void Dispose() {    m_lock.Dispose();}}

2.4 CountdownEvent类

System.Threading.CountdownEvent构造使用ManualResetEventSlim对象。这个构造阻塞一个线程,直到它的内部计数器变成0。从某种角度来说,这个构造的行为和Semaphore的行为相反(Semaphore是在计数为0时阻塞线程)。下面列出这个类的一些成员:

public class CountdownEvent : IDisposable{    public CountdownEvent(int initialCount);    public void Dispose();    public void Reset();    public void AddCount();    public bool TryAddCount();    public bool Signal();    public void Wait();    public int CurrentCount { get; }    public bool IsSet { get; }}

一旦一个CountdownEvent的CurrentCount为0时,它就不能再更改了,CountdownEvent为0时,addCount方法会抛出一个InvalidOperationException异常。如果CurrentCount为0,TryAddCount直接返回false.

2.5 Barrier类

System.Threading.Barrier控制一些列线程需要并行工作,从而在一个算法的不同阶段推进。看下面这个例子来进行理解:当CLR使用它的垃圾回收器(GC)服务器的版本时,GC算法为每个内核都创建了一个线程。这些线程在不同应用程序的栈中向上移动,并发标记堆中的对象。每个线程完成了它自己的哪一分部工作后,必须停下来等待其他线程完成。所有线程都标记好对象后,线程就可以并发的压缩堆的不同部分。每个线程都完成了对它的那一部分的堆的压缩后,线程必需阻塞以等待其他线程。所有线程都完成了对自己那一部分堆的压缩后,所有线程都要在应用程序的线程的栈中上行,对根进行修正,使之引用因为压缩而发生移动对象的新位置。只有在所有线程都完成这个工作之后,应用程序的线程才可以恢复执行。

使用Barrier可以轻松的解决上面这种问题。下面列举Barrier类的常用成员:

public class Barrier : IDisposable{public Barrier(int participantCount, Action
postPhaseAction);public void Dispose();public long AddParticipants(int participantCount);public void RemoveParticipants(int participantCount);public void SignalAndWait(CancellationToken cancellationToken);public long CurrentPhaseNumber { get; internal set; }public int ParticipantCount { get; }public int ParticipantsRemaining { get; }}

构造Barrier时要告诉它有多少个线程准备参与工作,还可以传递一个Action<Barrier>委托来引用所有参与者完成一个阶段的工作后要调用的代码。可以调用AddParticipant和RemoveParticipant方法在Barrier中动态添加和删除参与线程。每个线程完成它的阶段性工作后,应调用SignalAndWait,告诉Barrier已经完成一个阶段的工作,而Barrier会阻塞线程(使用MaunalResetEventSlim),所有参与者都调用了SignalAndWait后,Barrier将调用指定的委托(有最后一个调用SignalAndWait的线程调用),然后解除正在等待的所有的线程的阻塞,使它们开始下一个阶段。

3.双检锁技术

双检锁(Double-Check Locking)是一个非常著名的技术,开发人员用它将但实例(Singleton)对象的构造推迟到应用程序首次请求该对象时进行。有时也称为延迟初始化(Lazy initialization)。如果应用程序永远不请求对象,对象就永远不会构造,从而节约了事件和内存。但当多个线程同时请求单实例对象时就可能出现问题。这个时候必须使用一些线程同步机制确保单实例对象只被构造一次。

双检锁在Java被大量使用,后来有人发现Java不能保证该技术在任何地方都正常工作。在这篇文章对其进行了详细的阐述:http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
然而CLR很好的支持了双检锁技术,以下代码演示了如何使用C#实现双检锁技术:

public sealed class Singleton {        //s_lock对象是实现线程安全所需要的。定义这个对象时,我们假设创建单实例对象的代价要高于创建一个System.Object对象,        private static Object m_lock = new Object();        //这个字段应用单实例对象        private static Singleton s_value = null;        //私有构造器,阻止在这个类的外部创建类的实例        private Singleton() {}        //以下公共静态方法返回单实例对象        public static Singleton GetSingleton() {            if (s_value != null) return s_value;            Monitor.Enter(m_lock);            if (s_value == null) {                //仍未创建,创建它                Singleton temp = new Singleton();                //将引用保存到s_value中                Volatile.Write(ref s_value,temp);            }            Monitor.Exit(m_lock);            return s_value;        }    }

也许有的开发人员会这样写第二个if语句的代码:

s_value=new Singleton();

你的想法是让编译器生成代码为Singleton分配内存,再调用构造器来初始化字段,再将引用赋值给s_value字段。但那只是你一厢情愿的想法,编译器可能会这样做:为Singleton分配内存,将引用发布到(赋值)s_value,再调用构造器。从单线程的角度出发,像这样的改变顺序是无关紧要的。但在将引用发布给s_value之后,在调用Singleton构造器之前,如果有另一个线程调用GetSingleton方法,会发生什么呢?这个线程会发现s_value不为null,会开始使用Singleton对象,但此时对象的构造器还未结束执行呢!这是一个很难跟踪的bug。

上面的Volatile.Write方法解决了这个问题,它保证temp中的引用只有在构造器执行结束后,才赋值到s_value中。还可以在s_value上使用volatile关键字,使用volatile会使s_value的所有读取操作都具有易变性。
“双检锁”著名并不是因为它是有最好的效率,只是大多数程序员都在讨论而且。下面的例子是一个没有使用双检锁的Singleton,并且它的效率要比上面案例的Singleton要高。

internal sealed class Singleton{    private static Singleton s_value=new Singleton();    //私有化构造器    private Singleton(){    }    public static Singleton GetSingleton(){        return s_value;    }}

代码在首次访问类成员时,CLR会自动调用类型的构造器,当有多个线程访问时第一个线程才会完成创建Singleton实例的任务,其他的线程会执行返回s_value,这是一种线程安全的方式。然而这样代码的问题就是,首次访问类的任何成员都会调用类型构造器。所以,如果Singleton定义了其它成员,就会在访问其它成员时候创建Singleton对象。

下面通过Interlocked.CompareExchange方法来解决这个问题:

internal sealed class Singleton{    private static Singleton s_value=null;        private Singleton(){}    public static Singleton GetSingleton(){        if(s_value!=null) return s_value;        //创建一个新的单实例对象,并把它固定下来(如果另一个线程还为固定的话)        Singleton temp=new Singleton();        Interlocked.CompareExchange(ref s_value,temp,null);        //如果这个线程竞争失败,新建的第二个实例对象就会被回收        return s_value;    }}

上面的代码保证了只有在第一个调用GetSingleton()方法方法时,才会构建单实例对象。但是缺点也是明显的,就是可能会创建多个Singleton对象,但是最终只会固定一个Singleton实例对象。

System.Lazy和System.Threading.LazyInitializer是FCL封装提供的延迟构造的类。

4.异步线程的同步构造

锁很流行,但长时间拥有会带来巨大的伸缩性问题。如果代码能够通过异步的同步构造指出它想要一个锁,那么会非常有用。在这种情况下,如果线程得不到锁,可以直接返回并执行其他工作,而不必在哪里傻傻地阻塞。以后当锁可用时,代码可恢复执行并访问锁所保护的资源。

SemaphoreSlim类通过WaitAsync方法实现了这个思路,下面是这个方法最复杂的版本:

public Tast
WaitAsync(Int32 millisecondsTimeout,CancellationToken cancellationToken)

可用它异步地同步对一个资源的访问(不阻塞任何线程):

private static async Task AccessResourceViaAsyncSynchronization(SemaphoreSlim asyncLock){    //do something    await asyncLock.WaitAsync();//请求获取锁对资源进行独占访问    //表明没有其他线程正在访问资源    //独占式访问资源        //资源访问完毕,释放锁    asyncLock.Release();    //do Something}

SemaphoreSlim的WaitAsync方法很好用,但它提供的是信号量语义。.net framework并没有提供reader-writer语义的异步锁。

5.并发集合类

FCL提供了4个线程线程安全的集合类,全部在System.Collections.Concurrent命名空间中定义。它们是ConcurrentQueue、ConcurrentStack、ConcurrentDictionary和ConcurrentBag。

ConcurrentQueue提供了以先入先出(FIFO)的方式处理数据项,ConcurrentStack提供了以先入后出(FILO)的方式处理数据项,ConcurrentDictionary提供了一个无序key/value对集合,ConcurrentBag一个无序数据项集合,允许重复。

转载地址:http://qkgol.baihongyu.com/

你可能感兴趣的文章
模板引擎ejs的include方法
查看>>
NOIP2003 传染病控制
查看>>
bzoj千题计划316:bzoj3173: [Tjoi2013]最长上升子序列(二分+树状数组)
查看>>
python 3 中建立可迭代对象(making object iterable)
查看>>
linux: 堆排序和快速排序的整理
查看>>
请求数据传入(SpringMVC)
查看>>
第七篇 PHP编码规范
查看>>
队列(queue)
查看>>
jsHint-静态代码检查工具eclipse中使用
查看>>
SDE面试技巧之二:System Design
查看>>
测试中的小事情
查看>>
8.spring:事务管理(上):Spring的数据库编程、编程式事务管理
查看>>
PAT 1014
查看>>
Python异或加密字符串
查看>>
Cesium实现背景透明的方法
查看>>
大数据系列修炼-Scala课程06
查看>>
windows下注册和取消pg服务的方法
查看>>
Dijkstra
查看>>
android webView 播放优酷视频
查看>>
退回win7后无法上网 的解决方法
查看>>