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

  1. 1. 问题描述
  2. 2. 分析问题
  3. 3. 原因剖析

问题描述

公司的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());
}

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

最后祝你身体健康,再见