侦探般的程序员之二 不等待的调用异步方法填坑小计
概述
用户反馈了一个问题,操作之后的消息没有收到,但是问题比较特殊:
- 只有在特定的业务流程下才会出现
- 相同的流程只有特定的用户才会出现
问题分析:
- 代码的逻辑是这样的,先执行保存功能 如果保存成功了 就发送消息通知
- 问题的关键在于 发送消息的方法是异步的 并且没有使用await等待 直接调用,基本结构如下:
//保存
public bool Save () {
bool result = true;
var t= SendMsgAsync();
return result;
}
//发送消息
private async Task SendMsgAsync () {
Console.WriteLine ("开始发送消息")
long num = 0;
while (true) {
Console.WriteLine ("仅执行一次");
await Task.Delay (5000);
Console.WriteLine ("不执行");
}
}
- 通过日志分析 就是程序运行到某一点之后 就不再向下运行了 这个比较诡异 于是打上断点调试了一下
- 调试时的现象就是 发送消息的时候 执行到一个异步调用接口的方法时就没有进入下一个断点了 就消失不见,而且再没有进入断点的同时接口就返回了保存的结果
解决
- 看到这个问题 怀疑时异步调用方法并且没有await导致 于是再调用 SendMsgAsync 加上await 测试,消息发送成功,于是按这个方式更新一版,保证用户业务正常。
- 但是到这里,问题并没有解决 发送消息的逻辑比较复杂,用时有时会比较久,直接await的话接口会比较慢
- 请教一个同事,同事表示异步方法不能这样用不能不等待 我说那Task.Run()? 试了试 Task.Run()问题也解决了
- 但是这样感觉还是不够简洁 重要的是问题的原因显然不是不能await 要找到问题的本质,联想到之前遇到过的一个同步调用async方法导致死锁的问题,非常相似,怀疑跟SynchronizationContext有关
- SynchronizationContext 这个引发的问题的特点是:console程序不会有问题 .NetCore的WebApi也不会有问题(因为实质上也是控制台) 于是将代码简化后分别放入控制台项目和.netCore 项目运行 果然没问题
- 确定了SynchronizationContext 导致的问题之后 就简单了 这个问题的本质就是运行到异步方法时使用了新的线程运行,运行结束后要回到原始线程的状态时 获取不到或冲突导致
- 观察之前线上的错误日志对应的线程id 但是杂乱无章各种乱入 然后在本地手工调用System.Threading.Thread.CurrentThread.ManagedThreadId 并记录日志 发现因为记录日志的方法做了二次封装 线程id实际时记录日志的线程id 记录日志是异步的。。
- 于是写了一个Mock方法来复现问题并记录日志 发现 所有的有输出的日志都是同一个线程id 而且都是在SendMsgAsync await之前的才有输出 而core的代码 则是await后是另外的线程id
- 于是在 await SendMsgAsync()方法加入.ConfigureAwait (false); 成功 Mock的方法成功走完了 但是实际的方法没有生效,仍然没有推送消息
- 想起之前看到的clr via中说到 当运行到await的时候会根据一定情况来选择 是用当前进程运行 或者是 使用另一个线程运行,当前线程返回运行await后的方法 未来避免SynchronizationContext导致死锁
- clr via给出的方法是 在所有awaait使用ConfigureAwait(false)来声明不用恢复上下文环境(因为不知道实际会是在那里使用新的线程运行) 或者是使用Task.Run()来运行 Task.Run 是一个干净的上下文不存在切换的我呢提
- 第一个方法显然可操作性太差 毕竟调用链太长 第二个方法的话就是之前用过的
- 既然是因为是否使用其它线程来执行是因为不知道那里切换的话 那我们就在SendMsgAsync第一行手工来切换 使用await Task.Delay(1).ConfigureAwait (false);来强制切换一次 想起之前写js也用过setTimeout(function(){},1);来强制切换,异曲同工
- 修改还代码如下:
-
//保存 public bool Save () { bool result = true; var t= SendMsgAsync(); return result; } //发送消息 private async Task SendMsgAsync () { await Task.Delay(1).ConfigureAwait (false); Console.WriteLine ("开始发送消息") long num = 0; while (true) { Console.WriteLine ("循环执行"); await Task.Delay (5000); Console.WriteLine ("循环执行"); } }
线上问题分析:
- 特定的流程导致:只有特定的流程才需要使用接口(接口有数据缓存)获取数据,在其它情况下没有使用接口,程序没有发生线程的切换
- 特定的用户:特定的用户数据缓存中没有,需要调用接口获取,而使用缓存的话 就没有调用接口的过程 实际异步方法也没有线程的切换