C#基本线程同步

时间:2023-03-09 17:07:37
C#基本线程同步

0 概述

  所谓同步,就是给多个线程规定一个执行的顺序(或称为时序),要求某个线程先执行完一段代码后,另一个线程才能开始执行。

  

第一种情况:多个线程访问同一个变量

  1. 一个线程写,其它线程读:这种情况不存在同步问题,因为只有一个线程在改变内存中的变量,内存中的变量在任意时刻都有一个确定的值;

  2. 一个线程读,其它线程写:这种情况会存在同步问题,主要是多个线程在同时写入一个变量的时候,可能会发生一些难以察觉的错误,导致某些线程实际上并没有真正的写入变量;

  3. 几个线程写,其它线程读:情况同2。

  多个线程同时向一个变量赋值,就会出现问题,这是为什么呢?

  我们编程采用的是高级语言,这种语言是不能被计算机直接执行的,一条高级语言代码往往要编译为若干条机器代码,而一条机器代码,CPU也不一定是在一个CPU周期内就能完成的。计算机代码必须要按照一个“时序”,逐条执行。

  举个例子,在内存中有一个整型变量number(4字节),那么计算++number(运算后赋值)就至少要分为如下几个步骤:

  1. 寻址:由CPU的控制器找寻到number变量所在的地址;

  2. 读取:将number变量所在的值从内存中读取到CPU寄存器中;

  3. 运算:由CPU的算术逻辑运算器(ALU)对number值进行计算,将结果存储在寄存器中;

  4. 保存:由CPU的控制器将寄存器中保存的结果重新存入number在内存中的地址。

  这是最简单的时序,如果牵扯到CPU的高速缓存(CACHE),则情况就更为复杂了。

C#基本线程同步图1 CPU结构简图

  在多线程环境下,当几个线程同时对number进行赋值操作时(假设number初始值为0),就有可能发生冲突:

  当某个线程对number进行++操作并执行到步骤2(读取)时(0保存在CPU寄存器中),发生线程切换,该线程的所有寄存器状态被保存到内存后后,由另一个线程对number进行赋值操作。当另一个线程对number赋值完毕(假设将number赋值为10),切换回第一个线程,进行现场恢复,则在寄存器中保存的number值依然为0,该线程从步骤3继续执行指令,最终将1写入到number所在内存地址,number值最终为1,另一个线程对number赋值为10的操作表现为无效操作。

  看一个例子:

  1. using System;
  2. using System.Threading;
  3. namespace Edu.Study.Multithreading.WriteValue {
  4. class Program {
  5. /// <summary>
  6. /// 多个线程要访问的变量
  7. /// </summary>
  8. private static int number = 0;
  9. /// <summary>
  10. /// 令线程随机休眠的随机数对象
  11. /// </summary>
  12. private static Random random = new Random();
  13. /// <summary>
  14. /// 线程入口方法, 这里为了简化编程, 使用了静态方法
  15. /// </summary>
  16. private static void ThreadWork(object arg) {
  17. // 循环1000次, 每次将number字段的值加1
  18. for (int i = 0; i < 1000; ++i) {
  19. // += 1操作比++操作需要更多的CPU指令, 以增加出现错误的几率
  20. number += 1;
  21. // 线程在10毫秒内随机休眠, 以增加出现错误的几率
  22. Thread.Sleep(random.Next(10));
  23. }
  24. }
  25. /// <summary>
  26. /// 主方法
  27. /// </summary>
  28. static void Main(string[] args) {
  29. do {
  30. // 令number为0, 重新给其赋值
  31. number = 0;
  32. Thread t1 = new Thread(new ParameterizedThreadStart(ThreadWork));
  33. Thread t2 = new Thread(new ParameterizedThreadStart(ThreadWork));
  34. // 启动两个线程访问number变量
  35. t1.Start();
  36. t2.Start();
  37. // 等待线程退出, Timeout.Infinite表示无限等待
  38. while (t1.Join(Timeout.Infinite) && t2.Join(Timeout.Infinite)) {
  39. Console.WriteLine(number);
  40. break;
  41. }
  42. Console.WriteLine("请按按回车键重新测试,任意键退出程序......");
  43. } while (Console.ReadKey(false).Key == ConsoleKey.Enter);
  44. }
  45. }
  46. }

  例子中,两个线程(t1和t2)同时访问number变量(初始值为0),对其进行1000次+1操作,在两个线程都结束后,在主线程显式number变量的最终值。可以看到,很经常的,最终显示的结果不是2000,而是1999或者更少。究其原因,就是发生了我们上面讲的问题:两个线程在进行赋值操作时,时序重叠了

  可以做实验,在CPU核心数越多的计算机上,上述代码出现问题的几率越小。这是因为多核心CPU可能会在每一个独立核心上各自运行一个线程,而CPU设计者针对这种多核心访问一个内存地址的情况,本身就设计了防范措施。

第二种情况:多个线程组成了生产者和消费者:

  我们前面已经讲过,多线程并不能加快算法速度(多核心处理器除外),所以多线程的主要作用还是为了提高用户的响应,一般有两种方式:

  • 将响应窗体事件操作和复杂的计算操作分别放在不同的线程中,这样当程序在进行复杂计算时不会阻塞到窗体事件的处理,从而提高用户操作响应;
  • 对于为多用户服务的应用程序,可以一个独立线程为一个用户提供服务,这样用户之间不会相互影响,从而提高了用户操作的响应。

  所以,线程之间很容易就形成了生产者/消费者模式,即一个线程的某部分代码必须要等待另一个线程计算出结果后才能继续运行。目前存在两种情况需要线程间同步执行:

  • 多个线程向一个变量赋值或多线程改变同一对象属性;
  • 某些线程等待另一些线程执行某些操作后才能继续执行。

1 变量的原子操作

  CPU有一套指令,可以在访问内存中的变量前,并将一段内存地址标记为“只读”,此时除过标志内存的那个线程外,其余线程来访问这块内存,都将发生阻塞,即必须等待前一个线程访问完毕后其它线程才能继续访问这块内存。

  这种锁定的结果是:所有线程只能依次访问某个变量,而无法同时访问某个变量,从而解决了多线程访问变量的问题。

  原子操作封装在Interlocked类中,以一系列静态方法提供:

  • Add方法,对整型变量(4位、8位)进行原子的加法/减法操作,相当于n+=x或n-=x表达式的原子操作版本;
  • Increment方法,对整形变量(4位、8位)进行原子的自加操作,相当于++n的原子操作版本;
  • Decrement方法,对整型变量(4位、8位)进行原子的自减操作,相当于--n的原子操作版本;
  • Exchange方法,对变量或对象引用进行原子的赋值操作;
  • CompareExchange方法,对两个变量或对象引用进行比较,如果相同,则为其赋值。

  例如:

Interlocked.Add方法演示

  1. int n = 0;
  2. // 将n加1
  3. // 执行完毕后n的值变为1, 和返回值相同
  4. int x = Interlocked.Add(ref n, 1);
  5. // 将n减1
  6. x = Interlocked.Add(ref n, -1);
  7. Interlocked.Increment/Interlocked.Decrement方法演示
  8. int n = 0;
  9. // 对n进行自加操作
  10. // 执行完毕后n的值变为1, 和返回值相同
  11. int x = Interlocked.Increment(ref n);
  12. // 对n进行自减操作
  13. x = Interlocked.Decrement(ref n);
  14. Interlocked.Exchange方法演示
  15. string s = "Hello";
  16. // 用另一个字符串对象"OK"为s赋值
  17. // 操作完毕后s变量改变为引用到"OK"对象, 返回"Hello"对象的引用
  18. string old = Interlocked.Exchange(ref s, "OK");
  19. Interloceked.CompareExchange方法演示
  20. string s = "Hello";
  21. string ss = s;
  22. // 首先用变量ss和s比较, 如果相同, 则用另一个字符串对象"OK"为s赋值
  23. // 操作完毕后s变量改变为引用到"OK"对象, 返回"Hello"对象的引用
  24. string old = Interlocked.CompareExchange(ref s, ss, "OK");

  注意,原子操作中,要赋值的变量都是以引用方式传递参数的,这样才能在原子操作方法内部直接改变变量的值,才能完全避免非安全的赋值操作。

下面我们将前一节中出问题的代码做一些修改,修改其ThreadWork方法,在多线程下能够安全的操作同一个变量:

  1. private static void ThreadWork(object arg) {
  2. for (int i = 0; i < 1000; ++i) {
  3. // 使用原子方式操作变量, 避免多个线程为同一变量赋值出现错误
  4. Interlocked.Add(ref number, 1);
  5. Thread.Sleep(random.Next(10));
  6. }
  7. }

  上述代码解决了一个重要的问题:同一个变量同时只能被一个线程赋值

2 循环锁、关键代码段和令牌对象

  使用变量的原子操作可以解决整数变量的加减计算和各类变量的赋值操作(或比较后赋值操作)的问题,但对于更复杂的同步操作,原子操作并不能解决问题。

  有时候我们需要让同一段代码同时只能被一个线程执行,而不仅仅是同一个变量同时只能被一个线程访问,例如如下操作:

  1. double a = 10;
  2. double b = 20;
  3. c = Math.Pow(a, 2);
  4. c += Math.Pow(b, 2);
  5. c = Math.Sqrt(c);
  6. c /= Math.PI;

  假设变量c是一个类字段,同时被若干线程赋值,显然仅通过原子操作,无法解决c变量被不同线程同时访问的问题,因为计算c需要若干步才能完成计算,需要比较多的指令,原子操作只能在对变量一次赋值时产生同步,面对多次赋值,显然无能为力。无论c=Math.Pow(a, 2)这步如何原子操作后,这步结束后下步开始前,c的值都有可能其它线程改变,从而最终计算出错误的结果。

  所以锁定必须要施加到一段代码上才能解决上述问题,这就是关键代码段

  关键代码段需要两个前提条件:

  • 一个作为令牌的对象;
  • 一个锁操作。

  令牌对象有个状态属性:具备两个属性值:挂起和释放。可以通过原子操作改变这个属性的属性值。规定:所有线程都可以访问同一个令牌对象,但只有访问时令牌对象状态属性为释放状态的那个线程,才能执行被锁定的代码,同时将令牌对象的状态属性更改为挂起。其余线程自动进入循环检测代码(在一个循环中不断检测令牌对象的状态),直到第一个对象访问完锁定代码,将令牌对象状态属性重新设置为释放状态,其余线程中的某一个才能检测到令牌对象已经释放并接着执行被锁定的代码,同时将令牌对象状态属性设置为挂起

  语法如下:

  1. lock (对象引用) {
  2. // 关键代码段
  3. }

  其中lock称为循环锁,访问的引用变量所引用的对象称为令牌对象,一对大括号中的代码称为关键代码段。如果同时有多个线程访问同一关键代码段,则可以保证每次同时只有一个线程可以执行这段代码,一个线程执行完毕后另一个线程才能解开锁并执行这段代码。

  所以前面的那段代码可以改为:

  1. double a = 10;
  2. double b = 20;
  3. lock (某对象引用) {
  4. c = Math.Pow(a, 2);
  5. c += Math.Pow(b, 2);
  6. c = Math.Sqrt(c);
  7. c /= Math.PI;
  8. }

  在.net Framework中,任意引用类型对象都可以作为令牌对象。

  锁定使用起来很简单,关键在使用前要考虑锁定的颗粒度,也就是锁定多少行代码才能真正的安全。锁定的代码过少,可能无法保证完全同步,锁定的代码过多,有可能会降低系统执行效率(导致线程无法真正意义上的同时执行),我们举个例子,解释一下锁定的颗粒度:

  程序界面设计如下:

C#基本线程同步  图2 循环锁程序设计界面

  程序运行效果图如下:

C#基本线程同步   图3 程序运行效果图

  源代码摘录如下:

FormMain.cs

  1. using System;
  2. using System.Drawing;
  3. using System.Threading;
  4. using System.Windows.Forms;
  5. namespace Edu.Study.Multithreading.Lock {
  6. /// <summary>
  7. /// 更新PictureBox背景色的委托
  8. /// </summary>
  9. /// <param name="index">要更改背景色的PictureBox对象在数组中的索引</param>
  10. /// <param name="color">背景色</param>
  11. public delegate void ChangeRadioButtonHandler(int index, Color color);
  12. /// <summary>
  13. /// 主窗体
  14. /// </summary>
  15. public partial class FormMain : Form {
  16. /// <summary>
  17. /// RadioButton的数组
  18. /// </summary>
  19. private PictureBox[] picboxes = new PictureBox[10];
  20. /// <summary>
  21. /// 线程1, 将picboxes数组中的PictureBox对象背景色逐个设置为红色
  22. /// </summary>
  23. private Thread thread1 = null;
  24. /// <summary>
  25. /// 线程1, 将picboxes数组中的PictureBox对象背景色逐个设置为绿色
  26. /// </summary>
  27. private Thread thread2 = null;
  28. /// <summary>
  29. /// 主窗体构造器
  30. /// </summary>
  31. public FormMain() {
  32. InitializeComponent();
  33. // 初始化picboxes数组, 向其中存放PictureBox对象引用
  34. for (int i = 0; i < this.picboxes.Length; ++i) {
  35. PictureBox rb = new PictureBox();
  36. // 设置PictureBox对象大小
  37. rb.Size = new Size(50, 50);
  38. // 设置PictureBox边框样式
  39. rb.BorderStyle = BorderStyle.Fixed3D;
  40. // 设置PictureBox背景色初始为白色
  41. rb.BackColor = Color.White;
  42. this.picboxes[i] = rb;
  43. // 将PictureBox控件对象放置在流式布局面板上
  44. this.mainFlowLayoutPanel.Controls.Add(rb);
  45. }
  46. // 根据控件的数量重新计算窗体宽度
  47. this.Width =
  48. this.mainFlowLayoutPanel.Padding.Left +
  49. this.mainFlowLayoutPanel.Padding.Right +
  50. this.picboxes.Length * (50 + this.picboxes[0].Margin.Left + this.picboxes[0].Margin.Right);
  51. }
  52. /// <summary>
  53. /// 实现ChangeRadioButtonHandler委托, 转换设置某个PictureBox控件背景色
  54. /// </summary>
  55. /// <param name="index">要更改背景色的PictureBox在数组中的索引</param>
  56. /// <param name="color">背景色</param>
  57. private void ChangeRadioButton(int index, Color color) {
  58. // 操作如下: 从this.picboxes数组中, 每次将index参数指定的PictureBox对象设置为参数color指定的颜色
  59. // 并将前一个PictureBox对象背景色设置为白色
  60. if (index == 0) { // 如果index参数为零, 表示数组中第一个PictureBox对象
  61. // 将数组最后一个PictureBox对象背景色设置为白色
  62. this.picboxes[this.picboxes.Length - 1].BackColor = Color.White;
  63. } else { // 如果index参数不为零
  64. // 将 index-1指定的PictureBox背景色设置为白色
  65. this.picboxes[index - 1].BackColor = Color.White;
  66. }
  67. // 将index指定的PictureBox对象背景色设置为color参数指定的颜色
  68. this.picboxes[index].BackColor = color;
  69. }
  70. /// <summary>
  71. /// 线程方法1, 展示颗粒度较小的锁定
  72. /// </summary>
  73. /// <param name="arg">传入的参数对象, 这里为一个Color类的对象, 表示背景色</param>
  74. private void ThreadWorkTest1(object arg) {
  75. try { // 用于退出线程的异常捕获结构
  76. while (true) {
  77. // 遍历this.picboxes数组
  78. for (int i = 0; i < this.picboxes.Length; ++i) {
  79. // 以当前Form类对象为令牌对象, 这次锁定发生在循环内
  80. lock (this) {
  81. // 执行ChangeRadioButton方法, 更改PictureBox的背景色
  82. this.BeginInvoke(new ChangeRadioButtonHandler(this.ChangeRadioButton), i, arg);
  83. Thread.Sleep(500);
  84. }
  85. }
  86. }
  87. } catch (ThreadAbortException) {
  88. }
  89. }
  90. /// <summary>
  91. /// 线程方法2, 展示颗粒度较大的锁定
  92. /// </summary>
  93. /// <param name="arg">传入的参数对象, 这里为一个Color类的对象, 表示背景色</param>
  94. private void ThreadWorkTest2(object arg) {
  95. try { // 用于退出线程的异常捕获结构
  96. while (true) {
  97. // 以当前Form类对象为令牌对象, 这次锁定锁定整个循环
  98. lock (this) {
  99. // 遍历this.picboxes数组
  100. for (int i = 0; i < this.picboxes.Length; ++i) {
  101. // 执行ChangeRadioButton方法, 更改PictureBox的背景色
  102. this.BeginInvoke(new ChangeRadioButtonHandler(this.ChangeRadioButton), i, arg);
  103. Thread.Sleep(500);
  104. }
  105. }
  106. }
  107. } catch (ThreadAbortException) {
  108. }
  109. }
  110. /// <summary>
  111. /// 退出线程
  112. /// </summary>
  113. private void AbortThreads() {
  114. // 如果线程1对象存在, 终止线程1对象
  115. if (this.thread1 != null) {
  116. // 取消线程运行
  117. this.thread1.Abort();
  118. // 等待线程结束
  119. this.thread1.Join();
  120. }
  121. // 如果线程2对象存在, 终止线程2对象
  122. if (this.thread2 != null) {
  123. this.thread2.Abort();
  124. this.thread2.Join();
  125. }
  126. }
  127. /// <summary>
  128. /// "测试1"按钮事件
  129. /// </summary>
  130. private void test1StartButton_Click(object sender, EventArgs e) {
  131. // 终止上一次启动的线程
  132. this.AbortThreads();
  133. // 初始化线程1, 使用ThreadWorkTest1方法作为入口方法
  134. this.thread1 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest1));
  135. // 初始化线程2, 使用ThreadWorkTest1方法作为入口方法
  136. this.thread2 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest1));
  137. // 启动线程1, 参数为红色, 表示线程1将picboxes数组中的对象改为红色
  138. this.thread1.Start(Color.Red);
  139. // 启动线程2, 参数为绿色, 表示线程1将picboxes数组中的对象改为绿色
  140. this.thread2.Start(Color.Green);
  141. }
  142. /// <summary>
  143. /// "测试2"按钮事件
  144. /// </summary>
  145. private void test2StartButton_Click(object sender, EventArgs e) {
  146. // 终止上一次启动的线程
  147. this.AbortThreads();
  148. // 初始化线程1, 使用ThreadWorkTest2方法作为入口方法
  149. this.thread1 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest2));
  150. // 初始化线程2, 使用ThreadWorkTest2方法作为入口方法
  151. this.thread2 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest2));
  152. // 启动线程1, 参数为红色, 表示线程1将picboxes数组中的对象改为红色
  153. this.thread1.Start(Color.Red);
  154. // 启动线程2, 参数为绿色, 表示线程1将picboxes数组中的对象改为绿色
  155. this.thread2.Start(Color.Green);
  156. }
  157. /// <summary>
  158. /// 窗体关闭事件
  159. /// </summary>
  160. private void FormMain_FormClosing(object sender, FormClosingEventArgs e) {
  161. // 终止并等待所有的辅助线程
  162. AbortThreads();
  163. }
  164. }
  165. }

几点说明:

  • ThreadWorkTest1方法(第95-110行)和ThreadWorkTest2方法(第116-131行)算法逻辑完全相同,只是锁定颗粒度不同,前者是在循环内对代码加锁,后者是再循环外对循环加锁。造成的效果就是:前者两个线程可以同时进入循环,只是执行循环内两句代码时是同步执行的,所以点击“测试1”按钮,可以看到每个图片框先变成红色,然后变成绿色,接下来轮到下一个图片框不断重复;后者两个线程同时只能有一个进入循环,所以点击“测试2”按钮,可以看到,所有的图片框先逐次变成红色,然后逐次变成绿色;
  • 两个按钮点击事件都要启动新的线程,所以必须等待原有线程结束后才能启动新线程,AbortThreads方法(第136-149行)作用就是结束线程并等待线程退出。由于线程中没有必须要完成的操作,所以直接使用Thread类的Abort方法结束线程,并使用Join方法等待线程退出。
  • BeginInvoke方法(第103行、124行)和Invoke方法作用相同,区别就是Invoke方法会等待窗体线程(这里为主线程)将委托方法执行完毕后才返回,而BeginInvoke方法则不会等待,只是告诉窗体线程要执行这么一个委托,并不关心委托方法执行结果。BeginInvoke方法可以避免窗体线程阻塞导致的Invoke方法被阻塞,即即便窗体线程被阻塞了(例如使用Join方法阻塞),辅助线程调用窗体的BeginInvoke方法也不会被阻塞。如此以来就可以省略前面介绍的启动等待线程等待辅助线程结束的代码。注意:BeginInvoke的使用时机为:1、不关心委托方法的返回值;2、传递给委托方法的参数一般为一个局部对象的引用,如果是全局对象,则无法保证委托方法运行时,这个对象是什么状态。

  通过上述的例子,一方面思考循环锁的作用;一方面考虑调整循环锁的锁定颗粒度对程序带来的影响。将104行线程休眠代码移出关键代码段(即移动到lock结束大括号之后),运行代码,查看运行结果,思考原因。