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),则情况就更为复杂了。
图1 CPU结构简图
在多线程环境下,当几个线程同时对number进行赋值操作时(假设number初始值为0),就有可能发生冲突:
当某个线程对number进行++操作并执行到步骤2(读取)时(0保存在CPU寄存器中),发生线程切换,该线程的所有寄存器状态被保存到内存后后,由另一个线程对number进行赋值操作。当另一个线程对number赋值完毕(假设将number赋值为10),切换回第一个线程,进行现场恢复,则在寄存器中保存的number值依然为0,该线程从步骤3继续执行指令,最终将1写入到number所在内存地址,number值最终为1,另一个线程对number赋值为10的操作表现为无效操作。
看一个例子:
-
-
-
-
namespace Edu.Study.Multithreading.WriteValue {
-
-
-
-
-
-
-
private static int number = 0;
-
-
-
-
-
private static Random random = new Random();
-
-
-
-
-
private static void ThreadWork(object arg) {
-
-
-
for (int i = 0; i < 1000; ++i) {
-
-
-
-
Thread.Sleep(random.Next(10));
-
-
-
-
-
-
-
-
static void Main(string[] args) {
-
-
-
-
Thread t1 = new Thread(new ParameterizedThreadStart(ThreadWork));
-
Thread t2 = new Thread(new ParameterizedThreadStart(ThreadWork));
-
-
-
-
-
-
-
while (t1.Join(Timeout.Infinite) && t2.Join(Timeout.Infinite)) {
-
Console.WriteLine(number);
-
-
-
Console.WriteLine("请按按回车键重新测试,任意键退出程序......");
-
} while (Console.ReadKey(false).Key == ConsoleKey.Enter);
-
-
-
例子中,两个线程(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方法演示
-
-
-
-
-
int x = Interlocked.Add(ref n, 1);
-
-
x = Interlocked.Add(ref n, -1);
-
Interlocked.Increment/Interlocked.Decrement方法演示
-
-
-
-
-
int x = Interlocked.Increment(ref n);
-
-
x = Interlocked.Decrement(ref n);
-
-
-
-
-
-
string old = Interlocked.Exchange(ref s, "OK");
-
Interloceked.CompareExchange方法演示
-
-
-
-
-
-
string old = Interlocked.CompareExchange(ref s, ss, "OK");
注意,原子操作中,要赋值的变量都是以引用方式传递参数的,这样才能在原子操作方法内部直接改变变量的值,才能完全避免非安全的赋值操作。
下面我们将前一节中出问题的代码做一些修改,修改其ThreadWork方法,在多线程下能够安全的操作同一个变量:
-
private static void ThreadWork(object arg) {
-
for (int i = 0; i < 1000; ++i) {
-
-
Interlocked.Add(ref number, 1);
-
Thread.Sleep(random.Next(10));
-
-
上述代码解决了一个重要的问题:同一个变量同时只能被一个线程赋值。
2 循环锁、关键代码段和令牌对象
使用变量的原子操作可以解决整数变量的加减计算和各类变量的赋值操作(或比较后赋值操作)的问题,但对于更复杂的同步操作,原子操作并不能解决问题。
有时候我们需要让同一段代码同时只能被一个线程执行,而不仅仅是同一个变量同时只能被一个线程访问,例如如下操作:
-
-
-
-
-
-
-
假设变量c是一个类字段,同时被若干线程赋值,显然仅通过原子操作,无法解决c变量被不同线程同时访问的问题,因为计算c需要若干步才能完成计算,需要比较多的指令,原子操作只能在对变量一次赋值时产生同步,面对多次赋值,显然无能为力。无论c=Math.Pow(a, 2)这步如何原子操作后,这步结束后下步开始前,c的值都有可能其它线程改变,从而最终计算出错误的结果。
所以锁定必须要施加到一段代码上才能解决上述问题,这就是关键代码段:
关键代码段需要两个前提条件:
令牌对象有个状态属性:具备两个属性值:挂起和释放。可以通过原子操作改变这个属性的属性值。规定:所有线程都可以访问同一个令牌对象,但只有访问时令牌对象状态属性为释放状态的那个线程,才能执行被锁定的代码,同时将令牌对象的状态属性更改为挂起。其余线程自动进入循环检测代码(在一个循环中不断检测令牌对象的状态),直到第一个对象访问完锁定代码,将令牌对象状态属性重新设置为释放状态,其余线程中的某一个才能检测到令牌对象已经释放并接着执行被锁定的代码,同时将令牌对象状态属性设置为挂起。
语法如下:
-
-
-
其中lock称为循环锁,访问的引用变量所引用的对象称为令牌对象,一对大括号中的代码称为关键代码段。如果同时有多个线程访问同一关键代码段,则可以保证每次同时只有一个线程可以执行这段代码,一个线程执行完毕后另一个线程才能解开锁并执行这段代码。
所以前面的那段代码可以改为:
-
-
-
-
-
-
-
-
-
在.net Framework中,任意引用类型对象都可以作为令牌对象。
锁定使用起来很简单,关键在使用前要考虑锁定的颗粒度,也就是锁定多少行代码才能真正的安全。锁定的代码过少,可能无法保证完全同步,锁定的代码过多,有可能会降低系统执行效率(导致线程无法真正意义上的同时执行),我们举个例子,解释一下锁定的颗粒度:
程序界面设计如下:
图2 循环锁程序设计界面
程序运行效果图如下:
图3 程序运行效果图
源代码摘录如下:
FormMain.cs
-
-
-
-
using System.Windows.Forms;
-
-
namespace Edu.Study.Multithreading.Lock {
-
-
-
-
-
-
-
public delegate void ChangeRadioButtonHandler(int index, Color color);
-
-
-
-
-
-
public partial class FormMain : Form {
-
-
-
-
-
private PictureBox[] picboxes = new PictureBox[10];
-
-
-
-
-
private Thread thread1 = null;
-
-
-
-
-
private Thread thread2 = null;
-
-
-
-
-
-
-
-
-
for (int i = 0; i < this.picboxes.Length; ++i) {
-
PictureBox rb = new PictureBox();
-
-
-
rb.Size = new Size(50, 50);
-
-
rb.BorderStyle = BorderStyle.Fixed3D;
-
-
rb.BackColor = Color.White;
-
-
-
-
-
this.mainFlowLayoutPanel.Controls.Add(rb);
-
-
-
-
-
this.mainFlowLayoutPanel.Padding.Left +
-
this.mainFlowLayoutPanel.Padding.Right +
-
this.picboxes.Length * (50 + this.picboxes[0].Margin.Left + this.picboxes[0].Margin.Right);
-
-
-
-
-
-
-
-
private void ChangeRadioButton(int index, Color color) {
-
-
-
-
-
-
-
-
this.picboxes[this.picboxes.Length - 1].BackColor = Color.White;
-
-
-
-
-
this.picboxes[index - 1].BackColor = Color.White;
-
-
-
-
this.picboxes[index].BackColor = color;
-
-
-
-
-
-
-
private void ThreadWorkTest1(object arg) {
-
-
-
-
for (int i = 0; i < this.picboxes.Length; ++i) {
-
-
-
-
this.BeginInvoke(new ChangeRadioButtonHandler(this.ChangeRadioButton), i, arg);
-
-
-
-
-
} catch (ThreadAbortException) {
-
-
-
-
-
-
-
-
private void ThreadWorkTest2(object arg) {
-
-
-
-
-
-
for (int i = 0; i < this.picboxes.Length; ++i) {
-
-
this.BeginInvoke(new ChangeRadioButtonHandler(this.ChangeRadioButton), i, arg);
-
-
-
-
-
} catch (ThreadAbortException) {
-
-
-
-
-
-
-
private void AbortThreads() {
-
-
if (this.thread1 != null) {
-
-
-
-
-
-
-
if (this.thread2 != null) {
-
-
-
-
-
-
-
-
-
private void test1StartButton_Click(object sender, EventArgs e) {
-
-
-
-
-
this.thread1 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest1));
-
-
this.thread2 = new Thread(new ParameterizedThreadStart(this.ThreadWorkTest1));
-
-
-
this.thread1.Start(Color.Red);
-
-
this.thread2.Start(Color.Green);
-
请发表评论