执行代码
{
for (int i = 0; i < 10; i++)
{
Thread t = new Thread(delegate()
{
Thread.Sleep(new Random().Next(1, 10000));
Console.Write(i + ", ");
});
t.Start();
}
}
将得到输出:10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
而不是我们期望的类似于:3, 5, 6, 1, 0, 7, 9, 8, 4, 2, 这样的输出。这是为什么呢?(在实际项目中出现这个Bug的代码请参考[1])
分析:使用Reflector查看编译之后的代码
下面是编译前后的代码对比。(使用Reflector的具体方法请参考[2],这里仅展现结果)
可以发现,i 对于匿名方法来说算得上是“全局”变量,如果在线程处理 i 之前,i 的值就被改变了的话,就会出现我们不希望出现的结果。
解决方法1:使用更小范围的局部变量
我们做一个小小的更改,在for循环里面定义一个变量 j ,让匿名函数只访问这个 j
所以运行程序,可得到正确的输出:3, 5, 6, 1, 0, 7, 9, 8, 4, 2,
由此,我们可以做出一个假设:编译器会在声明匿名函数所使用的局部变量的地方声明AutoGenClass的实例。那么,可以推出另一个结论:如果匿名函数里面使用了成员变量,那么ThreadStart对象也会变成成员变量,有兴趣的话可以自己用Reflector看一下。
这个方法虽然能有效解决问题,但有一个缺点:声明临时变量的意图不明显。为了避免哪天有个十分热心又不明就里的程序员觉得“变量 j 根本和 i 一样嘛”而把 j 给移除了,我强烈建议在 j 的后面加一个注释:“这里声明一个临时变量 j 是有深意的,谁敢动它老子跟谁玩命!!”。为避免这个缺点,可考虑使用下面的解决方法。
解决方法2:在匿名函数中只使用它的参数
这次匿名函数压根就没使用“全局”变量,所以同样可得到正确的结果:9, 7, 0, 5, 3, 8, 1, 6, 4, 2,
如果需要使用线程池,代码大同小异:
{
for (int i = 0; i < 10; i++)
{
ThreadPool.QueueUserWorkItem(delegate(object arg)
{
Thread.Sleep(new Random().Next(1, 1000));
Console.Write(arg + ", ");
}, i);
}
Thread.Sleep(10000);
}
让我们再一次仔细思考一下为什么会出现Bug,以及解决方法1和解决方法2是如何生效的。为什么会出现Bug呢?表面上看是因为使用了多线程。但为什么使用了多线程结果就不对了呢?是因为我们期望的执行顺序是“把i的值增加1、输出i、把i的值再增加1、再输出i……”,而实际上由于输出 i 的操作由另一个线程来执行,导致输出 i 的操作进入了另一个平行宇宙——呃,我是说异步操作之中。这样,i 的值在不停地改变,而输出 i 的操作随时都可能被执行,所以输出的 i 的值就成了随机的,程序的行为也成了随机的,这可真是个不折不扣的Bug。想想看,如果另一个平行宇宙里的1-2-3脱掉裤子,会影响到我的裤子也一同掉下来,这是件多可怖的事情!
要解决这个问题,就要使 i 一但被传递给线程来执行,就不再受到主线程里对 i 的改变的影响。解决方法1通过每次创建新的线程前都创建一个新的AutoGenClass实例,并Copy i 的值(因为 i 是值类型)给AutoGenClass实例的成员变量达到这个目标。解决方法2通过在启动线程时把 i 的值压入匿名方法的参数堆栈来达到这个目标。
既然确保 i 的值在传递给线程执行之后就不再改变这么重要,我们是否应该去微软总部门前示威,要求在匿名函数里只能使用只读的局部变量呢?就这么定了!机票钱老赵出,现在报名……
冷静。不应该做这样的限制,因为 1)匿名函数既可以用作同步执行,也可被异步执行。在同步执行的时候更改局部变量是没问题的,而且同步执行的情况比较多,我们哪能因噎废食呢?2)即使限制成只读变量也没用。因为如果 i 是个复杂类型的实例的话,即使声明成只读的,一样可以更改它的属性的值,而如果匿名函数正巧依赖它的属性值,Bug还是会发生。3)有时我们需要故意让线程共享可修改的对象,请看下文。
“这TMD是怎么回事?”突然听到 Boss 吼道,“为什么你的程序输出的结果是无序的?马上给我改成输出 0,1,2,3,4,5,6,7,8,9, !”
可是我们用的多线程呀,怎么能保证各个线程按顺序取得数据呢?这种时候,我们就需要故意使用一个可被主线程和其它线程共享、修改的对象,当然一些同步操作也是必须的,请看下面的例子。
解决方法3:使用泛型Queue传递数据
代码如下:
{
Queue<int> q = new Queue<int>();
for (int i = 0; i < 10; i++)
{
q.Enqueue(i);
Thread t = new Thread(delegate()
{
Thread.Sleep(new Random().Next(1, 10000));
lock (q)
{
if (q.Count > 0)
Console.WriteLine(Thread.CurrentThread.Name + ": do " + q.Dequeue());
}
});
t.Name = "线程" + i;
t.Start();
}
}
输出:
线程1: do 1
线程7: do 2
线程9: do 3
线程3: do 4
线程5: do 5
线程6: do 6
线程8: do 7
线程0: do 8
线程4: do 9
请按任意键继续. . .
使用了线程同步之后,线程们排着队去Queue里取数据,然后执行,在效率上就体现不出多线程的优势了。不过,如果换成用线程池利用后台空闲线程还是有意义的。
聪明的你一定想到了,泛型Queue一样可以通过参数传递进去。
{
Queue<int> q = new Queue<int>();
for (int i = 0; i < 10; i++)
{
q.Enqueue(i);
Thread t = new Thread(delegate(object arg)
{
Thread.Sleep(new Random().Next(1, 10000));
Queue<int> qq = arg as Queue<int>;
lock (qq)
{
if (qq.Count > 0)
Console.WriteLine(Thread.CurrentThread.Name + ": do " + qq.Dequeue());
}
});
t.Name = "线程" + i;
t.Start(q);
}
}
结论
既然.net提供了由线程向匿名函数传递参数值的功能,你想要定下一条“多线程回调的匿名函数只允许使用它的参数,禁止使用函数外的变量”的规矩是可以理解的。不过即使这样做,当变量是复杂类型的实例的时候,同样会有产生Bug的危险。所以,要理解为什么以及如何同步变量和线程的执行,灵活运用,别莫名其妙地掉了裤子。
由于Thread.Start()的参数只能有一个,所以需要传递多个数值的时候就必须提前构造一个数组或Struct,这多少还是有些不便。让我们去微软门前游行,要求为Thread.Start()提供一个可变参数的重载吧,机票钱老赵出……
参考文献
[1] JeffreyZhao,警惕匿名方法造成的变量共享。cnblogs,2009.
[2] 菩提树下的杨过,利用Reflector把"闭包"看清楚。cnblogs,2009.
[3] overred, 《你不常用的c#之三》:Action 之怪状 。cnblogs, 2009.