【C#】线程同步--《C#本质论》

【C#】线程同步--《C#本质论》_第1张图片

目录

 一、使用Monitor来同步

二、使用lock来同步

三、避免锁定 this、typeof(type)和 string

四、Interlocked 类

五、避免死锁

六、更多同步类型

(1)Mutex

(2) WaitHandle

(3)重置事件类:ManualResetEvent和ManualResetEventSlim

(4)Semaphore / SemaphoreSlim 和 CountdownEvent

(5)并发集合类

七、线程本地存储

(1)ThreadLocal

 (2)ThreadStaticAttribute

(3) GetData() 和 SetData()

(4)AsyncLocal


线程同步的作用是避免死锁的同时防止出现竞态条件。若能同步多个线程对代码或数据的并发访问,就说这些代码和数据是线程安全的。

关于变量读写的原子性,有一个重点需要注意:假如类型的大小不超过一个本机(指针大小的)整数,“运行时”就保证该类型不会被部分性地读取或写入。所以,64位操作系统保证能够原子性地读写一个long (64位)。然而,128位变量(比如decimal)的读写就不保证是原子性的。所以,通过写操作来更改一个decimal变量时,可能会在仅仅复制了32位之后被打断,造成以后读取一个不正确的值,这称为一次torn read (被撕裂的读取)。

局部变量没有必要同步。局部变量加载到栈上,而每个线程都有自己的逻辑栈。所以,针对每个方法调用,每个局部变量都有自己的实例。在不同的方法调用之间,局部变量默认是不共享的。因此,它们在多个线程之间也是不共享的。然而,这并不是说局部变量完全没有并发性问题,因为代码可能轻易向多个线程公开局部变量”。例如,在迭代之间共享局部变量的并行for循环就会公开变量,使其可能被并发访问,从而造成一个竞态条件。s

    public static void Main()
    {
        int x = 0;
        Parallel.For(0, 10000, i => { x++; x--; });
        Console.WriteLine($"Count={x}");
    }

使用await/async模式重构代码

    public static async void CountAsync()
    {
        int count = 0;
        Task task = Task.Run(() =>
        {
            for(int i=0;i<1000; i++)
            {
                count++;
                count--;
            }
        });
        await task;
        Console.WriteLine($"Count2={count}");
    }

Count1=17
Count2=0

Count1=108
Count2=0 

 一、使用Monitor来同步

为了同步多个线程,防止它们同时执行特定的代码段,需要用监视器(monitor)来阻止第二个线程进入受保护的代码段,直到第一个线程退出那个代码段。监视器功能由System.Threading.Monitor类提供。为了标识受保护代码段的开始和结束位置,需要分别调用静态方法Monitor.Enter()和Monitor.Exit()。

    readonly static object sync=new object();
    const int total=1000;
    static long count=0;
    public static void Main()
    {
        Task task = Task.Run(() => Decrement());

        for (int i = 0; i < total; i++)
        {
            bool lockTaken = false;
            try
            {
                Monitor.Enter(sync,ref lockTaken);
                count++;
            }
            finally
            {
                if(lockTaken)
                {
                    Monitor.Exit(sync);
                }
            }
        }

        task.Wait();
        Console.WriteLine($"Count={count}");
    }

    private static void Decrement()
    {
        for(int i = 0; i < total; i++)
        {
            bool lockToken=false;
            try
            {
                Monitor.Enter(sync,ref lockToken);
                count--;
            }
            finally
            {
                if(lockToken)
                {
                    Monitor.Exit(sync);
                }
            }
        }
    }

Monitor还支持Pulse()方法,允许线程进入“就绪队列” (ready queue),指出下一个就轮到它获得锁。这是同步生产者一消费者模式的一种常见方式,目的是保证除非有“生产”,否则就没有“消费”。拥有监视器的生产者线程调用 Monitor.Pulse() 通知消费者线程一个项(item)已生产好,请“准备” (get ready)消费。

一个Pulse()调用只允许一个线程(消费者)进入就绪队列。生产者线程调用Monitor.Exit() 时,消费者线程将取得锁并进入关键执行区域以开始“消费”。消费者处理好等待处理的项以后,就调用Exit(),从而允许生产者(当前正由Monitor.Enter()阻塞)再次生产其他项。在本例中,一次只有一个线程进入就绪队列,确保没有“生产”就没有“消费”,反之亦然。

二、使用lock来同步

    readonly static object sync=new object();
    const int total=1000;
    static long count=0;
    public static void Main()
    {
        Task task = Task.Run(() => Decrement());

        for (int i = 0; i < total; i++)
        {
            lock (sync)
            {
                count++;
            }
        }
        task.Wait();
        Console.WriteLine($"Count={count}");
    }

    private static void Decrement()
    {
        for(int i = 0; i < total; i++)
        {
            lock (sync)
            {
                count--;
            }
        }
    }

三、避免锁定 this、typeof(type)和 string

this 关键字代表类中实例数据,锁定 this,可以为与一个特定对象实例关联的所有状态提供一个同步目标

typeof(type) 获取类型实例,锁定 typeof(type),可以为一个类型的所有静态数据提供一个同步目标

这样做的问题在于,其它地方可能会创建有一个完全不同的同步块,同步目标就是 this 或者 typeof(type) 所指向的同步目标。换言之,虽然只有实例自身内部的代码可以使用this关键字来阻塞,但创建实例的调用者仍可将那个实例传给一个同步锁。结果就变成了对两套不同的数据进行同步的两个同步块可能互相阻塞对方。虽然看起来不太可能,但是共享同一个同步目标可能影响性能,极端的时候甚至会造成死锁。

因此,尽量不要在 this 或者 typeof(type) 上锁定。更好的做法是定义一个私有只读字段,除了能访问它的那个类之外,没有谁能在这个字段上阻塞。

要避免的另一个锁定类型是 string,这是考虑到字符串留用 (string interning)问题。如果同一个字符串常量在多个位置出现,那么所有位置都可能引用同一个实例,使锁定的范围大于预期。

字符串留用机制: 公共语言运行库通过维护一个叫做 “拘留池” 的表来存放字符串,该表包含程序中以编程方式声明或创建的每个唯一的字符串的一个引用。因此,具有特定值的字符串实例在系统中只有一个。例如,如果将同一字符串分配给几个变量,运行库就会从拘留池中检索对该字符串的相同引用,并将它分配给各个变量。

四、Interlocked 类

 lock 或者 Monitor 会带来比较大的性能损失,还有一个备选方案:Interlocked,它通常直接由处理器支持,而且面向特定的同步模式,对于一些简单的操作运算, Interlocked 可以实现原子性的操作。

【C#】线程同步--《C#本质论》_第2张图片

五、避免死锁

两个或更多的线程都在等待对方释放同一个同步锁,就会发生死锁。死锁发生必须满足4个基本条件:

  • 排他或互斥(Mutual exclusion):一个线程独占一个资源,没有其它线程能获取相同的资源
  • 占有并等待 (Hold and wait):一个线程请求获取与它互斥的另一个线程占有的资源。
  • 不可抢先(No preemption):一个线程占有的资源不能被强制拿走,只能等它自己释放。
  • 循环等待条件(Circular wait condition):两个或多个线程构成一个循环等待链,它们锁定两个或多个相同的资源,每个线程都在等待链中的下一个线程占有的资源。

移除其中任何一个条件,就可以阻止死锁发生。

有可能造成死锁的一个情形是:两个或多个线程请求独占相同的两个或多个同步目标(资源)的所有权,而且以不同的顺序请求锁。如果保证多个锁总是以相同的顺序获得,就可避免这个情形。

发生死锁的另一个原因是不可重入(reentrant)的锁。如果来自一个线程的锁可能阻塞同一个线程(它重新请求同一个锁),这个锁就是不可重入的。例如,假定Threada获取一个锁,然后重新请求同一个锁,但由于锁已被拥有,所以发生了阻塞,那么这个锁就是不可重入的,额外的请求会造成死锁。所以,不可重入的锁只在单线程的时候发生。

lock关键字(通过底层的Monitor类)生成的代码是可重入的。

六、更多同步类型

(1)Mutex

概念上和 Mointor 几乎一致(没有pulse()方法支持),可以命名不同的 Mutex 来支持多个进程之间的同步,可用 Mutex 类同步对文件或者其它跨进程资源的访问。

    public static void Main()
    {
        bool firstApplicationInstance;
        string  utexName = Assembly.GetEntryAssembly().FullName;

        //第一个参数指定互斥最初是否应由主调线程拥有
        //第二个参数为互斥量的名字,操作系统中只会有一个该名的互斥量,有名字的互斥可以由不同的进程共享
        //第三个参数接收的bool值表示互斥是否为新建的,false表示互斥已定义
        using(Mutex mutex = new Mutex(false, utexName, out firstApplicationInstance))
        {
            if(!firstApplicationInstance)
            {
                Console.WriteLine("This application is already running");
                Console.ReadLine();
            }
            else
            {
                Console.WriteLine("Enter to shutdown");
                Console.ReadLine();
            }
        }
    }

运行第一个实例:

Enter to shutdown

保持第一个实例,运行第二个实例:

This application is already running

Mutex 派生自 WaitHandle,所以它包含WaitAll()、WaitAny() 和 SignalAndWait() 方法,可以自动获取多个锁(这是Monitor类不支持的)。

(2) WaitHandle

WaitHandle 是一个虚拟类,一般不直接用,而是使用它的派生类:

  • AutoResetEvent:表示线程同步事件在一个等待线程释放后收到信号时自动重置
  • ManualResetEvent:表示线程同步事件,收到信号时,必须手动重置该事件
  • EventWaitHandle:EventWaitHandle 的功能结合了前面两种。在构造函数中传入不同的参数,就会又不同的功能。
  • Mutex
  • Semaphore

等同步类使用的一个基础同步类。关键方法是 WaitOne(),会阻塞当前线程,直到 WaitHandle 实例收到信号或者被设置 (调用Set())。

WaitHandle 还有两个关键的静态成员:WaitAll() 和 WaitAny()。和它们的实例版本相似,这两个静态成员也支持超时。除此之外,它们要获取一个WaitHandle集合(以一个数组的形式),使它们能响应来自集合中的任何 WaitHandle 的信号。

关于WaitHandle最后要注意的一点是,它包含了一个实现了 IDisposable 的 SafeWaitHandle 类型的句柄。所以,在WaitHandle不再需要的时候,注意要对它们进行资源清理(dispose)。

(3)重置事件类:ManualResetEvent和ManualResetEventSlim

假如不进行控制,一个线程的指令相对于另一个线程中的指令的执行时机是不确定的。对这种不确定性进行控制的一个办法是使用重置事件(reset event)。虽然名称中有事件一词,但重置事件和C#的委托以及事件没有任何关系。重置事件用于强迫代码等候另一个线程的执行,直到获得事件已经发生的通知。它们尤其适合用来测试多线程代码,因为有时需要先等待一个特定的状态,才能对结果进行验证。

重置事件类型包括ManualResetEvent、ManualResetEventslim 以及 AutoResetEvent,但应尽量避免使用第三个。ManualResetEventslim 的性能更好,虽然它可能会使用更多的CPU周期。一般情况下应使用 ManualResetEventslim,除非需要等待多个事件,或者需要跨越多个进程。

AutoResetEvent 与 ManualResetEvent 相似,它允许线程 A 通过一个 Set() 调用通知线程 B 线程A已经抵达代码中的一个特定的位置。区别在于,AutoResetEvent 只解除一个线程的wait()调用所造成的阻塞,因为在线程 A 自动重置后,它会恢复锁定状态。然而,如果使用自动重置事件,很容易在编写生产者线程时发生失误,造成它的迭代次数多于消费者线程。因此,一般情况下最好使用 Monitor 的 Wait()/Pulse() 模式,或者使用一个信号量(如果少于个线程能参与一个特定的阻塞)。和AutoResetEvent相反,除非显式调用Reset(),否则 ManualResetEvent 不会恢复到未收到信号之前状态。

(4)Semaphore / SemaphoreSlim 和 CountdownEvent

Semaphore/SemaphoreSlim 在性能上的差异和 ManualResetEvent / ManualResetEventSlim 一样。ManualResetEvent / ManualResetEventslim 提供了一个要么打开要么关闭的锁(就像道门)。

和它们不同的是,信号量( semaphore ) 限制的只是在一个关键执行区域中同时通过的调用。信号量本质上是保持了对资源池的一个计数。计数为0时,就阻止对资源池更多访问,直到其中的一个资源返回。有可用的资源之后,就可以把它拿给队列中的下一个已阻寒的请求。

CountdownEvent和信号量相似,只是它实现的是反向同步。不是阻止对已经枯竭的一个资源池的访问,而是只有在计数为0时才允许访问。例如,假定一个并行操作是下载多个股票价格。只有在所有价格都下载完毕之后,才能执行一个特定的搜索算法。在这种情况下,可以用CountdownEvent对搜索算法进行同步,每下载一个股票,就使计数递减1。计数为0时,就允许搜索开始。

SemaphoreSlim 和 CountdownEvent 是在 NET Framework 4中引入的。在NET4.5中,SemaphoreSlim 包含了一个 WaitAsync() 方法,这样就可以在等待进入信号量时使用TAP。

(5)并发集合类

专门用来包含内建的同步代码,使它们能支持多个线程同时访问而不必关心竞态条件。

BlockingCollection :提供一个阻塞集合,允许在生产者/消费者模式中,生产者向集合中写入数据,同时消费者从集合中读取数据。这个类提供了一个泛型集合类型,支持同步添加和删除操作,而不必关心后端存储(可以是队列、栈、列表等 )。BlockingCollection为实现了IProducer ConsumerCollection接口的集合提供了阻塞和bounding支持

ConcurrentBag:线程安全的无序集合,由T类型的对象构成

ConcurrentDictionary:线程安全的字典,由键/值对构成的集合

ConcurrentQueue:线程安全的队列,支持先on

ConcurrentStack:线程安全的栈,支持先进后出

利用并发集合,可以实现的一个常见的模式是生产者和消费者的线程安全的访问。实现了IProducerConsumerCollection的类是专门为了支持这个模式而设计的。它允许一个或多个类将数据写入集合,而一个不同的集合将其读出并删除。数据添加和删除的顺序由实现了IProducerConsumerCollection接口的单独的集合类决定。

七、线程本地存储

某些情况下,使用同步锁可能导致让人无法接受的性能,并对伸缩性造成限制。另一些情况下,围绕特定数据元素提供同步可能过于复杂,尤其是在以前写好的原始代码的基础上进行修补。

同步的一个替代方案是隔离,而实现隔离的一个办法就是使用线程本地存储。利用线程本地存储,线程就有了专属的变量的实例。这样就没有同步的必要了,因为对只在单个线程的上下文中发生的数据进行同步是没有意义的。

决定是否使用线程本地存储时,需要进行一番性价比分析。例如,可以考虑为一个数据库连接使用线程本地存储。取决于数据库管理系统,数据库连接可能是相当昂贵的,为每个线程都创建连接可能不现实。另一方面,如果锁定一个连接来同步所有数据库调用,会造成可伸缩性的急剧下降。每个模式都有利与弊,具体如何抉择在很大程度上取决于单独的实现。

使用线程本地存储的另一个原因是将经常需要的上下文信息提供给其他方法使用,同时不显式地通过参数来传递数据。例如,假如调用栈中的多个方法都需要用户安全信息,就可以使用线程本地存储字段而不是参数来传递数据。这样可以使API更简洁,同时仍然能以线程安全的方式将信息传给方法。这要求你保证总是设置线程本地数据,在Task或其他线程池线程上,这一点尤其重要,因为基础线程是重用的。

(1)ThreadLocal

    //虽然是静态的,但每个线程都有字段的一个不同实例
    static ThreadLocal count = new ThreadLocal(() => 0.01134);
    public static double Count
    {
        get { return count.Value; }
        set { count.Value = value; }
    }

    public static void Main()
    {
        Thread thread = new Thread(Decrement);
        thread.Start();

        for(double i=0; i

Decrement Count=-32767.01134
Main Count 32767.01134

 (2)ThreadStaticAttribute

用 ThreadStaticAttribute 特性修饰静态字段是指定静态变量每线程一个实例的第二个办法。

和ThreadLocal相比,它的优点在于对NET Framework 4之前的版本也支持;如果涉及大量重复的、小的迭代处理,消耗的内存会少些,性能也会好一些。

    [ThreadStatic]
    static double count = 0.01134;
    public static double Count
    {
        get { return count; }
        set { count = value; }
    }

    public static void Main()
    {
        Thread thread = new Thread(Decrement);
        thread.Start();

        for(double i=0; i

Decrement Count=-32767
Main Count 32767.01134

 不同的是Decrement Count 的结果没有小数位,也就是说它没有被初始化,虽然count在声明时赋了值,但只会在调用静态构造器的线程上执行一次,即只有和“正在运行静态构造器的线程”关联的线程静态实例(也就是线程本地存储变量)才会被初始化。

类似的,如果构造器初始化一个线程本地存储字段,只有调用那个线程的构造器才会初始化线程本地存储实例。因此,好的编程实践是在每个线程最初调用的方法内部,对一个线程本地存储字段进行初始化。但这样做并非总是合理的,尤其是在涉及async的时候。在这种情况下,计算的不同部分可能在不同的线程上运行,造成每一部分线程本地存储值不同。

ThreadStatic 也不支持实例字段(它对实例字段并不会产生任何作用)。如果一定要处理实例字段,或者需要使用非默认值,则更推荐使用 ThreadLocal

(3) GetData() 和 SetData()

    //同一个LocalDataStoreSlot对象可以跨所有线程使用。
    LocalDataStoreSlot slot = Thread.GetNamedDataSlot("securityLevel");
    //LocalDataStoreSlot slot = Thread.AllocateDataSlot();   或用此方法获得匿名插槽

    int SecurityLevel
    {
        get
        {
            object data = Thread.GetData(slot);
            return data != null ? 0 : (int)data;
        }
        set
        {
            Thread.SetData(slot, value);
        }
    }

(4)AsyncLocal

上述线程本地存储方案均不适用于异步函数。因为await之后的执行可能会恢复到其他线程中。而AsyncLocal类可以跨越await保存数据

    static AsyncLocal local = new AsyncLocal();
    public static void Main()
    {
        new Thread(()=>Test("One")).Start();
        new Thread(() => Test("Two")).Start();
        Console.ReadKey();
    }

   static async void Test(string value)
   {
        local.Value = value;
        await Task.Delay(1000); 
        Console.WriteLine(value+" "+local.Value);
   }

Two Two
One One

 AsyncLocal对象在线程启动时拥有值,新线程会“继承”这个值

    static AsyncLocal local = new AsyncLocal();
    public static void Main()
    {
        local.Value = "Test";
        var t = new Thread(() =>
        {
            Console.WriteLine($"Thread Old Value:{local.Value}");
            local.Value = "no-test";
            Console.WriteLine($"Thread New Value:{local.Value}");
        });
        t.Start();
        t.Join();
        Console.WriteLine($"Main Value:{local.Value}");
        Console.ReadKey();
    }

Thread Old Value:Test
Thread New Value:no-test
Main Value:Test

你可能感兴趣的:(C#,c#,开发语言)