第 29 章 基元线程同步构造

第 29 章 基元线程同步构造

本章内容

锁的问题

  • 比较繁琐易错,需要标识共享数据并用额外的代码加锁。

  • 锁损害性能,获取和释放锁是需要时间的,而且需协调 CPU 决定哪个线程先取得锁。

  • 锁程同步锁一次只允许一个线程访问资源,会阻塞线程,导致更多线程被创建,增加上下文切换机率,导致性能损失。

综上所述,线程同步是一件不好的事情,避免使用静态字段等共享数据进行线程同步。使用值类型可避免传递引用以进行同步。多个线程只读访问共享数据没有问题。String 对象是一个例子,因为它是不可变的,所以多个线程可以同时访问。

29.1 类库和线程安全

FCL保证所有静态方法线程安全,需要使用内部锁来避免数据破坏。Console类中的静态字段锁定访问控制台,确保单线程访问。使方法线程安全并非一定需要内部锁。System.Math中Max方法是线程安全的,因为它处理每个线程自己的数据,互不干扰。

FCL不保证实例方法是线程安全的,因为添加锁定会降低性能,应该使静态方法都线程安全,使实例方法都非线程安全,除非实例方法是为了协调线程。在多线程访问时需要线程同步。建议遵循这个模式。

29.2 基元用户模式和内核模式构造

基元线程同步构造包括用户模式内核模式。用户模式速度更快,但Windows无法检测线程阻塞。用户模式线程可能会在资源暂时不可用时浪费CPU时间。建议尽量使用基元用户模式构造,但注意可能浪费大量CPU时间。内核模式构造可以阻塞线程以避免浪费CPU时间,但切换为内核模式会导致巨大的性能损失。因此,应该根据情况选择合适的构造来同步线程。

用户模式构造和内核模式构造优缺点

用户模式构造

基元用户模式比基元内核模式速度要快,她使用特殊的cpu指令来协调线程,在硬件中发生,速度很快。但也因此Windows操作系统永远检测不到一个线程在一个用户模式构造上阻塞了。举个例子来模拟一下用户模式构造的同步方式:

  • 线程1请求了临界资源,并在资源门口使用了用户模式构造的锁;
  • 线程2请求临界资源时,发现有锁,因此就在门口等待,并不停的去询问资源是否可用;
  • 线程1如果使用资源时间较长,则线程2会一直运行,并且占用CPU时间。占用CPU干什么呢?她会不停的轮询锁的状态,直到资源可用,这就是所谓的活锁;

缺点:线程2会一直使用CPU时间(假如当前系统只有这两个线程在运行),也就意味着不仅浪费了CPU时间,而且还会有频繁的线程上下文切换,对性能影响是很严重的。

优点: 效率高,适合哪种对资源占用时间很短的线程同步。

.NET中为我们提供了两种原子性操作,利用原子操作可以实现一些简单的用户模式锁(如自旋锁)。

  • System.Threading.Interlocked:易失构造,它在包含一个简单数据类型的变量上执行原子性的读或写操作。
  • Thread.VolatileReadThread.VolatileWrite:互锁构造,它在包含一个简单数据类型的变量上执行原子性的读和写操作。
内核模式构造

先模拟一个内核模式构造的同步流程来理解她的工作方式:

  • 线程1请求了临界资源,并在资源门口使用了内核模式构造的锁;
  • 线程2请求临界资源时,发现有锁,就会被系统要求睡眠(阻塞),线程2就不会被执行了,也就不会浪费CPU和线程上下文切换了;
  • 等待线程1使用完资源后,解锁后会发送一个通知,然后操作系统会把线程2唤醒。假如有多个线程在临界资源门口等待,则会挑选一个唤醒;

缺点: 将线程从用户模式切换到内核模式(或相反)导致巨大性能损失。调用线程将从托管代码转换为内核代码,再转回来,会浪费大量CPU时间,同时还伴随着线程上下文切换,因此尽量不要让线程从用户模式转到内核模式。

优点: 就是阻塞线程,不浪费CPU时间,适合那种需要长时间占用资源的线程同步。

内核模式构造的主要有两种方式,以及基于这两种方式的常见的锁:

  • 基于事件:如AutoResetEvent、ManualResetEvent
  • 基于信号量:如Semaphore
混合线程同步

如果是在没有资源竞争,或线程使用资源的时间很短,就是用用户模式构造同步,否则就升级到内核模式构造同步,其中最典型的代表就是 Lock 了。

常用的混合锁如 SemaphoreSlimManualResetEventSlimMonitorReadWriteLockSlim

29.3 用户模式构造

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

  • 易变构造(volatile construct)
    在特定的时间,它在包含一个简单数据类型的变量上执行原子性的读或写操作。

  • 互锁构造(interlocked construct)
    在特定的时间,它在包含一个简单数据类型的变量上执行原子性的读和写操作。

29.3.1 易变构造

静态 System.Threading.Volatile 类提供了两个静态方法,如下所示:

ReadWrite 还有一些重载版本可用于操作以下类型:Boolean(S)Byte(U)Int16UInt32(U)Int64(U)IntPtrSingleDoubleT。其中 T 是约束为 class(引用类型)的泛型类型。

1
2
3
4
public static class Volatile {
public static void Write(ref Int32 location, Int32 value);
public static Int32 Read(ref Int32 location);
}

这些方法比较特殊。它们事实上会禁止 C# 编译器、JIT 编译器和 CPU 平常执行的一些优化。下面描述了这些方法是如何工作的。

  • Volatile.Write 方法强迫 location 中的值在调用时写入。此外,按照编码顺序,之前的加载和存储操作必须在调用 Volatile.Write 之前发生。

  • Volatile.Write 方法强迫 location 中的值在调用时读取。此外,按照编码顺序,之后的加载和存储操作必须在调用 Volatile.Read 之后发生。

C# 对易变字段的支持

为了简化编程,C# 编译器提供了 volatile 关键字,它可应用于以下任何类型的静态或实例字段:Boolean(S)Byte(U)Int16(U)Int32,(U)IntPtrSingleChar,还可将 volatile 关键字应用于引用类型的字段,以及基础类型为 (S)Byte(U)Int16(U)Int32的任何枚举字段。JIT 编译器确保对易变字段的所有访问都是以易变读取或写入的方式执行。

另外,C# 不支持以传引用的方式将 volatile 字段传给方法。

29.3.2 互锁结构

Interlocked 类中的每个方法都执行一次原子读写以及写入操作。调用某个 Interlocked 方法之前的任何变量写入都在这个 Interlocked 方法调用之前执行;而这个调用之后的任何变量读取的都在这个调用之后读取。

29.3.3 实现简单的自旋锁

这种锁最大的问题在于,在存在对锁的竞争的前提下,会造成线程“自旋”。这个“自旋”会浪费宝贵的 CPU 时间,阻止 CPU 做其他更有用的工作。因此,自旋锁只应该用于保护那些会执行得非常快的代码区域。

29.4 内核模式构造

内核模式的优点。

  • 内核模式的构造检测到在一个资源上的竞争时,Windows 会阻塞输掉的线程,使它不占用一个 CPU “自旋”(spinning),无谓地浪费处理器资源。
  • 内核模式的构造可实现本机(native)和托管(managed)线程相互之间的同步。
  • 内核模式的构造可同步在同一台机器的不同进程中运行的线程。
  • 内核模式对的构造可应用安全性设置,防止未经授权的账户访问它们。
  • 线程可一直阻塞,直到集合中的所有内核模式构造都可用,或直到集合中的任何内核模式构造可用。
  • 在内核模式的构造上阻塞的线程可指定超时值;指定时间内访问不到希望的资源,线程就可以解除阻塞并执行其他任务。

System.Threading 命名空间提供了一个名为 WaitHandle 抽象基类。它包装一个 Windows 内核对象句柄。FCL 提供了几个从 WaitHandle 派生的类。所有类都在 System.Threading 命名空间中定义。类层次结构如下所示:

1
2
3
4
5
6
WaitHandle
EventWaitHandle
AutoResetEvent
ManualResetEvent
Semaphore
Mutex

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

29.4.1 Event 构造

事件(event)是由内核维护的布尔变量。有两种事件:自动重置事件手动重置事件

  • 自动重置事件为 false 时,系统会阻塞在该对象上等待的所有线程,为true时只唤醒一个阻塞的线程,并在解除阻塞后自动重置为false。
  • 手动重置事件为 false 时,系统会阻塞在该对象上等待的所有线程,为true时解除所有等待的线程的阻塞,不自动重置为false。
SimpleSpinLock和SimpleWaitLock区别
  • 锁上面没有竞争的时候,SimpleWaitLockSimpleSpinLock慢得多. 因为WaitLock的Enter和Leave方法的每一个调用都强迫调用线程从托管代码转换为内核代码, 再转换回来. 这是不好的地方
  • 存在竞争的时候, 输掉的线程会被内核阻塞. 不会在那边自旋(浪费CPU时间)

29.4.2 Semaphore 构造

信号量(semaphore)其实就是由内核维护的 Int32 变量。信号量为 0 时,在信号量上等待的线程会阻塞;信号量大于 0 时解除阻塞。在信号量上等待的线程解除阻塞时,内核自动从信号量的计数中减 1。信号量还关联了一个最大 Int32 值,当前技术绝不允许超过最大计数。下面展示了 Semaphore 类的样子:

1
2
3
4
5
public sealed class Semaphore : WaitHandle {
public Semaphore(Int32 initialCount, Int32 maximumCount);
public Int32 Release(); // 调用 Release(1); 返回上一个计数
public Int32 Release(Int32 releaseCount); // 返回上一个计数
}

在一个信号量上连续多次调用 Release,会使它的内部计数一直递增

29.4.3 Mutex 构造

互斥体(mutex)代表一个互斥的锁。它的工作方式和 AutoResetEvent 或者计数为 1 的 Semaphore 相似,三者都是一次只释放一个正在等待的线程。下面展示了 Mutex 类的样子:

1
2
3
4
public sealed class Mutex : WaitHandle {
public Mutex();
public void ReleaseMutex();
}

互斥体有一些额外的逻辑

  • Mutex 对象会查询调用线程的 Int32 ID,记录是哪个线程获得了它。一个线程调用ReleaseMutex 时,Mutex 确保调用线程就是获取 Mutex 的那个线程。如若不然,Mutex 对象的状态就不会改变,而 ReleaseMutex 会抛出一个 System.ApplicationException
  • 拥有 Mutex 的线程因为任何原因而终止,在 Mutex 上等待的某个线程会因为抛出 System.Threading.AbandonedMutexException 异常而被唤醒。该异常通常会成为未处理的异常,从而终止整个进程。
  • Mutex 对象维护着一个递归计数,指出拥有该 Mutex 的线程拥有了它多少次。只有计数变成 0,另一个线程才能成为该 Mutex 的所有者。