25.5.3 线程同步

在讲线程同步之前,我们先来看一段代码,如代码清单25-10所示。

代码清单25-10 多线程示例代码


using System;

using System.Threading;

namespace ProgrammingCSharp4

{

class AsynchronousSample

{

int count;

public int Max

{

get;

set;

}

public void Increase()

{

for(int i=0;i<Max;i++)

{

count++;

}

}

static void Main(string[]args)

{

Thread[]threads=new Thread[500];

AsynchronousSample sample=new AsynchronousSample(){Max=10000};

for(int i=0;i<threads.Length;i++)

{

threads[i]=new Thread(sample.Increase);

threads[i].Start();

}

for(int i=0;i<threads.Length;i++)

{

threads[i].Join();

}

Console.WriteLine("{0:N0}",sample.count);

}

}

}


看完了上述代码可以发现,这个代码的逻辑实际并不复杂,就是使用500个线程一起调用AsynchronousSample的实例对象的Increase方法。这个方法执行字段count++10 000次,每个线程执行完毕,count都应该自增10 000,500个线程执行的结果,正常情况下count的最后值应该是10 000×500=5 000 000,那事实是否如此呢?

表25-2是代码清单25-10在运行了5次以后的结果记录。需要说明的是,读者朋友如果运行代码清单25-10的话,运行结果可能与表25-2有所不同,就是在笔者自己的电脑上,每次运行的结果也不尽相同。

25.5.3 线程同步 - 图1

可见,第1、2、4次运行结果是5 000 000(注意,这里使用的输出格式是我们在第20章学习的字符串格式化的知识,温故而知新嘛),是对的,那么第3、5次为什么不是5 000 000而是比5 000 000小的4 998 264和4 998 077呢?注意,也可能是其他比5 000 000小的数字。下面来分析一下原因,实际上问题就出在Increase方法体中的"count++"这一句上,看似简单的一条自增语句怎么导致了这种结果呢?我们使用ILDasm分析一下count++编译后的CIL代码,如代码清单25-11所示。

代码清单25-11 count++语句编译后生成的CIL代码


IL_0006:ldfld int32 ProgrammingCSharp4.AsynchronousSample:count

IL_000b:ldc.i4.1

IL_000c:add

IL_000d:stfld int32 ProgrammingCSharp4.AsynchronousSample:count


可以看出,看起来就一句的"count++"语句分解为了4句,这4句的含义分别是:

(1)加载count字段的当前值到栈中;

(2)加载数字常量1到栈中;

(3)将栈顶的2个值相加,并返回结果;

(4)将计算的结果更新到count字段。

可见,count++语句并非原子操作,但它分解而成的这4个IL指令都是原子操作,且都是线程安全的,只是合并起来就非线程安全了。试想,如果线程1读到的count值是0,而此时线程2已经完成自增操作,但并未更新count字段时,这时线程1自增的结果将和线程2相同,都是1,本来应该是2的。因此这也解释了为什么计算的结果只会比5 000 000小,而不可能更大的原因。

知道了原因就简单了,那就是想办法让这4个原子操作组合为1个原子操作,也就是说任何时刻都只运行一个线程执行count++操作,这就是线程同步。要做到线程同步有很多种不同方法,这里我们介绍其中的几种。其中最常用的一个方法,就是使用lock语句为某个代码块加锁,在加锁的代码块只允许一个线程进入并执行,同时该线程获得该锁,在代码块执行完毕将自动解锁。

接下来使用lock关键字来为count++加锁,如代码清单25-12所示。

代码清单25-12 改进后的代码


using System;

using System.Threading;

namespace ProgrammingCSharp4

{

class AsynchronousSample

{

int count;

private object_lock=new object();

public int Max

{

get;

set;

}

public void Increase()

{

for(int i=0;i<Max;i++)

{

lock(_lock)

{

count++;

}

}

}

static void Main(string[]args)

{

Thread[]threads=new Thread[500];

AsynchronousSample sample=new AsynchronousSample(){Max=10000};

for(int i=0;i<threads.Length;i++)

{

threads[i]=new Thread(sample.Increase);

threads[i].Start();

}

for(int i=0;i<threads.Length;i++)

{

threads[i].Join();

}

Console.WriteLine("{0:N0}",sample.count);

}

}

}


再次运行,结果每次都是正确的了。到此使用了线程同步技术,问题解决。

还没结束,回过头来,研究一下这个神奇的lock语句,lock语句的语法形式为:


Object thisLock=new Object();

lock(thisLock)

{

//代码块

}


这里的thisLock对象应该避免使用public访问级别,最佳做法是使用private访问级别。除了实例对象,也可以使用静态对象来保护所有实例所共有的数据,该静态对象也应该是private访问级别。

那么,大家是不是很想知道使用了lock语句以后,IL代码会发生怎样的变化呢?我们马上来看看,如代码清单25-13所示。

代码清单25-13 使用了lock语句后生成的IL代码节选


.try

{

IL_0006:ldarg.0

IL_0007:ldfld object ProgrammingCSharp4.AsynchronousSample:_lock

IL_000c:dup

IL_000d:stloc.2

IL_000e:ldloca.s'<>s__LockTaken0'

IL_0010:call void[mscorlib]System.Threading.Monitor:Enter(object,bool&)

IL_0015:ldarg.0

IL_0016:dup

IL_0017:ldfld int32 ProgrammingCSharp4.AsynchronousSample:count

IL_001c:ldc.i4.1

IL_001d:add

IL_001e:stfld int32 ProgrammingCSharp4.AsynchronousSample:count

IL_0023:leave.s IL_002f

}//end.try

finally

{

IL_0025:ldloc.1

IL_0026:brfalse.s IL_002e

IL_0028:ldloc.2

IL_0029:call void[mscorlib]System.Threading.Monitor:Exit(object)

IL_002e:endfinally

}//end handler


可见,编译器进行了如下操作:

1)添加了try……finally语句;

2)lock语句转换为了对System.Threading.Monitor.Enter(Object)以及System.Threading.Monitor.Exit()两个方法的调用,前者是获取锁,后者是释放锁。

可见,lock语句只不过是一个简化Monitor类使用的快捷语法,编译器在幕后帮助我们做了许多工作。事实上,Monitor类提供了同步访问对象的机制,我们也可以直接使用它来实现线程同步的目的。这里的lock语句实际上和下述使用Monitor类的代码相当,如代码清单25-14所示。

代码清单25-14 使用Monitor实现lock语句功能相当的代码示例


Monitor.Enter(_lock);

try

{

count++;

}

finally

{

Monitor.Exit(_lock);

}


限于篇幅,这里不再展开,感兴趣的读者可以自行查阅资料。