本章介绍在C#中实现线程同步的几种方法。因为多个线程同时访问共享数据时,可能会造成共享数据的损坏,从而导致与预期的结果不相符。为了解决这个问题,所以需要用到线程同步,也被俗称为“加锁”。但是加锁绝对不对提高性能,最多也就是不增不减,要实现性能不增不减还得靠高质量的同步源语(Synchronization Primitive)。但是因为正确永远比速度更重要,所以线程同步在某些场景下是必须的。
线程同步有两种源语(Primitive)构造:用户模式(user - mode)和内核模式(kernel - mode),当资源可用时间短的情况下,用户模式要优于内核模式,但是如果长时间不能获得资源,或者说长时间处于“自旋”,那么内核模式是相对来说好的选择。
但是我们希望兼具用户模式和内核模式的优点,我们把它称为**混合构造(hybrid construct)**,它兼具了两种模式的优点。
在C#中有多种线程同步的机制,通常可以按照以下顺序进行选择。
在同步中,一定要注意避免死锁的发生,死锁的发生必须满足以下4个基本条件,所以只需要破坏任意一个条件,就可避免发生死锁。
CLR保证了对这些数据类型的读写是原子性的:Boolean、Char、(S)Byte、(U)Int16、(U)Int32、(U)IntPtr和Single。但是如果读写Int64可能会发生读取撕裂(torn read)的问题,因为在32位操作系统中,它需要执行两次Mov操作,无法在一个时间内执行完成。
那么在本节中,就会着重的介绍System.Threading.Interlocked类提供的方法,Interlocked类中的每个方法都是执行一次的读取以及写入操作。更多与Interlocked类相关的资料请参考链接,戳一戳本文不在赘述。
演示代码如下所示,分别使用了三种方式进行计数:错误计数方式、lock锁方式和Interlocked原子方式。
1private static void Main(string[] args)
2{
3 Console.WriteLine("错误的计数");
4
5 var c = new Counter();
6 Execute(c);
7
8 Console.WriteLine("--------------------------");
9
10
11 Console.WriteLine("正确的计数 - 有锁");
12
13 var c2 = new CounterWithLock();
14 Execute(c2);
15
16 Console.WriteLine("--------------------------");
17
18
19 Console.WriteLine("正确的计数 - 无锁");
20
21 var c3 = new CounterNoLock();
22 Execute(c3);
23
24 Console.ReadLine();
25}
26
27static void Execute(CounterBase c)
28{
29 // 统计耗时
30 var sw = new Stopwatch();
31 sw.Start();
32
33 var t1 = new Thread(() => TestCounter(c));
34 var t2 = new Thread(() => TestCounter(c));
35 var t3 = new Thread(() => TestCounter(c));
36 t1.Start();
37 t2.Start();
38 t3.Start();
39 t1.Join();
40 t2.Join();
41 t3.Join();
42
43 sw.Stop();
44 Console.WriteLine($"Total count: {c.Count} Time:{sw.ElapsedMilliseconds} ms");
45}
46
47static void TestCounter(CounterBase c)
48{
49 for (int i = 0; i < 100000; i++)
50 {
51 c.Increment();
52 c.Decrement();
53 }
54}
55
56class Counter : CounterBase
57{
58 public override void Increment()
59 {
60 _count++;
61 }
62
63 public override void Decrement()
64 {
65 _count--;
66 }
67}
68
69class CounterNoLock : CounterBase
70{
71 public override void Increment()
72 {
73 // 使用Interlocked执行原子操作
74 Interlocked.Increment(ref _count);
75 }
76
77 public override void Decrement()
78 {
79 Interlocked.Decrement(ref _count);
80 }
81}
82
83class CounterWithLock : CounterBase
84{
85 private readonly object _syncRoot = new Object();
86
87 public override void Increment()
88 {
89 // 使用Lock关键字 锁定私有变量
90 lock (_syncRoot)
91 {
92 // 同步块
93 Count++;
94 }
95 }
96
97 public override void Decrement()
98 {
99 lock (_syncRoot)
100 {
101 Count--;
102 }
103 }
104}
105
106
107abstract class CounterBase
108{
109 protected int _count;
110
111 public int Count
112 {
113 get
114 {
115 return _count;
116 }
117 set
118 {
119 _count = value;
120 }
121 }
122
123 public abstract void Increment();
124
125 public abstract void Decrement();
126}
127运行结果如下所示,与预期结果基本相符。

System.Threading.Mutex在概念上和System.Threading.Monitor几乎一样,但是Mutex同步对文件或者其他跨进程的资源进行访问,也就是说Mutex是可跨进程的。因为其特性,它的一个用途是限制应用程序不能同时运行多个实例。
Mutex对象支持递归,也就是说同一个线程可多次获取同一个锁,这在后面演示代码中可观察到。由于Mutex的基类System.Theading.WaitHandle实现了IDisposable接口,所以当不需要在使用它时要注意进行资源的释放。更多资料:戳一戳演示代码如下所示,简单的演示了如何创建单实例的应用程序和Mutex递归获取锁的实现。
1const string MutexName = "CSharpThreadingCookbook";
2
3static void Main(string[] args)
4{
5 // 使用using 及时释放资源
6 using (var m = new Mutex(false, MutexName))
7 {
8 if (!m.WaitOne(TimeSpan.FromSeconds(5), false))
9 {
10 Console.WriteLine("已经有实例正在运行!");
11 }
12 else
13 {
14 Console.WriteLine("运行中...");
15
16 // 演示递归获取锁
17 Recursion();
18
19 Console.ReadLine();
20 m.ReleaseMutex();
21 }
22 }
23
24 Console.ReadLine();
25}
26
27static void Recursion()
28{
29 using (var m = new Mutex(false, MutexName))
30 {
31 if (!m.WaitOne(TimeSpan.FromSeconds(2), false))
32 {
33 // 因为Mutex支持递归获取锁 所以永远不会执行到这里
34 Console.WriteLine("递归获取锁失败!");
35 }
36 else
37 {
38 Console.WriteLine("递归获取锁成功!");
39 }
40 }
41}
42运行结果如下图所示,打开了两个应用程序,因为使用Mutex实现了单实例,所以第二个应用程序无法获取锁,就会显示已有实例正在运行。

SemaphoreSlim类与之前提到的同步类有锁不同,之前提到的同步类都是互斥的,也就是说只允许一个线程进行访问资源,而SemaphoreSlim是可以允许多个访问。
在之前的部分有提到,以*Slim结尾的线程同步类,都是工作在混合模式下的,也就是说开始它们都是在用户模式下"自旋",等发生第一次竞争时,才切换到内核模式。但是SemaphoreSlim不同于Semaphore类,它不支持系统信号量,所以它不能用于进程之间的同步。
该类使用比较简单,演示代码演示了6个线程竞争访问只允许4个线程同时访问的数据库,如下所示。
1static void Main(string[] args)
2{
3 // 创建6个线程 竞争访问AccessDatabase
4 for (int i = 1; i <= 6; i++)
5 {
6 string threadName = "线程 " + i;
7 // 越后面的线程,访问时间越久 方便查看效果
8 int secondsToWait = 2 + 2 * i;
9 var t = new Thread(() => AccessDatabase(threadName, secondsToWait));
10 t.Start();
11 }
12
13 Console.ReadLine();
14}
15
16// 同时允许4个线程访问
17static SemaphoreSlim _semaphore = new SemaphoreSlim(4);
18
19static void AccessDatabase(string name, int seconds)
20{
21 Console.WriteLine($"{name} 等待访问数据库.... {DateTime.Now.ToString("HH:mm:ss.ffff")}");
22
23 // 等待获取锁 进入临界区
24 _semaphore.Wait();
25
26 Console.WriteLine($"{name} 已获取对数据库的访问权限 {DateTime.Now.ToString("HH:mm:ss.ffff")}");
27 // Do something
28 Thread.Sleep(TimeSpan.FromSeconds(seconds));
29
30 Console.WriteLine($"{name} 访问完成... {DateTime.Now.ToString("HH:mm:ss.ffff")}");
31 // 释放锁
32 _semaphore.Release();
33}
34运行结果如下所示,可见前4个线程马上就获取到了锁,进入了临界区,而另外两个线程在等待;等有锁被释放时,才能进入临界区。

AutoResetEvent叫自动重置事件,虽然名称中有事件一词,但是重置事件和C#中的委托没有任何关系,这里的事件只是由内核维护的Boolean变量,当事件为false,那么在事件上等待的线程就阻塞;事件变为true,那么阻塞解除。
在.Net中有两种此类事件,即AutoResetEvent(自动重置事件)和ManualResetEvent(手动重置事件)。这两者均是采用内核模式,它的区别在于当重置事件为true时,自动重置事件它只唤醒一个阻塞的线程,会自动将事件重置回false,造成其它线程继续阻塞。而手动重置事件不会自动重置,必须通过代码手动重置回false。
因为以上的原因,所以在很多文章和书籍中不推荐使用AutoResetEvent(自动重置事件),因为它很容易在编写生产者线程时发生失误,造成它的迭代次数多余消费者线程。
演示代码如下所示,该代码演示了通过AutoResetEvent实现两个线程的互相同步。
1static void Main(string[] args)
2{
3 var t = new Thread(() => Process(10));
4 t.Start();
5
6 Console.WriteLine("等待另一个线程完成工作!");
7 // 等待工作线程通知 主线程阻塞
8 _workerEvent.WaitOne();
9 Console.WriteLine("第一个操作已经完成!");
10 Console.WriteLine("在主线程上执行操作");
11 Thread.Sleep(TimeSpan.FromSeconds(5));
12
13 // 发送通知 工作线程继续运行
14 _mainEvent.Set();
15 Console.WriteLine("现在在第二个线程上运行第二个操作");
16
17 // 等待工作线程通知 主线程阻塞
18 _workerEvent.WaitOne();
19 Console.WriteLine("第二次操作完成!");
20
21 Console.ReadLine();
22}
23
24// 工作线程Event
25private static AutoResetEvent _workerEvent = new AutoResetEvent(false);
26// 主线程Event
27private static AutoResetEvent _mainEvent = new AutoResetEvent(false);
28
29static void Process(int seconds)
30{
31 Console.WriteLine("开始长时间的工作...");
32 Thread.Sleep(TimeSpan.FromSeconds(seconds));
33 Console.WriteLine("工作完成!");
34
35 // 发送通知 主线程继续运行
36 _workerEvent.Set();
37 Console.WriteLine("等待主线程完成其它工作");
38
39 // 等待主线程通知 工作线程阻塞
40 _mainEvent.WaitOne();
41 Console.WriteLine("启动第二次操作...");
42 Thread.Sleep(TimeSpan.FromSeconds(seconds));
43 Console.WriteLine("工作完成!");
44
45 // 发送通知 主线程继续运行
46 _workerEvent.Set();
47}
48运行结果如下图所示,与预期结果符合。

ManualResetEventSlim使用和ManualResetEvent类基本一致,只是ManualResetEventSlim工作在混合模式下,而它与AutoResetEventSlim不同的地方就是需要手动重置事件,也就是调用Reset()才能将事件重置为false。
演示代码如下,形象的将ManualResetEventSlim比喻成大门,当事件为true时大门打开,线程解除阻塞;而事件为false时大门关闭,线程阻塞。
1static void Main(string[] args)
2{
3 var t1 = new Thread(() => TravelThroughGates("Thread 1", 5));
4 var t2 = new Thread(() => TravelThroughGates("Thread 2", 6));
5 var t3 = new Thread(() => TravelThroughGates("Thread 3", 12));
6 t1.Start();
7 t2.Start();
8 t3.Start();
9 // 休眠6秒钟 只有Thread 1小于 6秒钟,所以事件重置时 Thread 1 肯定能进入大门 而 Thread 2 可能可以进入大门
10 Thread.Sleep(TimeSpan.FromSeconds(6));
11 Console.WriteLine($"大门现在打开了! 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
12 _mainEvent.Set();
13 // 休眠2秒钟 此时 Thread 2 肯定可以进入大门
14 Thread.Sleep(TimeSpan.FromSeconds(2));
15 _mainEvent.Reset();
16 Console.WriteLine($"大门现在关闭了! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");
17 // 休眠10秒钟 Thread 3 可以进入大门
18 Thread.Sleep(TimeSpan.FromSeconds(10));
19 Console.WriteLine($"大门现在第二次打开! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");
20 _mainEvent.Set();
21 Thread.Sleep(TimeSpan.FromSeconds(2));
22 Console.WriteLine($"大门现在关闭了! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");
23 _mainEvent.Reset();
24 Console.ReadLine();
25}
26static void TravelThroughGates(string threadName, int seconds)
27{
28 Console.WriteLine($"{threadName} 进入睡眠 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
29 Thread.Sleep(TimeSpan.FromSeconds(seconds));
30 Console.WriteLine($"{threadName} 等待大门打开! 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
31 _mainEvent.Wait();
32 Console.WriteLine($"{threadName} 进入大门! 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
33}
34static ManualResetEventSlim _mainEvent = new ManualResetEventSlim(false);运行结果如下,与预期结果相符。

CountDownEvent类内部构造使用了一个ManualResetEventSlim对象。这个构造阻塞一个线程,直到它内部计数器(CurrentCount)变为0时,才解除阻塞。也就是说它并不是阻止对已经枯竭的资源池的访问,而是只有当计数为0时才允许访问。
这里需要注意的是,当CurrentCount变为0时,那么它就不能被更改了。为0以后,Wait()方法的阻塞被解除。
演示代码如下所示,只有当Signal()方法被调用2次以后,Wait()方法的阻塞才被解除。
1static void Main(string[] args)
2{
3 Console.WriteLine($"开始两个操作 {DateTime.Now.ToString("mm:ss.ffff")}");
4 var t1 = new Thread(() => PerformOperation("操作 1 完成!", 4));
5 var t2 = new Thread(() => PerformOperation("操作 2 完成!", 8));
6 t1.Start();
7 t2.Start();
8
9 // 等待操作完成
10 _countdown.Wait();
11 Console.WriteLine($"所有操作都完成 {DateTime.Now.ToString("mm: ss.ffff")}");
12 _countdown.Dispose();
13
14 Console.ReadLine();
15}
16
17// 构造函数的参数为2 表示只有调用了两次 Signal方法 CurrentCount 为 0时 Wait的阻塞才解除
18static CountdownEvent _countdown = new CountdownEvent(2);
19
20static void PerformOperation(string message, int seconds)
21{
22 Thread.Sleep(TimeSpan.FromSeconds(seconds));
23 Console.WriteLine($"{message} {DateTime.Now.ToString("mm:ss.ffff")}");
24
25 // CurrentCount 递减 1
26 _countdown.Signal();
27}运行结果如下图所示,可见只有当操作1和操作2都完成以后,才执行输出所有操作都完成。

Barrier类用于解决一个非常稀有的问题,平时一般用不上。Barrier类控制一系列线程进行阶段性的并行工作。
假设现在并行工作分为2个阶段,每个线程在完成它自己那部分阶段1的工作后,必须停下来等待其它线程完成阶段1的工作;等所有线程均完成阶段1工作后,每个线程又开始运行,完成阶段2工作,等待其它线程全部完成阶段2工作后,整个流程才结束。
演示代码如下所示,该代码演示了两个线程分阶段的完成工作。
1static void Main(string[] args)
2{
3 var t1 = new Thread(() => PlayMusic("钢琴家", "演奏一首令人惊叹的独奏曲", 5));
4 var t2 = new Thread(() => PlayMusic("歌手", "唱着他的歌", 2));
5
6 t1.Start();
7 t2.Start();
8
9 Console.ReadLine();
10}
11
12static Barrier _barrier = new Barrier(2, Console.WriteLine($"第 {b.CurrentPhaseNumber + 1} 阶段结束"));
13
14static void PlayMusic(string name, string message, int seconds)
15{
16 for (int i = 1; i < 3; i++)
17 {
18 Console.WriteLine("----------------------------------------------");
19 Thread.Sleep(TimeSpan.FromSeconds(seconds));
20 Console.WriteLine($"{name} 开始 {message}");
21 Thread.Sleep(TimeSpan.FromSeconds(seconds));
22 Console.WriteLine($"{name} 结束 {message}");
23 _barrier.SignalAndWait();
24 }
25}运行结果如下所示,当“歌手”线程完成后,并没有马上结束,而是等待“钢琴家”线程结束,当"钢琴家"线程结束后,才开始第2阶段的工作。

ReaderWriterLockSlim类主要是解决在某些场景下,读操作多于写操作而使用某些互斥锁当多个线程同时访问资源时,只有一个线程能访问,导致性能急剧下降。
如果所有线程都希望以只读的方式访问数据,就根本没有必要阻塞它们;如果一个线程希望修改数据,那么这个线程才需要独占访问,这就是ReaderWriterLockSlim的典型应用场景。这个类就像下面这样来控制线程。
ReaderWriterLockSlim还支持从读线程升级为写线程的操作,详情请戳一戳。文本不作介绍。ReaderWriterLock类已经过时,而且存在许多问题,没有必要去使用。
示例代码如下所示,创建了3个读线程,2个写线程,读线程和写线程竞争获取锁。
1static void Main(string[] args)
2{
3 // 创建3个 读线程
4 new Thread(() => Read("Reader 1")) { IsBackground = true }.Start();
5 new Thread(() => Read("Reader 2")) { IsBackground = true }.Start();
6 new Thread(() => Read("Reader 3")) { IsBackground = true }.Start();
7
8 // 创建两个写线程
9 new Thread(() => Write("Writer 1")) { IsBackground = true }.Start();
10 new Thread(() => Write("Writer 2")) { IsBackground = true }.Start();
11
12 // 使程序运行30S
13 Thread.Sleep(TimeSpan.FromSeconds(30));
14
15 Console.ReadLine();
16}
17
18static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
19static Dictionary<int, int> _items = new Dictionary<int, int>();
20
21static void Read(string threadName)
22{
23 while (true)
24 {
25 try
26 {
27 // 获取读锁定
28 _rw.EnterReadLock();
29 Console.WriteLine($"{threadName} 从字典中读取内容 {DateTime.Now.ToString("mm:ss.ffff")}");
30 foreach (var key in _items.Keys)
31 {
32 Thread.Sleep(TimeSpan.FromSeconds(0.1));
33 }
34 }
35 finally
36 {
37 // 释放读锁定
38 _rw.ExitReadLock();
39 }
40 }
41}
42
43static void Write(string threadName)
44{
45 while (true)
46 {
47 try
48 {
49 int newKey = new Random().Next(250);
50 // 尝试进入可升级锁模式状态
51 _rw.EnterUpgradeableReadLock();
52 if (!_items.ContainsKey(newKey))
53 {
54 try
55 {
56 // 获取写锁定
57 _rw.EnterWriteLock();
58 _items[newKey] = 1;
59 Console.WriteLine($"{threadName} 将新的键 {newKey} 添加进入字典中 {DateTime.Now.ToString("mm:ss.ffff")}");
60 }
61 finally
62 {
63 // 释放写锁定
64 _rw.ExitWriteLock();
65 }
66 }
67 Thread.Sleep(TimeSpan.FromSeconds(0.1));
68 }
69 finally
70 {
71 // 减少可升级模式递归计数,并在计数为0时 推出可升级模式
72 _rw.ExitUpgradeableReadLock();
73 }
74 }
75}运行结果如下所示,与预期结果相符。

SpinWait是一个常用的混合模式的类,它被设计成使用用户模式等待一段时间,人后切换至内核模式以节省CPU时间。
它的使用非常简单,演示代码如下所示。
1static void Main(string[] args)
2{
3 var t1 = new Thread(UserModeWait);
4 var t2 = new Thread(HybridSpinWait);
5
6 Console.WriteLine("运行在用户模式下");
7 t1.Start();
8 Thread.Sleep(20);
9 _isCompleted = true;
10 Thread.Sleep(TimeSpan.FromSeconds(1));
11 _isCompleted = false;
12
13 Console.WriteLine("运行在混合模式下");
14 t2.Start();
15 Thread.Sleep(5);
16 _isCompleted = true;
17
18 Console.ReadLine();
19}
20
21static volatile bool _isCompleted = false;
22
23static void UserModeWait()
24{
25 while (!_isCompleted)
26 {
27 Console.Write(".");
28 }
29 Console.WriteLine();
30 Console.WriteLine("等待结束");
31}
32
33static void HybridSpinWait()
34{
35 var w = new SpinWait();
36 while (!_isCompleted)
37 {
38 w.SpinOnce();
39 Console.WriteLine(w.NextSpinWillYield);
40 }
41 Console.WriteLine("等待结束");
42}运行结果如下两图所示,首先程序运行在模拟的用户模式下,使CPU有一个短暂的峰值。然后使用SpinWait工作在混合模式下,首先标志变量为False处于用户模式自旋中,等待以后进入内核模式。

