.Net 中各种线程同步锁

编程编的久了,总会遇到多线程的情况,有些时候我们要几个线程合作完成某些功能,这时候可以定义一个全局对象,各个线程根据这个对象的状态来协同工作,这就是基本的线程同步

支持多线程编程的语言一般都内置了一些类型和方法用于创建上述所说的全局对象也就是锁对象,它们的作用类似,使用场景有所不同。.Net中这玩意儿有很多,若不是经常使用,我想没人能完全记住它们各自的用法和相互的区别。为了便于查阅,现将它们记录在此。

ps:本文虽然关注 .Net 平台,但涉及到的大部分锁概念都是平台无关的,在很多其它语言(如_Java__)中都能找到对应。_


锁模式

正式介绍各种锁之前,先了解下锁模式——锁分为内核模式锁用户模式锁,后面也有了混合模式锁

内核模式就是在系统级别让线程中断,收到信号时再切回来继续干活。该模式在线程挂起时由系统底层负责,几乎不占用 CPU 资源,但线程切换时效率低。

用户模式就是通过一些 CPU 指令或者死循环让线程一直运行着直到可用。该模式下,线程挂起会一直占用 CPU 资源,但线程切换非常快。

长时间的锁定,优先使用内核模式锁;如果有大量的锁定,且锁定时间非常短,切换频繁,用户模式锁就很有用。另外内核模式锁可以实现跨进程同步,而用户模式锁只能进程内同步

本文中,除后半部分轻量级同步原语自定义锁为用户模式锁,其它锁都为内核模式。

lock 关键字

lock 应该是大多数开发人员最常用的锁操作,此处不赘述。需要注意的是使用时应 lock 范围尽量小,lock 时间尽量短,避免无谓等待。

Monitor

上面 lock 就是Monitor的语法糖,通过编译器编译会生成 Monitor 的代码,如下:

lock (syscRoot)
{
    //synchronized region
}
//上面的lock锁等同于下面Monitor
Monitor.Enter(syscRoot);
try
{
    //synchronized region
}
finally
{
    Monitor.Exit(syscRoot);
}

Monitor 还可以设置超时时间,避免无限制的等待。同时它还有 Pulse\PulseAll\Wait 实现唤醒机制。

ReaderWriterLock

很多时候,对资源的读操作频率要远远高于写操作频率,这种情况下,应该对读写应用不同的锁,使得在没有写锁时,可以并发读(加读锁),在没有读锁或写锁时,才可以写(加写锁)。ReaderWriterLock就实现了此功能。

主要的特点是在没有写锁时,可以并发读,而非一概而论,不论读写都只能一次一个线程。

MethodImpl(MethodImplOptions.Synchronized)

如果是方法层面的线程同步,除上述的lock/Monitor之外,还可以使用MethodImpl(MethodImplOptions.Synchronized)特性修饰目标方法。

SynchronizationAttribute

ContextBoundObject

要了解SynchronizationAttribute,不得不先说说ContextBoundObject

首先进程中承载程序集运行的逻辑分区我们称之为AppDomain(应用程序域),在应用程序域中,存在一个或多个存储对象的区域我们称之为Context(上下文)

在上下文的接口当中存在着一个消息接收器负责检测拦截和处理信息。当对象是MarshalByRefObject的子类的时候,CLR将会建立Transparent Proxy,实现对象与消息之间的转换。应用程序域是 CLR 中资源的边界。一般情况下,应用程序域中的对象不能被外界的对象所访问,而MarshalByRefObject 的功能就是允许在支持远程处理的应用程序中跨应用程序域边界访问对象,在使用.NET Remoting远程对象开发时经常使用到的一个父类。

ContextBoundObject更进一步,它继承 MarshalByRefObject,即使处在同一个应用程序域内,如果两个 ContextBoundObject 所处的上下文不同,在访问对方的方法时,也会借由Transparent Proxy实现,即采用基于消息的方法调用方式。这使得 ContextBoundObject 的逻辑永远在其所属的上下文中执行。

ps: 相对的,没有继承自 ContextBoundObjec t的类的实例则被视为上下文灵活的(context-agile),可存在于任意的上下文当中。上下文灵活的对象总是在调用方的上下文中执行。


一个进程内可以包括多个应用程序域,也可以有多个线程。线程可以穿梭于多个应用程序域当中,但在同一个时刻,线程只会处于一个应用程序域内。线程也能穿梭于多个上下文当中,进行对象的调用。

SynchronizationAttribute用于修饰ContextBoundObject,使得其内部构成一个同步域,同一时段内只允许一个线程进入。


WaitHandle

在查阅一些异步框架的源码或接口时,经常能看到WaitHandle这个东西。WaitHandle 是一个抽象类,它有个核心方法WaitOne(int millisecondsTimeout, bool exitContext),第二个参数表示在等待前退出同步域。在大部分情况下这个参数是没有用的,只有在使用SynchronizationAttribute修饰ContextBoundObject进行同步的时候才有用。它使得当前线程暂时退出同步域,以便其它线程进入。具体请看上述 SynchronizationAttribute 小节。

WaitHandle 包含有以下几个派生类:

  1. ManualResetEvent

  2. AutoResetEvent

  3. CountdownEvent

  4. Mutex

  5. Semaphore

ManualResetEvent

可以阻塞一个或多个线程,直到收到一个信号告诉 ManualResetEvent 不要再阻塞当前的线程。 注意所有等待的线程都会被唤醒。

可以想象 ManualResetEvent 这个对象内部有一个信号状态来控制是否要阻塞当前线程,有信号不阻塞,无信号则阻塞。这个信号我们在初始化的时候可以设置它,如ManualResetEvent event=new ManualResetEvent(false);这就表明默认的属性是要阻塞当前线程。

代码举例:

ManualResetEvent _manualResetEvent = new ManualResetEvent(false);

private void ThreadMainDo(object sender, RoutedEventArgs e)
{
    Thread t1 = new Thread(this.Thread1Foo);
    t1.Start(); //启动线程1
    Thread t2 = new Thread(this.Thread2Foo);
    t2.Start(); //启动线程2
    Thread.Sleep(3000); //睡眠当前主线程,即调用ThreadMainDo的线程
    _manualResetEvent.Set();   //有信号
}

void Thread1Foo()
{
    //阻塞线程1
    _manualResetEvent.WaitOne();
    
    MessageBox.Show("t1 end");
}

void Thread2Foo()
{
    //阻塞线程2
    _manualResetEvent.WaitOne();
    
    MessageBox.Show("t2 end");
}

AutoResetEvent

用法上和 ManualResetEvent 差不多,不再赘述,区别在于内在逻辑。

与 ManualResetEvent 不同的是,当某个线程调用Set方法时,只有一个等待的线程会被唤醒,并被允许继续执行。如果有多个线程等待,那么只会随机唤醒其中一个,其它线程仍然处于等待状态。

另一个不同点,也是为什么取名Auto的原因:AutoResetEvent.WaitOne()会自动将信号状态设置为无信号。而一旦ManualResetEvent.Set()触发信号,那么任意线程再调用 ManualResetEvent.WaitOne() 就不会阻塞,除非在此之前先调用anualResetEvent.Reset()重置为无信号。

CountdownEvent

它的信号有计数状态,可递增AddCount()或递减Signal(),当到达指定值时,将会解除对其等待线程的锁定。

注意:CountdownEvent 是用户模式锁。

Mutex

Mutex 这个对象比较“专制”,同时段内只能准许一个线程工作。

Semaphore

对比 Mutex 同时只有一个线程工作,Semaphore 可指定同时访问某一资源或资源池的最大线程数。


轻量级同步

.NET Framework 4 开始,System.Threading 命名空间中提供了六个新的数据结构,这些数据结构允许细粒度的并发和并行化,并且降低一定必要的开销,它们称为轻量级同步原语,它们都是用户模式锁,包括:

  • Barrier

  • CountdownEvent(上文已介绍)

  • ManualResetEventSlim (ManualResetEvent 的轻量替代,注意,它并不继承 WaitHandle)

  • SemaphoreSlim (Semaphore 轻量替代)

  • SpinLock (可以认为是 Monitor 的轻量替代)

  • SpinWait

Barrier

当在需要一组任务并行地运行一连串的阶段,但是每一个阶段都要等待其他任务完成前一阶段之后才能开始时,您可以通过使用Barrier类的实例来同步这一类协同工作。当然,我们现在也可以使用异步Task方式更直观地完成此类工作。

SpinWait

如果等待某个条件满足需要的时间很短,而且不希望发生昂贵的上下文切换,那么基于自旋的等待时一种很好的替换方案。SpinWait不仅提供了基本自旋功能,而且还提供了SpinWait.SpinUntil方法,使用这个方法能够自旋直到满足某个条件为止。此外 SpinWait 是一个Struct,从内存的角度上说,开销很小。

需要注意的是:长时间的自旋不是很好的做法,因为自旋会阻塞更高级的线程及其相关的任务,还会阻塞垃圾回收机制。SpinWait 并没有设计为让多个任务或线程并发使用,因此需要的话,每一个任务或线程都应该使用自己的 SpinWait 实例。

当一个线程自旋时,会将一个内核放入到一个繁忙的循环中,而不会让出当前处理器时间片剩余部分,当一个任务或者线程调用Thread.Sleep方法时,底层线程可能会让出当前处理器时间片的剩余部分,这是一个大开销的操作。

因此,在大部分情况下, 不要在循环内调用 Thread.Sleep 方法等待特定的条件满足。可以认为 SpinWait 是 Thread.Sleep 的轻量替换。它并非锁,但可以通过它实现自定义锁(下文会讲到)。

SpinLock是对 SpinWait 的简单封装。


自定义锁

由 SpinWait 使用方法易知,搭配一个或多个全局条件,就可以实现自定义锁。除此之外,还有一些东东,本身并不属于锁的范畴,但可借助以实现自定义锁,比如下面描述的volatileInterlocked

volatile 关键字

volatile最初是为了解决缓存一致性问题引入的。

缓存一致性

了解缓存一致性,首先要了解.Net/Java的内存模型(.Net 当年是诸多借鉴了 Java 的设计理念)。而 Java 内存模型又借鉴了硬件层面的设计。

我们知道,在现代计算机中,处理器的指令速度远超内存的存取速度,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为主存与处理器之间的缓冲。处理器计算直接存取的是高速缓存中的数据,计算完毕后再同步到主存中。

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主存。

而 Java 内存模型的每个线程有自己的工作内存,其中保留了被线程使用的变量的副本。线程对变量的所有的操作都必须在工作内存中完成,而不能直接读写主内存中的变量。不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

虽然两者的设计相似,但是前者主要解决存取效率不匹配的问题,而后者主要解决内存安全(竞争、泄露)方面的问题。显而易见,这种设计方案引入了新的问题——缓存一致性(CacheCoherence)——即各工作内存、工作内存与主存,它们存储的相同变量对应的值在同一时刻可能不一样。


为了解决这个问题,很多平台都内置了 volatile 关键字,使用它修饰的变量,可以保证所有线程每次获取到的是最新值。这是怎么做到的呢?这就要求所有线程在访问变量时遵循预定的协议,比如MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等,此处不赘述,只需要知道系统额外帮我们做了一些事情,多少会影响执行效率。

另外 volatile 还能避免编译器自作聪明重排指令。重排指令在大多数时候无伤大雅,还能对执行效率有一定提升,但某些时候会影响到执行结果,此时就可以使用 volatile。

Interlocked

同 volatile 的可见性作用类似,Interlocked 可为多个线程共享的变量提供原子操作,这个类是一个静态类,它提供了以线程安全的方式递增、递减、交换和读取值的方法。

它的原子操作基于 CPU 本身,非阻塞,所以也不是真正意义上的锁,当然效率会比锁高得多。

原子操作

计算机中的原子操作有两层含义:

  • 在执行过程中不会被中断或干扰的操作,是不可分割的操作单元,要么全部执行成功,要么全部不执行;

  • 多线程/进程对“同时”进行同一个原子操作,不会相互产生干扰导致预期之外的结果。这在单核和多核情况下又有不同考量——在单核 CPU 中,原子操作通常是指在一个指令周期内可以完成的操作,不会被中断,例如赋值、递增、递减等操作;在多核 CPU 中,原子操作需要考虑多个核心同时访问共享资源的情况,需要使用特殊的机制来确保操作的原子性,如硬件支持的原子指令或锁机制。

这两个特点,使得原子操作可作为内部条件实现自定义锁。

文章转载自: 莱布尼茨

原文链接:https://www.cnblogs.com/newton/p/18365359

体验地址:引迈 - JNPF快速开发平台_低代码开发平台_零代码开发平台_流程设计器_表单引擎_工作流引擎_软件架构

1.几种同步方法的区别 lock和Monitor是.NET用一个特殊结构实现的,Monitor对象是完全托管的、完全可移植的,并且在操作系统资源要求方 面可能更为有效,同步速度较快,但不能跨进程同步。lock(Monitor.Enter和Monitor.Exit方法的封装),主要作用是定临界区,使临 界区代码只能被获得的线程执行。Monitor.Wait和Monitor.Pulse用于线程同步,类似信号操作,个人感觉使用比较复杂,容易造成死 。 互斥体Mutex和事件对象EventWaitHandler属于内核对象,利用内核对象进行线程同步,线程必须要在用户模式和内核模 式间切换,所以一般效率很低,但利用互斥对象和事件对象这样的内核对象,可以在多个进程的各个线程间进行同步。 互斥体Mutex类似于一个接力棒,拿到接力棒的线程才可以开始跑,当然接力棒一次只属于一个线程(Thread Affinity),如果这个线程不释放接力棒(Mutex.ReleaseMutex),那么没办法,其他所有需要接力棒运行的线程都知道能等着看热 闹。 EventWaitHandle 类允许线程通过发信号互相通信。 通常,一个或多个线程在 EventWaitHandle 上阻止,直到一个未阻止的线程调用 Set 方法,以释放一个或多个被阻止的线程。 2.什么时候需要定 首先要理解定是解决竞争条件的,也就是多个线程同时访问某个资源,造成意想不到的结果。比如,最简单的情况是,一个计数器,两个线程 同时加一,后果就是损失了一个计数,但相当频繁的定又可能带来性能上的消耗,还有最可怕的情况死。那么什么情况下我们需要使用,什么情况下不需要 呢? 1)只有共享资源才需要定 只有可以被多线程访问的共享资源才需要考虑定,比如静态变量,再比如某些缓存的值,而属于线程内部的变量不需要定。 2)多使用lock,少用Mutex 如果你一定要使用定,请尽量不要使用内核模块的定机制,比如.NET的Mutex,Semaphore,AutoResetEvent和 ManuResetEvent,使用这样的机制涉及到了系统在用户模式和内核模式间的切换,性能差很多,但是他们的优点是可以跨进程同步线程,所以应该清 楚的了解到他们的不同和适用范围。 3)了解你的程序是怎么运行的 实际上在web开发大多数逻辑都是在单个线程展开的,一个请求都会在一个单独的线程处理,其的大部分变量都是属于这个线程的,根本没有必要考虑 定,当然对于ASP.NET的Application对象的数据,我们就要考虑加了。 4)把定交给数据库 数 据库除了存储数据之外,还有一个重要的用途就是同步,数据库本身用了一套复杂的机制来保证数据的可靠和一致性,这就为我们节省了很多的精力。保证了数据源 头上的同步,我们多数的精力就可以集在缓存等其他一些资源的同步访问上了。通常,只有涉及到多个线程修改数据库同一条记录时,我们才考虑加。 5)业务逻辑对事务和线程安全的要求 这 条是最根本的东西,开发完全线程安全的程序是件很费时费力的事情,在电子商务等涉及金融系统的案例,许多逻辑都必须严格的线程安全,所以我们不得不牺牲 一些性能,和很多的开发时间来做这方面的工作。而一般的应用,许多情况下虽然程序有竞争的危险,我们还是可以不使用定,比如有的时候计数器少一多一, 对结果无伤大雅的情况下,我们就可以不用去管它。 3.InterLocked类 Interlocked 类提供了同步对多个线程共享的变量的访问的方法。如果该变量位于共享内存,则不同进程的线程就可以使用该机制。互操作是原子的,即整个操作是不能由相 同变量上的另一个互操作所断的单元。这在抢先多线程操作系统是很重要的,在这样的操作系统,线程可以在从某个内存地址加载值之后但是在有机会更改 和存储该值之前被挂起。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值