闭包,变量捕获与重入问题

问题描述

公司的APP,在断网后进行一个10秒的倒计时操作,每秒钟都会尝试重新联网。当秒数到0时又重新开始计时,倒计时在用户退出程序或者连上网络结束。

按理说是个很简单的Case。QA却报过来个BUG,说APP状态由

联网->断网->联网->断网

变化后,倒计时的秒数变为

9 3 8 2 7 1

变得不连续了。

思考了一下,觉得应该是第一次倒计时的没有退出,第二次倒计时开始后两者都开始更新UI。

看了代码,果然是这样的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void OnIPChanged()
{
    ShowOfflineUI();
}
void ShowOfflineUI()
{
    Task.Factory.Start(delegate
    {
        int i = 10;
        while(true)
        {
            i = (i-1)%10;
            Invoke(UpdateCountDown(i))//update UI on UI thread
            try
            {
                Thread.Sleep(TimeSpan.FromSeconds(1))
                if(Connect())
                {
                    break;
                }
            }
        }
    })
}

在第二次触发倒计时的时候原来的倒计时还在继续,即,两个倒计时同时执行是不安全的。这是典型的重入问题


说实话我不是很喜欢直接用Thread.Sleep来做操作,这样占用了一个线程忙等,浪费了资源。C#中一般采用基于Task的异步编程来做,这样不会浪费资源。Task提供的CancellationToken能够比较容易的实现取消。放到这个Case里,只要开始倒计时的时候取消上一次的倒计时就好了。

说干就干,我最喜欢用Task重写基于Thread的并发/异步了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
TaskCancellationToken _cts;
void OnIPChanged()
{
    ShowOfflineUI();
    ShowOfflineUI();
}
async Task ShowOfflineUI()
{
    _cts?.Cancel();
    _cts = new CancellationToken();
    await Task.Run(
        async ()=>
        {
            int i = 0;
            while(true)
            {
                i = (i-1)%10;
                Invoke(UpdateCountDown(i));
                await Task.Delay(TimeSpan.FromSeconds(1),_cts.Token);
                if(Connect())
                {
                    break;
                }
            }
        }

    );
}

为了验证方法的正确性,我特地在OnIPChanged()调用了两次ShowOfflineUI()。但是第一个Task并没有被取消。

分析问题

写出了跟自己预期不一样的代码怎么办? 当然是上Debugger了。 在Debugger的火眼金睛下,我很快注意到了最明显的现象:在第二次调用ShowOfflineUI的时候,_cts.Cancel()并没有把第一个Task中的CancellationToken的IsCancellationRequest变为True。 计算机科学里有句老话,那就是永远不要怀疑近30年内编译器的正确性。经过数十分钟的排(谷)查(歌)定位了问题的原因:两个Task引用的_cts是同一个

原因剖析

如果一个匿名函数引用了不属于他自己的局部变量,那么这个现象就称为闭包。因为这实在是太自然了所以我才没往这上面想。在给Task.Run中,我传入了一个Action类型的函数,它捕获了外部变量_cts。在函数中每次用到_cts的时候,被捕获的变量的值被重新计算,结果作为实际的值。而我的本意是让两个Task拥有不同的_cts,这样后边的Task就可以取消前边的Task了。 明白了这些后就很好改了,在ShowOfflineUI里放一个局部变量,存储_cts的值,让匿名函数捕获这个局部变量就好了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async Task ShowOfflineUI()
{
    var local = _cts;
    local?.Cancel();
    _cts = new CancellationToken();
    await Task.Run(
        async ()=>
        {
            int i = 0;
            while(true)
            {
                i = (i-1)%10;
                Invoke(UpdateCountDown(i));
                await Task.Delay(TimeSpan.FromSeconds(1),local?.Token??CancellationToken.None);
                if(Connect())
                {
                    break;
                }
            }
        }

    );
}

有兴趣的朋友可以猜一猜这两段代码的结果,再运行验证一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public void NonLocal()
{
    List<Action> actions = new List<Action>();
    for(int i=0 ;i<10;i++)
        actions.Add(()=>Console.WriteLine(i));
    actions.ForEach(a=>a());
}

public void Local()
{
     List<Action> actions = new List<Action>();
     for(int i=0 ;i<10;i++)
     {
         int j = i;
         actions.Add(()=>Console.WriteLine (j));
     }
     actions.ForEach(a=>a());
}

相信名字就给大家足够多的提示了

最后祝你身体健康,再见

使用 Hugo 构建
主题 StackJimmy 设计