当前位置:网站首页 / C#人爱学不学 / 正文

转载【CLR】C#线程同步和锁-----看这篇就够了

时间:2022年11月25日 | 作者 : aaronyang | 分类 : C#人爱学不学 | 浏览: 229次 | 评论 0


原文地址:查看

多个线程同时访问共享数据的时候,线程同步能够防止线程损坏。之所以强调同时,是因为线程同步问题其实就是访问时间问题。如果有些数据会被多个线程访问,但是这几个线程访问的时间都是错开的,不会同时接触到数据,那完全就用不到线程同步。


线程同步会遇到几个问题,所以能避免使用就别用:


使用繁琐,容易出错:你需要清楚的明白哪些数据可能是会被同时访问的,然后用一个锁锁住它,确保一次只有一个线程能够访问。如果任何一块数据被漏了,那就会有问题。

没法确定你所有用锁的方式一定正确:只能靠测试和经验。

损害性能:获取和释放锁时要调用一些额外的方法;不同的CPU也要协调来决定哪个线程应先取得锁。具体会慢多少,取决于你使用什么样的锁。

阻塞一个线程会导致更多的线程被创建:线程阻塞之后,CPU可能处于空闲状态。线程池为了使CPU保持“饱和”状态,可能会创建一个新的线程。而且之前也讲过线程池会根据完成任务需要的时间,动态的创建和销毁线程。创建线程会耗费大量的内存和时间。当阻塞的线程恢复之后,会与新创建的那个线程共存,导致线程池线程数超过CPU数,操作系统就需要调度不同的线程到CPU执行,会增大上下文切换的概率。

所以,写代码时尽量避免进行线程同步。具体的说就是少用静态字段共享数据,自己线程拥有的对象别暴露给其它线程,使用值类型。



1. 基元构造

这四个字什么意思?基元类型我们知道有Int32,Int64,Byte这种。构造我们知道有构造函数这种。组合起来就有点难理解啥意思了。我个人认为这类似于一种原子操作,一段不可拆分的执行代码。也可以理解为锁。


基元构造分为两种:


用户模式构造:


使用了特殊的CPU执行来协调线程,协调是在硬件中进行,所以构造速度快;

因为是在硬件中,所以操作系统无法检测一个线程是否阻塞;

线程池不会创建新的线程来替换这种阻塞线程;

CPU只会阻塞线程很短的时间,然后将被再次执行,然后再次阻塞;优先使用这种;

内核模式构造:


此种构造由windows本身提供;

线程由用户模式切换到内核模式会有巨大的性能损失;

操作系统能够检测一个线程是否阻塞,所以可以将其挂起,节省CPU时间;

混合构造:


一种理想的构造,兼有二者的长处。

在资源不用竞争时,应该像用户构造一样快。

如果资源需要竞争了,应该像内核构造一样阻塞

这种构造更常见,因为一般程序很少会有两个或多个线程同时访问数据。

死锁(deadlock)与活锁(livelock):

死锁:内核模式构造,占用资源的线程一直不释放,导致其他线程被阻塞挂起。

活锁:用户模式构造,占用资源的线程一直不释放,其他线程被阻塞空转(自旋)。


1.1 用户模式构造

CLR对Boolean、Char、Byte、SByte、UInt16、Int16、UInt32、Int32、UIntPtr、IntPtr、Single类型的读写保证是原子的。但对(U)Int64、Double等类型的读写不保证是原子的,即当赋值X时:


obj.X=0x123456789abcdef;

1

如果有其它线程在读X,则他们可能读到0x1234567000000000x000000009abcdef

虽然对Boolean等类型的读取和写入时原子的,但是因为编译器和CPU的优化可能导致还没写入就已经开始读取了,或者对Int64的写入还没有完成,就开始读取了。所以有两种形式的用户模式构造可以规范什么时候写好了什么时候可以开始读:


易变构造volatile construct:在一个特定时间内,它在简单类型上执行原子性的读或写操作。

互锁构造interlocked construct: 在一个特定时间内,它在简单类型上执行原子性的读和写操作。


1.1.1 易变构造

class
{
    int a=0;
    int b=0;
    void Thread1()
    {
        //编译器发现以下两行代码互换顺序也没啥影响,所以编译器优化后这两行代码的顺序是不一定的。
        b=5;
        a=1;
    }
    void Thread2()
    {
        if(a==1)
        {
            Console.WriteLine(b);
        }
    }
}

由于编译的优化,所以输出有可能是0也有可能是5。C# 提供了System.Threading.Volatile类来禁止CPU做这些优化。

class
{
    int a=0;
    int b=0;
    void Thread1()
    {
        b=5;
        Volatile.Write(ref a,1);//确保a是最后被写入的。
    }
    void Thread2()
    {
        if(Volatile.Read(ref a)==1)//确保a是最先被读取的
        {
            Console.WriteLine(b);
        }
    }
}


1.1.2 互锁构造

易变构造执行的是一次原子性的读或原子性的写。System.Threading.Interlocked静态类提供了一次性的读和写操作。我们知道++i不是原子的,那么如何解决呢,就是使用Interlocked.Increment(ref a);初次之外还有:

public static Int32 Increment(ref Int32 location);//++i
public static Int32 Decrement(ref Int32 location);//--i
public static Int32 Add(ref Int32 location, Int32 value)//value可以正负,
//赋值操作,将value赋值给location1,并将原location1的值返回。
public static Int32 Exchange(ref Int32 location1,Int32 value);


interlocked的所有方法都建立了完整的内存栅栏,会确保调用某个方法前,对这个变量的写入都已完成。且对这个变量的读取,都得在这个方法执行之后。Interelocked支持intlong,不支持其他类型。借用Interlocked的Exchange方法我们可以实现一个简单的自旋锁,此锁自旋时耗cpu:

struct MySpinLock
{
    int isInUse;//0==false,1==true;
    public void Enter()
    {
        while(true)
        {
            if(Interlocked.Exchange(ref isInUse,1)==0)
            {
                return;
            }
        }
    }
    public void Leave()
    {
        Interlocked.Exchange(ref isInUse,0);
    }
}

自旋锁,一般不要在单CPU机器上运行,容易造成活锁。低优先的线程占用资源,而高优先级的线程因为获取不到资源而一直自旋等待。我们的MySpinLock和System.Threding.SpinLock都是结构体类型,这意味着很轻量,内存友好。因为是值类型,所以不要传递spinlock的实例。



1.2 内核模式构造

内核模式构造比用户模式构造慢,因为:


因为需要windows操作系统自身的配合

内核对象上调用的每个方法都造成调用线程从托管代码转为用户模式代码再转为内核模式代码,然后再朝反方向一路返回。这些转换需要大量CPU时间。


但是也有如下优点:


可实现本机(native)和托管(managed)线程相互之间的同步

同步的线程可处于同一台机器的不同进程中。(这也就是为什么mutex可以用来防止一个exe两次启动的原因)

可应用安全设置,防止未授权的账户访问

线程可以一直阻塞,直到某一个资源可用(WaitAny),或所有资源都可用(WaitAll)。

阻塞可以指定超时值,超时之后就解除阻塞执行其他任务。


内核模式构造分为两种:事件Event和信号量Semaphore。其它的构造,如互斥体Mutex则是由这两部分构成的。.net提供了一个抽象类WaitHandle,而event、semaphore、mutex都继承了这个类。


结构如下:

WaitHandle

        EventWaitHandle

                AutoResetEvent

                ManualResetEvent

        Semaphore

        Mutex




WaitHandle提供三个公共方法WaitOne\WaitAll\WaitAny,每个方法都代表一个完整的内存栅栏。

public virtual bool WaitOne();

public virtrul bool WaitOne(TimeSpan timeout);//可设置超时时间


public static bool WaitAll(WaitHandle[] handles);//等待全部handles的信号

public static bool WaitAll(WaitHandle[] handles,TimeSpan timeout);//超时


public static int WaitAny(WaitHandle[] handles);//等待handles里某个信号

public static int WaitAny(WaitHandle[] handles,TimeSpan timeout);


内核构造常见一个用途是创建在任何时刻值允许它的一个实例运行的应用程序,可以简单理解为一个exe只能单开:

static void Main()
{
    using(var sem=new Semaphore(0,1,"UniqueName",out bool createNew)
    {
        if(createNew)
        {
            //todo ...
        }
        else
        {
            //exe已经在运行了,退出
            Environment.Exit(0);
        }
    }
}

windows内核会确保指定名称的内核对象,只能由一个线程创建。另外一个进程里的线程虽然没法创建,但是他可以直接使用这个sem实例(进程间通讯)。上述代码使用的是semaphore但EventWaitHandle和Mutex也类似。


1.2.1 Event构造

Event本质就是操作系统内核维护的bool变量,false时就阻塞,true就通过。


.net提供了自动重置事件AutoResetEvent和手动重置事件ManualResetEvent,这两个类继承自EventWaitHandle

调用基类的Set()方法,这个内核变量会被设为true,表示解锁。Reset()方法,用来设为false,上锁。

当AutoResetEvent的bool变量为true时,它只唤醒一个阻塞的线程,因为在解除这个阻塞的线程之后,内核会把它自动重置为false。

当ManualResetEvent的bool变量为true时,它将唤起所有被阻塞的线程,需要你手动设置为false,才能继续阻塞线程。

一个用自动重置事件实现的线程同步锁:

class SimpleWaitLock:IDisposable
{
    readonly AutoResetEvent are;
    public SimpleWaitLock()
    {
        are=new AutoResetEvent(true);
    }
    public void Enter()
    {
        are.WaitOne();
    }
    public void Leave()
    {
        are.Set();
    }
    public void Dispose()
    {
        are.Dispose();
    }
}

SimpleWaitLock因为是内核模式构造,所以当资源不存在竞争时会比之前的SimpleSpinLock慢很多,当资源存在竞争时因为阻塞线程不会出现自旋所以不会浪费CPU时间。




1.2.2 Semaphore构造

信号量其实就是内核维护的int变量。等于0时就阻塞,大于0就不阻塞,一个线程从阻塞变为不阻塞那么变量就减1。

所以这种逻辑就可以用来设置最多允许多少个线程并发访问一个资源。

class Semaphore:WaitHandle
{
    public Semaphore(int initCount,int maxCount);
    public int Release();//变量的值增加1
    public int Release(int releaseCount);//变量的值增加releaseCount
    
}

是不是感觉AutoResetEvent就和maxCount=1的Semaphore非常相似。二者区别是,AutoResetEvent即使你多次调用Set方法,也只会有一个线程解除阻塞,但是Semaphore的Release()方法你调用一次就会有一个线程解除阻塞,超过maxCount的时候,就抛异常了。


接下来用Semaphore来实现同一个SimpleWaitLock:

classs SimpleWaitLock:IDisposable
{
    Semphore sem;
    public SimpleWaitLock(int maxCount)
    {
        sem=new Semphore(maxCount,maxCount);
    }
    public void Enter()
    {
        sem.WaitOne();
    }
    public void Leave()
    {
        sem.Release(1);
    }
    public void Dispose()
    {
        sem.Close();
    }
}

1.2.3 Mutex构造(支持递归)

Mutex代表一个互斥的锁,和AutoResetEvent、maxCount为1的Semaphore一样,一次都只能释放一个线程。但是Mutex还有一些其它复杂逻辑:

Mutex会记录下来哪个线程调用了它,记下来它的ID,只有这个ID的线程才能调用释放锁(ReleaseMutex),否则就抛出异常。

拥有Mutex的线程如果在释放这个Mutex之前被任何原因终止,则阻塞线程会收到AbandonedMutexException而被唤醒。这个异常通常不会被捕获,所以会导致整个进程终止。避免了其它线程访问脏数据。

Mutex支持递归,拥有一个递归计数器,指出拥有改Mutex的线程拥有了它多少次。拥有一次就加1,释放一次就减1,当计数器为0时,这个mutex才能被其它线程拥有。AutoResetEvent就不支持递归,当拥有AutoResetEvent的线程再次调用waitone时候就会被阻塞,造成死锁。

正由于Mutex的以上功能,造成Mutex会有额外的内存开销用来记录线程ID和递归计数,所以Mutex锁会比较慢,如果真的需要有递归功能的锁的话可以使用AutoResetEvent来实现。



1.3 混合构造

直到第一次有线程在一个资源上存在竞争时,才会创建内核模式构造。如果一直都没有竞争,应用程序就可以避免因创建对象而产生性能损失,同时避免为对象分配内存。FCL提供的混合构造有:

1.3.1 ManualResetEventSlim和SemaphoreSlim

这两个构造的工作方式和ManualResetEvent、Semaphore完全一致,只不过多了自旋功能,都是等到第一次资源有竞争时,才会创建内核模式的构造。他们的Wait方法允许传入一个超时和一个CancellationToken。

1.3.2 Monitor和同步块

Monitor这是一个比较常用的混合构造类,支持自旋、线程所有权、递归。


同步块:堆中的每个对象都可以关联一个名为同步块的数据结构。同步块为内核对象、对象的拥有线程、递归计数、等待线程计数提供了相应字段。Monitor主要是对这个同步块进行操作(将对象传入Monitor的Enter等方法)。


因为大多数对象都不会被Monitor使用(Monitor.Enter(obj)),所以如果为堆里的每个对象都分配一个同步块的话,就显得很浪费。


CLR采用了一种更经济的做法:CLR初始化时在堆中分配一个同步块数组,当一个对象被构造时,它没有任何同步块,同步块索引为-1,当Monitor.Enter调用这个对象时,就在同步块数组里找到一个同步块,并将这个同步块分配那个对象,并设置相应的同步块索引。当调用Monitor.Exit方法时,会判断还有没有其他线程需要使用对象的同步块(因使用不到同步块而等待的线程),如果没有,就再次将对象的同步块索引设置为-1,这样对象和同步块就脱离了关系。


因为Monitor被设计成了静态类,所以新手使用时如果想达到互斥访问资源的效果,尤其要注意传给Enter和Exit方法的对象:


不能是值类型,因为Enter接受的是引用类型,所以装箱之后,会变成一个新的对象。

最好不要用字符串,因为字符串拘留池的存在,两个字符串可能是同一个引用。会造成本来是一个异步的程序,在某种情况下却以同步的方式在执行。

最好的做法就是new一个static的readonly对象传到Enter和Exit方法中,确保这两个方法使用的是同一个对象。

除了Monitor还有一个关键字:lock,这个关键字是对Monitor的一个封装,等价于以下写法:

void MehthodA()
{
    bool lockToken=false;
    try
    {
        Monitor.Enter(this,ref lockToken);
        //do something
    }
    finally
    {
        if(lockToken) Monitor.Exit(this);
    }
}

1.3.3 ReaderWriterLockSlim类

简单理解就是读写锁,写的时候只有一个线程能写,读的时候所有的线程都可以读。但是:


一个线程正在写入时,其它所有的读线程和写线程都被阻塞。

写线程结束之后,要么解除某个写线程的阻塞,使他能够写入;要么解除所有读线程的阻塞,使所有的读线程都能读取。

一个线程正在读时,其它所有的写线程都被阻塞,所有读线程都可以执行。

如果所有的读线程都读完走了,那就解除一个写线程的阻塞,使他能够写入数据。

class A :IDsiposable
{
    readonly ReaderWriteLockSlim m_lock=new ReaderWriteLockSlim(LockRecursionPolicy.NoRecursion);//不需要支持递归和所有权,否则性能有很大影响
    DateTime m_timeOfLastTrans;
    public void PerformTransaction()
    {
        m_lock.EnterWriteLock();
        m_timeLastTrans=DateTime.Now;
        m_lock.ExitWriteLock();
    }
    public DateTime LastTransaction()
    {
        m_lock.EnterReadLock();
        var temp=m_timeofLastTrans;
        m_lock.ExitReadLock();
        return tem;
    }
}

这个类还支持将某个读线程升级为写线程,设计者可能是考虑到一个线程刚开始是读数据,但是根据读到数据的不同,可能又需要写数据。但是读线程不是立即升级为写线程,而是等其他所有读线程都完全退出锁的时候,再立即获取这个锁进行写入。


下面列举出了这个锁出现的几个问题:


即使不存在线程竞争资源,它的速度也很慢

线程所有权和递归是在构造里的,完全取消不了,使锁变得更慢

更喜欢唤醒读线程,所以写线程可能会被阻塞很多,造成拒绝服务问题


1.3.4 CountdownEvent类

这个类使用一个ManualResetEventSlim对象,当内部的CurrentDown字段值大于0时,它就阻塞一个线程,阻塞一个值就减1。当值等于0时,就放行所有的线程。而且变成0之后,就不能再被更改了。正好和Semaphore相反。

1.3.5 Barrier类

System.Threading.Barrier用来协调一系列线程的同步工作。应用场景:N个线程共同进行第一阶段的工作,如果某个线程完成的比较快,则需要等待其它线程。当这N个线程,第一阶段的工作都完成之后,再进行第二阶段。第二阶段也是这么来,然后再进行第N阶段。


要避免阻塞线程,就不要手动去创建各种各样的线程,如监控线程、上传线程、检查线程等。这种工作都交给线程池。

如果需要阻塞在不同进程中运行的线程,就使用内核构造。

尽量避免使用递归锁,如递归的read-write锁。Monitor锁也是递归,但是性能还可以(使用的是native代码)。


3. 异步锁

到目前为止我们看到的锁都是当获取不到资源时需要阻塞在那里,啥也干不成,干等。异步锁就是解决这个问题,提供了异步等待的方法(WaitAsync),当资源需要竞争时,它不会阻塞在那里,而是直接跳过去,去执行其他任务。当它获取到锁时,再回过来读取资源。

如SemaphoreSlim的WaitAsync方法,就可以异步等待(异步阻塞)。

SeamphoreSlim不支持线程所有权和递归。







推荐您阅读更多有关于“”的文章

猜你喜欢

额 本文暂时没人评论 来添加一个吧

发表评论

必填

选填

选填

必填

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

  查看权限

抖音:wpfui 工作wpf,目前主maui

招聘合肥一枚WPF工程师,跟我一个开发组,10-15K,欢迎打扰

目前在合肥市企迈科技就职

AYUI8全源码 Github地址:前往获取

杨洋(AaronYang简称AY,安徽六安人)AY唯一QQ:875556003和AY交流

高中学历,2010年开始web开发,2015年1月17日开始学习WPF

声明:AYUI7个人与商用免费,源码可购买。部分DEMO不免费

不是从我处购买的ayui7源码,我不提供任何技术服务,如果你举报从哪里买的,我可以帮你转正为我的客户,并送demo

查看捐赠

AYUI7.X MVC教程 更新如下:

第一课 第二课 程序加密教程

标签列表