第 30 章 混合线程同步构造
第 30 章 混合线程同步构造
NyxX第 30 章 混合线程同步构造
本章内容
一般都合并了用户模式和内核模式构造,我们称为混合线程同步构造。
30.1 一个简单的混合锁
代码略
30.2 自旋、线程所有权和递归
由于转换为内核模式会造成巨大的性能损失,而且线程占有锁的时间通常都很短,所以为了提升应用程序的总体性能,可以让一个线程在用户模式中“自旋”一小段时间,再让线程转换为内核模式。如果线程正在等待的锁在线程“自旋”期间变得可用,就能避免向内核模式的转换了。
比较无任何锁,使用基元用户模式构造,以及使用内核模式构造性能
代码略
1 | Incrementing x: 8 最快 |
30.3 FCL 中的混合结构
30.3.1 ManualResetEventSlim类和 SemaphoreSlim类
System.Threading.ManualResetEventSlim 和 System.Threading.SemaphoreSlim 这两个类。构造的工作方式和对应的内核模式构造完全一致,只是它们都在用户模式中“自旋”,而且都推迟到发生第一次竞争时,才创建内核模式的构造。它们的 Wait 方法允许传递一个超时值和一个 CancellationToken。
1 |
|
30.3.2 Monitor类和同步块
最常用的混合型线程同步构造就是 Monitor 类,它提供了支持自旋、线程所有权和递归和互斥锁。
堆中的每个对象都可关联一个名为 同步块 的数据结构
同步块 的数据结构包括:
- 内核对象
- 拥有线程的 ID
- 递归计数
- 等待线程计数
同步块索引
- 为节省内存,CLR 初始化时在堆中分配一个
同步块数组。每当一个对象在堆中创建的时候,都有两个额外的开销字段与它关联。第一个“类型对象指针”,包含类型的“类型对象”的内存地址。第二个是“同步块索引”,包含同步块数组中的一个整数索引。 - 一个对象在构造时,它的同步块索引初始化为 -1,表明不引用任何同步块。然后,调用 Monitor.Enter 时,CLR 在数组中找到一个空白同步块,并设置对象的同步块索引,让它引用该同步块。
- 调用 Exit 时,会检查是否有其他任何线程正在等待使用对象的同步块。如果没有线程在等待它,Exit 将对象的同步块索引设回 -1,自由的同步块将来可以和另一个对象关联。
Monitor 是静态类,它的方法接收对任何堆对象的引用。这些方法对指定对象的同步块中的字段进行操作。以下是 Monitor 类最常用的方法:
1 | public static class Monitor { |
Monitor 额外的问题
- 变量能引用一个代理对象,调用 Monitor 的方法时,传递对代理对象的引用,锁定的是代理对象而不是代理引用的实际对象。
- 如果线程调用 Monitor.Enter,向它传递对类型对象的引用,而且这个类型对象是以 “AppDomain 中立”的方式加载的,线程就会跨越进程中的所有 AppDomain 在那个类型上获取锁。
它破坏了 AppDomain 本应提供的隔离能力。所以永远都不要向 Monitor 的方法传递类型对象引用。 - 由于字符串可以留用,String 对象引用传给 Monitor 的方法,两个独立的代码段现在就会在不知情的情况下以同步方式执行。所以
永远不要将 String 引用传给 Monitor 的方法。 - 由于
Monitor的方法要获取一个Object,所以传递值类型会导致值类型被装箱,造成线程在已装箱对象上个获取锁。每次调用Monitor.Enter都会在一个完全不同的对象上获取锁,造成完全无法实现线程同步。 - 调用类型的类型构造器时,CLR 要获取类型对象上的一个锁,确保只有一个线程初始化类型对象及其静态字段。这个类型可能以 “AppDomain 中立”的方式加载,假定类型构造器的代码进入死循环,进程中的所有 AppDomain 都无法使用该类型。我的建议是尽量避免使用类型构造器,或者至少保持它们的短小和简单。
C# 语言通过 lock 关键字来提供了一个简化的语法。
1
2
3
4
5 private void SomeMethod() {
lock (this) {
// 这里的代码拥有对数据的独占访问权...
}
}
它等价于像下面这样写方法:
1 | private void SomeMethod() { |
lock 语句的问题
- 在 try 块中,如果在更改状态时发生异常,这个状态就会处于损坏状态。锁在 finally 块中退出时,另一个
线程可能开始操作损坏的状态。 - 进入和离开 try 块会
影响方法的性能。
lockTaken 作用
假定一个线程进入 try 块,但在调用 Monitor.Enter 之前退出。现在,finally 块会得到调用,但它的代码不应退出锁。lockTaken 变量就是为了解决这个问题而设计的。它初始化为false,假定现在还没有进入锁(还没有获得锁)。然后,如果调用 Monitor.Enter,而且成功获得锁,Enter方法就会将 lockTaken 设为 true。finally 块通过检查 lockTaken ,便知道到底要不要调用 Monitor.Exit。顺便说一句,SpinLock 结构也支持这个 lockTaken模式。
30.3.3 ReaderWriterLockSlim 类
一个线程向数据写入时,请求访问的其他所有线程都被阻塞。
一个线程从数据读取时,请求读取的其他线程允许继续执行,但请求写入的线程仍被阻塞。
向线程写入的线程结束后,要么解除一个写入线程(
writer)的阻塞,使它能向数据写入。如果没有线程被阻塞,锁就进入可以自由使用的状态,可供下一个reader或writer线程获取。
下面展示了这个类(未列出部分方法的重载版本):
1 | public class ReaderWriterLockSlim : IDisposable{ |
LockRecurionsPolicy 标志,它的定义如下:
1 | public enum LockRecursionPolicy |
如果传递 SupportsRecursion 标志,锁就支持线程所有权和递归行为。
30.3.4 OneManyLock 类
我自己创建了一个 reader-writer 构造,它的速度比 FCL 的 ReaderWriterLockSlim 类快。该类名为 OneManyLock,因为它要么允许一个 writer 线程访问,要么允许多个 reader 线程访问。下面展示了这个类:
1 | public sealed class OneManyLock : IDisposable{ |
30.3.5 CountdownEvent 类
这个构造使用了一个 ManualResetEventSlim 对象。这个构造阻塞一个线程,直到它的内部计数器变成 0。
一旦一个 CountdownEvent 的 CurrentCount 变成 0,它就不能更改了。CurrentCount 为 0 时,AddCount 方法会抛出一个InvalidOperationException。如果 CurrentCount 为 0,TryAddCount 直接返回 false。
30.3.6 Barrier 类
- 创建
Barrier对象时,可以指定参与阶段工作的线程个数以及所有参与线程都完成阶段工作时调用的回调函数。 - 可以调用
AddParticipant函数和RemoveParticipant函数来动态添加和删除参与阶段工作的线程。 - 每一个参与线程完成阶段工作后,就会调用
SignalAndWait函数。当调用该函数的参与线程是最后一个时,就会调用创建Barrier对象时传递的回到函数,然后解除所有阻塞线程,使它们开始下一阶段工作。
30.3.7 线程同步构造小结
- 代码尽量不要阻塞任何线程
- 执行异步计算或 I/O 操作时,将数据从一个线程交给另一个线程时,应避免多个线程同时访问数据
- 如果不能完全做到这一点,请尽量使用 Volatile 和 Interlocked 的方法,因为它们的速度很快,而且绝不阻塞线程。
- 不要刻意地为线程打上标签,应通过线程池将线程出租短暂时间。
- 如果一定要阻塞线程,请使用内核对象构造。
- 避免使用递归锁,因为它们会损害性能。但 Monitor 是递归的,性能也不错。
- 不要在
finally块中释放锁,因为进入和离开异常处理块会招致性能损失。 - 如果写代码来占有锁,注意时间不要太长,否则会增大线程阻塞的机率。
- 对于计算限制的工作,可以使用任务避免使用大量线程同步构造。
30.4 著名的双检锁技术
1 | internal sealed class Singleton { |
解释为什么不使用s_value = new Singleton()?
这条语句可以分解成三句:
- 编译器生成代码为一个
Singleton分配内存 - 调用构造器来初始化字段
- 再将引用赋给
s_value字段
但编译器可能打乱其运行顺序,单线程打乱顺序不会出现问题,但多线程可能会出现问题,比如:在将引用发布给 s_value 之后,并在调用构造器之前,如果另一个线程调用了 GetSingleton 方法。那么该线程会使用一个还没有初始化完的对象。
对 Volatile.Write 的调用修正了这个问题。它保证 temp 中的引用只有在构造器结束执行之后,才发布到 s_value 中。
面是 Singleton 类的一个简单得多的版本,它的行为和上一个版本相同。这个版本没有使用“著名”的双检锁技术:
1 | internal sealed class Singleton { |
代码首次访问类的成员时,CLR 会自动调用类型的类构造器,CLR 已保证了对类构造器的调用是线程安全的。所以,如果 Singleton 类型定义了其他静态成员,就会在访问其他任何静态成员时创建 Singleton 对象。有人通过定义嵌套类来解决这个问题:
1 | internal sealed class Singleton { |
首先,它的速度非常快。其次,它永不阻塞线程。但可能创建多个 Singleton 对象,只有在构造器没有副作用的时候才能使用这个技术。
FCL 有两个类型封装了本书描述的模式。下面是泛型 Syste.Lazy 类:
1 | public class Lazy<T> { |
LazyThreadSafetyMode 标志
1 | public enum LazyThreadSafetyMode { |
30.5 条件变量模式
30.6 异步的同步构造
锁很流行,但长时间拥有会带来巨大的伸缩性问题。如果代码能通过异步的同步构造指出它想要一个锁,那么会非常有用。在这种情况下,如果线程得不到锁,可直接返回并执行其他工作,而不必在那里傻傻地阻塞。以后当锁可用时,代码可恢复执行并访问锁所保护的资源。
SemaphoreSlim 类通过 WaitAsync 方法实现了这个思路,下面是该方法的最复杂的重载版本的签名。
1 | public Task<Boolean> WaitAsync(Int32 millisecondsTimeout, CancellationToken cancellationToken); |
30.7 并发集合类
FCL 自带 4 个线程安全的集合类,全部在 System.Collections.Concurrent 命名空间中定义。它们是 ConcurrentQueue,ConcurrentStack,ConcurrentDictionary 和 ConcurrentBag。
实现了 IProducerConsumerCollection 接口的非阻塞并发集合对象都可以通过 BlockingCollection 类的辅助函数来转变成一个阻塞的并发集合对象。如果集合已满,那么负责生产 (添加) 数据项的线程会阻塞;如果集合已空,那么负责消费 (移除) 数据项的线程会阻塞。







