问题描述
公司的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)) 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()); }
|
相信名字就给大家足够多的提示了
最后祝你身体健康,再见