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有所不同,就是在笔者自己的电脑上,每次运行的结果也不尽相同。
可见,第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);
}
限于篇幅,这里不再展开,感兴趣的读者可以自行查阅资料。