第 30 章 混合线程同步构造

第 30 章 混合线程同步构造

本章内容

一般都合并了用户模式和内核模式构造,我们称为混合线程同步构造。

30.1 一个简单的混合锁

代码略

30.2 自旋、线程所有权和递归

由于转换为内核模式会造成巨大的性能损失,而且线程占有锁的时间通常都很短,所以为了提升应用程序的总体性能,可以让一个线程在用户模式中“自旋”一小段时间,再让线程转换为内核模式。如果线程正在等待的锁在线程“自旋”期间变得可用,就能避免向内核模式的转换了。

比较无任何锁,使用基元用户模式构造,以及使用内核模式构造性能

代码略

1
2
3
4
5
6
Incrementing x: 8                           最快
Incrementing x in M: 69 慢约 9 倍
Incrementing x in SpinLock: 164 慢约 21 倍
Incrementing x in SimpleHybridlock: 164 慢约 21 倍(类似于 SpinLock)
Incrementing x in AnotherHybridLock: 230 慢约 29 倍(因为所有权/递归)
Incrementing x in SimpleWaitLock: 8854 慢约 1107 倍

30.3 FCL 中的混合结构

30.3.1 ManualResetEventSlim类和 SemaphoreSlim类

System.Threading.ManualResetEventSlim 和 System.Threading.SemaphoreSlim 这两个类。构造的工作方式和对应的内核模式构造完全一致,只是它们都在用户模式中“自旋”,而且都推迟到发生第一次竞争时,才创建内核模式的构造。它们的 Wait 方法允许传递一个超时值和一个 CancellationToken。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

public class ManualResetEventSlim : IDisposable {
public ManualResetEventSlim(Boolean initialState, Int32 spinCount);
public void Dispose();
public void Reset();
public void Set();
public Boolean Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken);

public Boolean IsSet { get; }
public Int32 SpinCount { get; }
public WaitHandle WaitHandle { get; }
}

public class SemaphoreSlim : IDisposable {
public SemaphoreSlim(Int32 initialCount, Int32 maxCount);
public void Dispose();
public Int32 Release(Int32 releaseCount);
public Boolean Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken);

// 该特殊的方法用于 async 和 await(参见第 28 章)
public Task<Boolean> WaitAsync(Int32 millisecondsTimeout, CancellationToken cancellationToken);
public Int32 CurrentCount { get; }
public WaitHandle AvailableWaitHandle { get; }
}

30.3.2 Monitor类和同步块

最常用的混合型线程同步构造就是 Monitor 类,它提供了支持自旋、线程所有权和递归和互斥锁。

堆中的每个对象都可关联一个名为 同步块 的数据结构

同步块 的数据结构包括:

  • 内核对象
  • 拥有线程的 ID
  • 递归计数
  • 等待线程计数
同步块索引
  • 为节省内存,CLR 初始化时在堆中分配一个同步块数组。每当一个对象在堆中创建的时候,都有两个额外的开销字段与它关联。第一个“类型对象指针”,包含类型的“类型对象”的内存地址。第二个是“同步块索引”,包含同步块数组中的一个整数索引。
  • 一个对象在构造时,它的同步块索引初始化为 -1,表明不引用任何同步块。然后,调用 Monitor.Enter 时,CLR 在数组中找到一个空白同步块,并设置对象的同步块索引,让它引用该同步块。
  • 调用 Exit 时,会检查是否有其他任何线程正在等待使用对象的同步块。如果没有线程在等待它,Exit 将对象的同步块索引设回 -1,自由的同步块将来可以和另一个对象关联。

Monitor 是静态类,它的方法接收对任何堆对象的引用。这些方法对指定对象的同步块中的字段进行操作。以下是 Monitor 类最常用的方法:

1
2
3
4
5
6
7
8
9
10
11
public static class Monitor {
public static void Enter(Object obj);
public static void Exit(Object obj);

// 还可指定尝试进入锁时的超时值(不常用):
public static Boolean TryEnter(Object obj, Int32 millisecondsTimeout);

// 稍后会讨论 lockTaken 实参
public static void Enter(Object obj, ref Boolean lockTaken);
public static void TryEnter(Object obj, Int32 millisecondsTimeout, ref Boolean lockTaken);
}

堆中的对象、它们的同步块索引以及 CLR 的同步块数组元素之间的关系

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
2
3
4
5
6
7
8
9
10
11
private void SomeMethod() {
Boolean lockTaken = false;
try {
// 这里可能发生异常(比如 ThreadAbortException)...
Monitor.Enter(this, ref lockTaken);
// 这里的代码拥有对数据的独占访问权...
}
finally {
if (lockTaken) Monitor.Exit(this);
}
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ReaderWriterLockSlim : IDisposable{
public ReaderWriterLockSlim(LockRecursionPolicy recursionPolicy);
public void Dispose();

public void EnterReadLock();
public Boolean TryEnterReadLock(Int32 millisecondsTimeout);
public void ExitReadLock();

public void EnterWriteLock();
public Boolean TryEnterWriteLock(Int32 millisecondsTimeout);
public void ExitWriteLock();

// 大多数应用程序从不查询一下任何属性
public Boolean IsReadLockHeld { get; }
public Boolean IsWriteLockHeld { get; }
public Int32 CurrentReadCount { get; }
public Int32 RecursiveReadCount { get; }
public Int32 RecursiveWriteCount { get; }
public Int32 WaitingReadCount { get; }
public Int32 WaitingWriteCount { get; }
public LockRecursionPolicy RecursionPolicy { get; }
// 未列出和 reader 升级到 writer 有关的成员
}

LockRecurionsPolicy 标志,它的定义如下:

1
2
3
4
5
public enum LockRecursionPolicy
{
NoRecursion,
SupportsRecursion
}

如果传递 SupportsRecursion 标志,锁就支持线程所有权和递归行为。

30.3.4 OneManyLock 类

我自己创建了一个 reader-writer 构造,它的速度比 FCL 的 ReaderWriterLockSlim 类快。该类名为 OneManyLock,因为它要么允许一个 writer 线程访问,要么允许多个 reader 线程访问。下面展示了这个类:

1
2
3
4
5
6
7
public sealed class OneManyLock : IDisposable{
public OneManyLock();
public void Dispose();

public void Enter(Boolean exclusive);
public void Leave();
}

30.3.5 CountdownEvent 类

这个构造使用了一个 ManualResetEventSlim 对象。这个构造阻塞一个线程,直到它的内部计数器变成 0。

一旦一个 CountdownEvent 的 CurrentCount 变成 0,它就不能更改了。CurrentCount 为 0 时,AddCount 方法会抛出一个InvalidOperationException。如果 CurrentCount 为 0,TryAddCount 直接返回 false。

30.3.6 Barrier 类

  1. 创建 Barrier 对象时,可以指定参与阶段工作的线程个数以及所有参与线程都完成阶段工作时调用的回调函数。
  2. 可以调用 AddParticipant 函数和 RemoveParticipant 函数来动态添加和删除参与阶段工作的线程。
  3. 每一个参与线程完成阶段工作后,就会调用 SignalAndWait 函数。当调用该函数的参与线程是最后一个时,就会调用创建 Barrier 对象时传递的回到函数,然后解除所有阻塞线程,使它们开始下一阶段工作。

30.3.7 线程同步构造小结

  • 代码尽量不要阻塞任何线程
  • 执行异步计算或 I/O 操作时,将数据从一个线程交给另一个线程时,应避免多个线程同时访问数据
  • 如果不能完全做到这一点,请尽量使用 Volatile 和 Interlocked 的方法,因为它们的速度很快,而且绝不阻塞线程。
  • 不要刻意地为线程打上标签,应通过线程池将线程出租短暂时间。
  • 如果一定要阻塞线程,请使用内核对象构造。
  • 避免使用递归锁,因为它们会损害性能。但 Monitor 是递归的,性能也不错。
  • 不要在 finally 块中释放锁,因为进入和离开异常处理块会招致性能损失。
  • 如果写代码来占有锁,注意时间不要太长,否则会增大线程阻塞的机率。
  • 对于计算限制的工作,可以使用任务避免使用大量线程同步构造。

30.4 著名的双检锁技术

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
internal sealed class Singleton {
// s_lock 对象是实现线程安全所需要的。定义这个对象时,我们假设创建单实例对象的
// 代价高于创建一个 System.Object 对象,并假设可能根本不需要创建单实例对象、
// 否则,更经济、更简单的做法是在一个类构造器中创建单实例对象、
private static readonly Object s_lock = new Object();

// 这个字段引用一个单实例对象
private static Singleton s_value = null;

// 私有构造器阻止这个类外部的任何代码创建实例
private Singleton() {
// 把初始化单实例对象的代码放在这里...
}

// 以下公共静态方法返回单实例对象(如果必要就创建它)
public static Singleton GetSingleton() {
// 如果单实例对象已经创建,直接返回它(这样速度很快)
if (s_value != null) return s_value;

Monitor.Enter(s_lock); // 还没有创建,让一个线程创建它
if (s_value == null) {
// 仍未创建,创建它
Singleton temp = new Singleton();

// 将引用保存到 s_value 中(参见正文的详细讨论)
Volatile.Write(ref s_value, temp);
}
Monitor.Exit(s_lock);

// 返回对单实例对象的引用
return s_value;
}
}
解释为什么不使用s_value = new Singleton()?

这条语句可以分解成三句:

  • 编译器生成代码为一个 Singleton 分配内存
  • 调用构造器来初始化字段
  • 再将引用赋给 s_value 字段

但编译器可能打乱其运行顺序,单线程打乱顺序不会出现问题,但多线程可能会出现问题,比如:在将引用发布给 s_value 之后,并在调用构造器之前,如果另一个线程调用了 GetSingleton 方法。那么该线程会使用一个还没有初始化完的对象。

对 Volatile.Write 的调用修正了这个问题。它保证 temp 中的引用只有在构造器结束执行之后,才发布到 s_value 中。

面是 Singleton 类的一个简单得多的版本,它的行为和上一个版本相同。这个版本没有使用“著名”的双检锁技术:

1
2
3
4
5
6
7
8
9
10
internal sealed class Singleton {
private static Singleton s_value = new Singleton();

// 私有构造器防止这个类外部的任何代码创建一个实例
private Singleton() {
// 将初始化单实例对象的代码放在这里...
}
// 以下公共静态方法返回单实例对象 (如有必要就创建它)
public static Singleton GetSingleton() { return s_value; }
}

代码首次访问类的成员时,CLR 会自动调用类型的类构造器,CLR 已保证了对类构造器的调用是线程安全的。所以,如果 Singleton 类型定义了其他静态成员,就会在访问其他任何静态成员时创建 Singleton 对象。有人通过定义嵌套类来解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
internal sealed class Singleton {
private static Singleton s_value = null;

// 私有构造器防止这个类外部的任何代码创建实例
private Singleton() {
// 将初始化单实例对象的代码放在这里...
}

// 以下公共静态方法返回单实例对象 (如有必要就创建它)
public static Singleton GetSingleton() {
if (s_value != null) return s_value;

// 创建一个新的单实例对象,并把它固定下来(如果另一个线程还没有固定它的话)
Singleton temp = new Singleton();
Interlocked.CompareExchange(ref s_value, temp, null);

// 如果这线程竞争失败,新建的第二个单实例对象会被垃圾回收
return s_value; // 返回对单实例对象的引用
}
}

首先,它的速度非常快。其次,它永不阻塞线程。但可能创建多个 Singleton 对象,只有在构造器没有副作用的时候才能使用这个技术。

FCL 有两个类型封装了本书描述的模式。下面是泛型 Syste.Lazy 类:

1
2
3
4
5
public class Lazy<T> {
public Lazy(Func<T> valueFactory, LazyThreadSafetyMode mode);
public Boolean IsValueCreated { get; }
public T Value { get; }
}

LazyThreadSafetyMode 标志

1
2
3
4
5
public enum LazyThreadSafetyMode {
None, // 完全没有用线程安全支持 (适合 GUI 应用程序)
ExecutionAndPublication, // 使用双检锁技术
PublicationOnly // 使用 Interlocked.CompareExchange 技术
}

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 类的辅助函数来转变成一个阻塞的并发集合对象。如果集合已满,那么负责生产 (添加) 数据项的线程会阻塞;如果集合已空,那么负责消费 (移除) 数据项的线程会阻塞。