在异步调用匿名函数时明智地使用局部变量

来源:互联网 发布:求实软件 编辑:程序博客网 时间:2024/05/18 03:54

问题:由于在多线程中使用了匿名函数外的局部变量而导致的Bug

执行代码

static void Main(string[] args)
{
    
for (int i = 0; i < 10; i++)
    {
        Thread t 
= new Thread(delegate()
        {
            Thread.Sleep(
new Random().Next(110000));
            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,

如果需要使用线程池,代码大同小异:

static void Main(string[] args)
{
    
for (int i = 0; i < 10; i++)
    {
        ThreadPool.QueueUserWorkItem(
delegate(object arg)
        {
            Thread.Sleep(
new Random().Next(11000));
            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传递数据

代码如下:

static void Main(string[] args)
{
    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(110000));
            
lock (q)
            {
                
if (q.Count > 0)
                    Console.WriteLine(Thread.CurrentThread.Name 
+ ": do " + q.Dequeue());
            }
        });
        t.Name 
= "线程" + i;
        t.Start();
    }
}

输出:

线程2: do 0
线程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一样可以通过参数传递进去。

static void Main(string[] args)
{
    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(110000));
            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()提供一个可变参数的重载吧

原创粉丝点击