【C#进阶系列】28 基元线程同步构造

时间:2021-12-01 19:27:26

多个线程同时访问共享数据时,线程同步能防止数据损坏。之所以要强调同时,是因为线程同步问题实际上就是计时问题。

不需要线程同步是最理想的情况,因为线程同步一般很繁琐,涉及到线程同步锁的获取和释放,容易遗漏,而且锁会损耗性能,获取和释放锁都需要时间,最后锁的玩法就在于一次只能让一个线程访问数据,那么就会阻塞线程,阻塞线程就会让额外的线程产生,阻塞越多,线程越多,线程过多的坏处就不谈了。

所以可以避免线程同步的话就应该去避免,尽量不要去使用静态字段这样的共享数据。

类库和线程安全

.net类库保证了所有静态方法都是线程安全的,也就是说两个线程同时调用一个静态方法,不会发生数据被破坏的情况。

并不能保证所有实例方法线程安全。因为一般情况下实例创建后只有创建的线程能访问到,除非后来将实例的引用传给了一个静态变量,或者将引用传给了线程池的队列或者任务,那么此时可能就要考虑用线程同步了。

Console类包含一个静态字段,类的许多方法都要获取和释放这个对象上的锁,确保只有一个线程访问控制台。

基元用户模式和内核模式构造(这一部分看不明白可以先看看后面的用户模式和内核模式的讲解,就会清楚了)

基元是指可以在代码中使用的最简单的构造。

有两种基元构造:用户模式和内核模式。应尽量使用基元用户模式构造,它们的速度显著高于内核模式的构造。

这是因为它们使用特殊的CPU指令来协调线程,意味着协调是在硬件上发生的,也意味着操作系统永远检测不到一个线程在基元用户模式的构造上阻塞了。

只有操作系统内核才能停止一个线程的运行。

所以在用户模式下运行的线程可能被系统抢占。

所以也可以用内核模式构造,因为线程通过内核模式的构造获取其它线程拥有的资源时,Windows会阻塞线程以避免它浪费CPU时间。当资源变得可用时,Windows会恢复线程,允许它访问资源。

然而线程从用户模式切换到内核模式(或相反)会招致巨大的性能损失。

对于在一个构造上等待的线程,如果占有构造的这个线程不释放它,前者就可能一直阻塞。构造是用户模式的构造情况下,线程会一直在一个CPU上运行,称为“活锁”。如果是内核模式的构造,线程会一直阻塞,称为“死锁”。

死锁优于活锁,因为活锁既浪费CPU时间,又浪费内存,而死锁只浪费内存。

而混合构造兼具两者之长,在没有竞争的情况下,这个构造很快且不会阻塞(就像用户模式的构造),在存在对构造的竞争的情况下,它会被操作系统内核阻塞。(下一章讲)

用户模式构造

CLR保证对以下数据类型的变量的读写是原子性的:Boolean,Char,S(Byte),U(Int16),U(Int32),U(IntPtr),Single以及引用类型。

这意味着变量中的所有字节都是一次性读取或写入。(举个反例,对于一个Int64静态变量初始化为0,一个线程写它的时候只写了一半,另一个线程读取的时候读取到的是中间状态。不过话说回来,貌似64位机器一次性读取64位,是不是在这个时候Int64也会编程原子性呢,未验证,不过不影响我们理解。)

本章讲解的基元用户模式构造就在于规划好这些原子性数据的读取/写入时间。

实际上这些构造也可以强制为Int32和Double这些类型数据进行原子性的规划好时间的访问。

有两种基元用户模式线程同步构造

  • 易变构造
  • 互锁构造

所有易变和互锁构造都要求传递对包含简单数据类型的一个变量的引用(内存地址)。

易变构造

在讲易变构造之前,得先讲一个问题,就是代码优化的问题。

之前我们讲过C#编译器,JIT编译器,CPU都可能会优化代码,典型的例子就是Timer的应用,一个Timer对象在后续没有使用的情况下,可能直接被优化掉了,根本不会定时执行回调函数。

而这些优化效果是很难在调试的时候看出来,因为调试的时候并没有对代码进行优化。

而多线程也会导致这样的问题,比如一个线程回调函数用到某个静态变量后,且并不改变这个变量,那么可能就会进行优化,认为这个变量的值不变,让其直接优化成固定的值。而你本来的目的实在另一个线程中改变这个静态变量的值,现在你的改变也起不了效果看了。

并且以下这样的代码而言可能因为代码的执行顺序不同而出现超出预料的结果。

        static int you = 0;
static int me = 0;
private static void Thread1() {
me
= 2;
you
= 2;
}
private static void Thread2()
{
if (you == 2) {
Console.WriteLine(me);

}

像上面的代码,Thread1和Thread2方法分别在两个线程中循环运行。

按照我们预计的结果是,当Thread1运行完了,那么Thread2就会检测到你2了,然后就打印我是2.

然而因为编译器优化的原因,you=2和me=2的顺序完全是可以反过来的,那么当先写了you=2后,me=2这句代码还没执行,此时Thread2已经开始检测到you==2了,那么此时打印的话,会显示我不是2,是0.

或者Thread1中的顺序没有变,而Thread2中的顺序变了,即you读取到数据和me读取到数据的代码也是可以被优化的,编译器在Thread1未运行时,先读了me的值为0,而此时Thread1运行了,虽然给了me为2,但是线程2的寄存器中已经存为0了,所以未读取,那么此时结果依然是你是2,而我不是2;

要解决这个问题就引入了我们的易变构造,这需要了解到一个静态类System.Threading.Volatile,它提供了两个静态方法Write和Read。

这两个方法比较特殊,它们会禁止C#编译器,JIT编译器和CPU平常执行的一些优化。

具体的实现在于,Write方法会保证函数中,所有在Write方法之前执行的数据读写操作都在Write方法写入之前就执行了。

而Read方法会保证函数中,所有在Read方法执行之后的数据读写操作,一定实在Read方法执行后才进行。

修改代码后

        static int you = 0;
static int me = 0;
private static void Thread1() {
me
= 2;
Volatile.Write(
ref you,2);
}
private static void Thread2()
{
if (Volatile.Read(ref you) == 2) {
Console.WriteLine(me);

}

此时因为Volatile.Write使编译器会保证函数中,所有在Write方法之前执行的数据读写操作都在Write方法写入之前就执行了。

也就是说编译器不会在执行的时候将you=2放在me=2后面了。解决了之前说的第一种情况。

而Volatile.Read保证函数中,所有在Read方法执行之后的数据读写操作,一定实在Read方法执行后才进行。

也就是说me读取肯定在有读取数据的后面,也就解决了之前说的第二种情况。

然而正如你所看到的,这很难理解,关键是自己用到项目中都会觉得真蛋疼,还得百度一下看看是不是Read和Write的保证记混了。

所以为了简化编程,C#编译器提供了volatile关键字,它可以应用于之前提到的那些原子性的简单类型。

volatile声明后,JIT编译器会确保易变字段都是以易变读取和写入的方式进行,不必显示调用Read和Write。(也就是说只要用了volatile,那么me=2的效果就是Volatile.Write(ref me,2),同理读也是一样)

并且volatile会告诉C#编译器和JIT编译器不将字段缓存到CPU寄存器,确保字段的所有读写操作都在内存中进行。

现在再改写之前的代码:

        static volatile int you = 0;
static int me = 0;
private static void Thread1() {
me
= 2;
you
=2;
}
private static void Thread2()
{
if (you == 2) {
Console.WriteLine(me);

}

然而作者却表示并不喜欢volatile关键字,因为出现上述所说的情况的概率很低,并且volatile禁止优化后对性能会有影响。且C#不支持以传引用的方式传递volatile变量给某个函数。

互锁构造

说道互锁构造,就要说System.Threading.Interlocked类提供的方法。

这个类中的每个方法都执行一次原子性的读或者写操作。

这个类中的所有方法都建立了完整的内存栅栏,也就是说调用某个Interlocked方法之前的任何变量写入都在这个Interlocked方法调用之前执行,而这个调用之后的任何变量读取都在这个调用之后读取。

它的作用就等于之前的Volilate的Read和Write的作用加在一起。

作者推荐使用Interlocked的方法,它们不仅快,而且也能做不少事情,比简单的加(Add),自增(Increment),自减(Decrement),互换(Exchange)。

Interlocked的方法虽然好用,但主要用于操作Int类型。

如果想要原子性地操作类对象中的一组字段,那么可以用以下方法实现:

/// <summary>
/// 简单的自旋锁
/// </summary>
struct SimpleSpinLock {
private Int32 m_ResourceInUse;//0表示false,1表示true

public void Enter() {
while (true) {
//将资源设为正在使用,Exchange方法的意思是,将m_ResourceInUse赋值为1,并返回原来的m_ResourceInUse的值
if (Interlocked.Exchange(ref m_ResourceInUse, 1) == 0) return;

}
}

public void Leave() {
Volatile.Write(
ref m_ResourceInUse, 0);
}
}
public class SomeResource {
private SimpleSpinLock m_sl = new SimpleSpinLock();
public void AccessResource() {
m_sl.Enter();
/*每次只有一个线程能访问到这里的代码*/
m_sl.Leave();
}
}

上面的代码原理就是,当一个线程调用Enter后,那么就会return,并置m_ResourceInUse为1,此时表示资源被占用了。

如果另外一个线程再调用Enter,那么得到的m_ResourceInUse为1,所以不会返回,就不断执行循环,直到第一个线程调用Leave函数,将m_ResourceInUse置为0。

原理很简单,但相信看这个模式的人也应该很清楚了,也就是说只要第一个线程不退出,其它所有的线程都要不断进行循环操作(术语为自旋)。

所以自旋锁应该是用于保护那些会执行得非常快的代码区域。(且不要用在单CPU机器上,因为占有锁的线程不能快速释放锁)

如果占有锁的线程优先级地狱想要获取锁的线程,那么这就造成占有锁的线程可能根本没机会运行,更别提释放锁了。(这就是活锁,前面也提到了)

实际上FCL就提供了一个类似的自旋锁,也就是System.Threading.SpinLock结构,并且还是用了SpinWait结构来增强性能。

由于SpinLock和之前我们自己写的SimpleSpinLock都是结构体,也就是说他们都是值类型,都是轻量级且内存友好的。

然而不要传递它们的实例,因为值类型会复制,而你将失去所有的同步。

事实上Interlocked.CompareExchange本来就可以不仅仅用于操作整数,还可以用来操作其它原子性的基元类型,他还有一个泛型方法。

它的作用是,对比第1个参数和第3个参数,如果两者相等,那么将第2个参数的值赋给第1个参数,并返回第一个参数之前的值。

内核模式构造

内核模式比用户模式慢,这个是可以预见的,因为线程要从托管代码转为本机用户模式代码,再转为内核模式代码,然后原路返回,也就了解为什么慢了。

但是之前也介绍过了,内核模式也具备用户模式所不具备的优点:

  • 内核模式的构造检测到一个资源上的竞争,windows会阻塞输掉的线程,使他不会像之前介绍的用户模式那样“自旋”(也就是那个不断循环的鬼),这样也就不会一直占着一个CPU了,浪费资源。
  • 内核模式的构造可实现本机和托管线程相互之间的同步
  • 内核模式的构造可同步在同一台机器的不同进程中运行的线程。
  • 内核模式的构造可应用安全性设置,防止未经授权的帐户访问它们。
  • 线程可一直阻塞,直到集合中所有内核模式构造可用,或直到集合中的任何内核模式构造可用
  • 在内核模式的构造上阻塞的线程可指定超时值;指定时间内访问不到希望的资源,线程就可以解除阻塞并执行任务。

事件和信号量是两种基元内核模式线程同步构造,至于互斥体什么的则是在这两者基础上建立而来的。

System.Threading命名空间提供了一个抽象基类WaitHandle。这个简单的类唯一的作用就是包装一个Windows内核对象句柄。(它有一些派生类EventWaitHandle,AutoResetEvent,ManualResetEvent,Semaphore,Mutex)

WaitHandle基类内部有一个SafeWaitHandle字段,它容纳一个Win32内核对象句柄。

这个字段在构造一个具体的WaitHandle派生类时初始化。

在一个内核模式的构造上调用的每个方法都代表一个完整的内存栅栏。(之前也说过了,表示调用这个方法之前的任何变量的写入都必须在此方法前完成,调用这个方法之后的任何变量的读取都必须在此方法后完成)。

这个类中的方法就不具体介绍了,基本上这些方法的主要功能呢个就是调用线程等待一个或多个底层内核对象收到信号。

只是要注意在等待多个的方法(即WaitAll和WiatAny这种)中,传递的内核数组参数,数组最大元素数不能超过64,否则会异常。

主要讲一下三个内核构造,也是之前WaitHandle的三个直接继承派生类:

  • EventHandle(Event构造)
    • 事件实际上就是由内核维护的Boolean变量。为false就阻塞,为true就解除阻塞。
    • 有两种事件,即自动重置事件(AutoResetEvent)和手动重置事件(ManualResetEvent)。区别就在于是否在接触一个线程的阻塞后,将事件自动重置为false。
    • 用自动重置事件写个锁示例如下:
        /// <summary>
      /// 简单的阻塞锁
      /// </summary>
      class SimpleWaitLock {
      private readonly AutoResetEvent m_ResourceInUse;

      public SimpleWaitLock() {
      m_ResourceInUse
      = new AutoResetEvent(true);//初始化事件,表示事件构造可用
      }

      public void Enter() {
      //阻塞内核,直到资源可用
      m_ResourceInUse.WaitOne();
      }

      public void Leave() {
      //解除当前线程阻塞,让另一个线程访问资源
      m_ResourceInUse.Set();
      }
      public void Dispose() {
      m_ResourceInUse.Dispose();
      }
      }

      此示例可以和前面的那个自旋锁相对比,调用方法一模一样。

  • Semaphore(Semaphore构造)
    • Semaphore的英文就是信号量,其实是由内核维护的Int32变量。信号量为0时,在信号量上等待的线程阻塞,信号量大于0时接触阻塞。信号量上等待的线解除阻塞时,信号量自动减1.
    • 同样一个例子来表示,与上面代码对比之后更清晰:(信号量最大值设置为1的话,且释放的时候也只释放一个的话,那么实际上和事件效果一样)
       /// <summary>
      /// 简单的阻塞锁
      /// </summary>
      class SimpleWaitLock {
      private readonly Semaphore m_ResourceInUse;

      public SimpleWaitLock(Int32 maxCount) {
      m_ResourceInUse
      = new Semaphore(maxCount, maxCount);
      }

      public void Enter() {
      //阻塞内核,直到资源可用
      m_ResourceInUse.WaitOne();
      }

      public void Leave() {
      //解除当前线程阻塞,让另外2个线程访问资源
      m_ResourceInUse.Release(2);
      }
      public void Dispose() {
      m_ResourceInUse.Close();
      }
      }
  • Mutex(Mutex构造)
    • Mutex的中文就是互斥体。代表了一个互斥的锁。
    • 互斥体有一个额外的逻辑,Mutex会记录下线程的ID值,如果释放的时候不是这个线程释放的,那么就不会释放掉,并且还会抛异常。
    • 互斥体实际上在维护一个递归计数,一个线程当前拥有一个Mutex,而后该线程再次在Mutex等待,那么此计数就会递增,而线程调用ReleaseMutex会导致递减,只有计数递减为0,那么这个线程才会解除阻塞。另一个线程才会称为该Mutex的所有者
    • Mutex对象需要额外的内存来容纳那些记录下来的ID值和计数信息,并且锁也会变得更慢了。所以很多人避免用Mutex对象。
    • 通常一个方法在用到一个锁时调用了另一个方法,这个方法也要用到锁,那么就可以考虑用互斥体。因为用事件这种内核构造方法的话,在调用的另一个方法中用到锁就会导致阻塞,从而死锁。例子:
       public class SomeResource {
      private readonly Mutex m_lock = new Mutex();
      public void Method1() {
      m_lock.WaitOne();
      Method2();
      //递归获取锁
      m_lock.ReleaseMutex();
      }
      public void Method2()
      {
      m_lock.WaitOne();
      /*做点什么*/
      m_lock.ReleaseMutex();
      }
      }

      像以上的这种结构如果简单得用事件来写就会有问题,然而并不是不能用事件去递归实现,而且如果用以下的方法递归实现效果反而会更好:

    • 用事件方式实现递归锁:
      /// <summary>
      /// 事件构造实现的递归锁,效率比Mutex高很多
      /// </summary>
      class ComplexWaitLock:IDisposable {
      private AutoResetEvent m_lock=new AutoResetEvent(true);
      private Int32 m_owningThreadId = 0;
      private Int32 m_lockCount = 0;

      public void Enter() {
      //获取当前线程ID
      Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId;
      //当前线程再次进入就会递增计数
      if (m_owningThreadId == currentThreadId) {
      m_lockCount
      ++;
      return;
      }
      m_lock.WaitOne();
      m_owningThreadId
      = currentThreadId;
      m_lockCount
      = 1;

      }

      public void Leave() {
      //获取当前线程ID
      Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId;
      if (m_owningThreadId != currentThreadId)
      throw new InvalidOperationException();

      if (--m_lockCount == 0) {
      m_owningThreadId
      = 0;
      m_lock.Set();
      }
      }
      public void Dispose() {
      m_lock.Dispose();
      }
      }

      上面的代码其实很好搞懂,就是用事件把Mutex的玩法自己实现了。然而上面的代码之所以比Mutex快,是因为这些代码都是用托管代码在实现,而不是像Mutex一样用内核代码,仅仅只有调用事件构造的方法时才会用到内核代码。