在我们 2015 年开始的从 .NET Framework 向 .NET Core 迁移的工程中,遇到的最大的坑就是标题中所说的——同步方法中调用异步方法发生”死锁”。虽然在 .NET Framework 时代就知道不能在同步方法中调用异步方法,但我们却明知路有坑,偏向此路行。不是我们自讨苦吃,而是被迫无奈,因为在 .NET Core 2.0 之前,BCL(基础类库)中有些 API 只有异步实现没有同步实现,比如用于将主机名解析为 IP 地址的 API —— Dns.GetHostAddressesAsync() 。
但最终“被迫无奈”变成“血的教训”,这根本不是坑,而是无底洞。无论在开发与测试环境中多么正常,只要一发布到生产环境有一定并发量就会发生“死锁” —— 大量请求无响应,一直处于等待状态,线程池发飙,线程数持续不断地增长,内存随之增长,直至撑爆服务器(详见当时的一篇随笔 .NET Core 中遇到奇怪的线程死锁问题:内存与线程数不停地增长)。
我们想尽一切方法,用尽网上能找到的同步方法调用异步方法避免死锁的办法,都于事无补,唯有去掉同步方法调用异步方法的代码。当我们意识这是一个无底洞后,赶紧绕道而行,全面放弃在同步方法中调用异步方法,并将“千万千万不要在同步方法中调用异步方法”作为一条 .NET Core 开发准则。
这段踩坑踩到无底洞的血泪史,每当想起都很心痛,心痛不是当时的任何努力都是那么的苍白无力,而是对问题背后原因的困惑 —— 为什么同步方法中 Wait 异步方法会产生如此致命的后果?如果真的千万千万不能这么干,那 .NET Core 为什么不直接在编译时就报错?“死锁”的背后究竟发生了什么?
。。。
2018年10月20日偶然间发现一个网站 —— dotNET Weekly ,在其中发现一篇10月17日发布的博文 —— .NET Threadpool starvation, and how queuing makes it worse,在读懂这篇博文之后,联系到之前踩坑的经历,终于想通了“死锁”的背后(只是个人推测,并不一定正确)。
.NET Core 线程池有 n+1 个队列,每个线程有自己的本地队列(n),整个线程池有一个全局队列(1)。每个线程接活(从队列中取出任务执行)的顺序是这样的:先从自己的本地队列中找活 -> 如果本地队列为空,则从全局队列中找活 -> 如果全局队列为空,则从其他线程的本地队列中抢活。
我们来想象一下异步方法等待同步方法的场景。当10个并发请求到达时(进入的是全局队列),假设线程池中正好有10个空闲线程,这10个线程立马把活接过来,但线程在执行过程中遇到了同步方法等待异步方法(Task.Wait)的情况而进入阻塞状态,无奈地无所事事地在那干等异步方法执行完成而无法帮其他线程干活(这时情况已经有些不妙,由于阻塞线程池少了10个干活的线程)。雪上加霜的是,这些阻塞的线程所等待的异步方法在完成异步操作执行 await 之后的代码时也需要线程,不仅干活的线程少了,而且剩下的线程要干的活更多了(情况更不妙了)。随着并发请求持续不断地进来,形势变得越来越严峻,被阻塞的线程越来越多,能干活的线程越来越少而且要干的活越来越多,于是越来越多的一线干活的线程的队列开始排起了长队。火上浇油的是,那些阻塞着的线程要退出阻塞状态需要等它们所等待的任务被正忙得不可开交的干活线程执行,干活线程越忙,它们被阻塞的时间越长。于是出现了一个奇怪的场面,一群不干活的线程围观并等待着少数干活的线程,眼看着这些干活线程的队列排队越来越长,虽然它们也能干活,但由于它们被关在小黑屋里,无法出手相助,要等它们的主人将它们释放出来,而它们的主人就排在长队中等着从干活线程那拿到小黑屋的钥匙。。。这样的场面最终只有一个结局,所有干活的线程的本地队列都排起了长队,没有空闲的线程。
好戏开始了,不,是灾难开始了。线程池中没有空闲线程,全局队列中的活没人接,于是全局队列开始排队,线程池的线程不够用,如果不赶紧补充线程进来,线程池会被饿死(Threadpool Starvation)。救援行动开始了,CLR 赶紧生产线程喂给线程池,由于全局队列享有最高优先级(根据之前所述的线程接活顺序),一喂进去就被全局队列吃了,但 CLR 一秒钟只能生产1-2个线程,远远满足不了全局队列的胃口,而最需要救援的各个干活线程的本地队列连汤都喝不到。除了 CLR 的外部救援,线程池也同时进行自救,有些线程玩命干活,终于处理完了自己队列中的任务,终于有机会可以帮助其他同伴了,但是它们立即接到了上级命令 —— 以最快速度去救援全局队列,军令不可违,它们眼睁睁地看着同伴绝望地处理着一望无际的长队中的任务,奔赴全局队列,自救也救不到干活线程的本地队列。
这种完全以全局队列为中心、救地位最高的、不救最需要的救援行动最终带来了毁灭性的结果。那些解救全局队列的线程又因为 Task.Wait 而阻塞而需要更多的线程执行阻塞所等待的任务。救援行动变成了自杀行动,线程池就这样被活活饿死了(Threadpool Starvation)。
这就是我所推测的真相,真相背后的真正罪魁祸首其实是对线程的阻塞,所以千万千万不要阻塞(blocking)线程。