ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

异步≠新线程:深入理解async/await与线程池饥饿

异步≠新线程:深入理解async/await与线程池饥饿 一个现象我观察了很久很多人把异步理解成新开一条线程去干活面试的时候问async/await十个人里有八九个会说异步就是不阻塞主线程开个新线程去执行。你要是追问那这个线程是谁创建的什么时候创建的基本就卡壳了。等真到了线上排查问题服务器CPU不高但请求全卡住打开线程窗口一看线程池线程全在睡觉这时候才知道当初的理解偏差有多致命。这篇就来把异步到底要不要创建线程这件事彻底讲清楚。内容覆盖异步背后真正的执行模型、什么时候真会动线程、高并发下最常见的线程池饥饿问题怎么定位和解决以及日常写异步代码必须避开的三个坑。适合对async/await有一定使用经验、但没深究过底层机制的.NET开发者看完能建立一套正确的判断框架也顺手学会几招诊断手段。1. 异步 新线程这个误区到底从哪来的先别急着嘲笑这个误区它的出现其实非常有道理因为有三股力量在背后共同拱火。1.1 三个制造误区的源头第一个源头是历史遗留的异步概念。老一代开发者接触的异步模型比如APMBegin/End模式和EAP基于事件的异步模式里面大量使用ThreadPool.QueueUserWorkItem或者直接new Thread。那个年代做异步IO确实经常要手动起线程去等结果线程和异步就这样被焊死在了一起。第二个源头是Task.Run的存在。异步方法里经常能看到这样的代码public async Taskstring GetDataAsync() { return await Task.Run(() HeavyCpuWork()); }Task.Run会把委托丢到线程池执行这确实是开了新线程。很多人看到这个写法就联想异步 线程池 新线程。但问题在于这只是异步的一种用法而且恰恰是不推荐在IO场景下使用的用法却被当成了异步的标准形象。第三个源头是理论教育上的断档。很多教程讲async/await只讲用了就不卡界面、不卡请求至于为什么就说因为在后台跑至于线程在哪里就含含糊糊带过去了。这种讲法留下的心智模型就是异步 ≈ 幕后有线程在跑。1.2 这个误区会带来什么实际后果理解错了写代码就会错。最常见的连锁反应是异步方法里动不动就套Task.Run把本来可以通过IO完成端口处理的数据库查询、HTTP调用硬生生多搬了一次线程。结果就是线程池线程被白白消耗等到并发一上来线程池里的线程全在忙着搬运工的活真正该干活的任务排着队没人处理。更严重的是排查方向的错误。线上接口大面积超时第一反应就是线程不够加线程于是调大线程池最大线程数或者干脆改用async方法里new Thread——越修越糟。所以要理解异步第一步就是把这个新线程的心智模型卸掉换一个更接近底层的模型。2. 异步的本质不是开线程的懒办法而是效率极高的状态机要搞清楚异步到底做了什么得从async/await的编译产物说起。2.1 async方法的真面目状态机一个async方法编译出来后会变成一个状态机类它有状态字段、builder字段、以及一个MoveNext方法。方法里的代码被切成几段每一段在await处被分成暂停和恢复。关键点在这里async方法在遇到await之前完全是同步执行的就在调用它的那个线程上。比如public async Taskstring LoadAsync() { Console.WriteLine($开始执行线程ID{Thread.CurrentThread.ManagedThreadId}); var data await ReadFileAsync(); Console.WriteLine($恢复执行线程ID{Thread.CurrentThread.ManagedThreadId}); return data; }如果ReadFileAsync内部已经有数据在内存里比如缓存命中返回的Task已经完成了那await根本不会暂停整个方法一口气跑完两个Console.WriteLine会在同一个线程上。即便ReadFileAsync真的需要等待第一个Console.WriteLine也一定在调用线程上执行。所以异步和新开线程从起点上就不搭。2.2 await一个没有完成的Task时发生了什么这是整个模型最核心的部分。当await发现Task还没完成它立刻做两件事第一把当前状态保存到状态机里包括方法里的局部变量、当前执行位置、同步上下文等第二向这个Task注册一个continuation继续执行的回调然后直接return。注意直接return这四个字调用方拿回的是一个未完成的Task但调用线程已经解脱了可以继续去干别的。真正等结果的那段时间里没有线程在等这个任务。那么等结果的任务到底由谁在执行得分两种情况CPU密集型任务比如Task.Run里跑的计算确实在线程池线程上执行。这个是真实占用了线程的。IO密集型任务比如读文件、发HTTP请求、查数据库最终会走到操作系统层面的异步IO接口。操作系统向设备发完指令后线程就可以撤了等设备把数据准备好通过IO完成端口IOCP由线程池里的线程回调回来。IO完成端口这个概念值得多说一句它允许一个进程同时处理成千上万个IO操作而对应的处理线程池却只需要很少的线程来接数据。数据到了就从线程池里借一个线程接完数据把continuation执行一下然后又还回去。整个过程中没有一个线程是蹲在那里等IO的。2.3 线程池在线程创建上的小气是有原因的.NET线程池对线程数是进行了审慎控制的它会根据CPU核心数和吞吐量动态调整而且有自动注入机制但注入速度是有节制的——默认大约每秒才尝试注入一个新线程旧版本更保守每500毫秒才挂起一个线程注入请求。这样的保守策略本质就是为了防止每次异步操作都新建线程这种灾难性做法。想想如果每个文件读取、每次数据库查询都新建一个线程然后线程在那儿sleep等待IO返回那么并发1万个请求就需要1万个线程。一个线程默认栈空间是1MB光栈就得吃掉10GB内存线程上下文切换的CPU开销更是直接把服务器打趴。线程池的最大线程数虽然可以配置到很高但那只是天花板不是启动数。平时线程池里的线程数量会保持在很低水平。所以真正的问题是你根本不需要新线程去等IO你需要的是让线程等在数据到达的那一刻的机制。这个机制就是IO完成端口 状态机回调。3. CPU密集型的异步唯一需要新线程的场景前面说异步不一定创建线程但有一种情况确实会CPU密集型的异步操作。这里要把边界划清楚不然又会走向另一个极端。3.1 Task.Run到底在什么时候真正该用判断标准一句话你是否有真正需要占用CPU的同步计算且不希望它占用当前调用线程典型例子图像处理滤镜、加解密、JSON序列化大对象、复杂的计算校验。这些操作没有等待IO的必要纯粹是CPU在算。如果直接在UI线程或者请求处理线程上执行界面会卡死或者请求线程被占住无法处理其他请求。这时候用Task.Run把它丢到线程池是合理的。但注意一个细节如果异步方法里后续还有真正的IO await那外层套Task.Run就是多此一举甚至是有害的。比如public async Taskstring ProcessAsync() { // 错误示范Task.Run包了一个本身就会异步IO的方法 return await Task.Run(() FetchFromDbAsync()); }FetchFromDbAsync本身就是异步的查询阶段根本占用不了线程。Task.Run只是让一个线程池线程去执行发起异步调用并等待这段逻辑把它丢进线程池之后它又会await返回等数据库数据到达时再被IO完成端口唤醒。也就是说Task.Run额外借了一个线程却只做了一个传递的动作——纯属浪费。更合理的写法是去掉Task.Run直接await FetchFromDbAsync()。3.2 CPU密集型异步的线程调度细节CPU密集型任务丢到线程池后由线程池的全局队列或工作线程的本地队列调度。线程池会尽量让同一个Task里创建的子任务在同一个线程上执行工作窃取机制减少上下文切换。这里有个容易被忽略的点线程池线程数量不足时会触发注入但注入是渐进的呈锯齿状爬升不会立刻满足需求。所以如果有大量CPU密集型任务瞬间涌入线程池创建新线程有一个延迟任务可能会等待一会儿才被执行表现就是接口从几百毫秒涨到几秒。针对这种情况常见的调整选项包括在应用启动时预热线程池调用ThreadPool.SetMinThreads(64, 64)让线程池至少保留这么多线程避免峰值时期等待注入。拆分大的CPU任务为小粒度并行用Parallel.ForEach或者Channel模式控制并发度而不是无脑丢Task.Run。3.3 判断标准小结场景该不该用到线程/Task.Run原因读文件、访问HTTP接口、查询数据库IO密集型不该应该用真正的异步IO不占线程计算密集型图像处理、加解密、序列化应该需要CPU线程用线程池合理同步网络库或同步数据库驱动过渡方案尽量换异步版本驱动别靠Task.Run包装递归、循环中的CPU计算分情况小计算直接同步做大计算才考虑线程池4. 线上翻车实录线程池饥饿是怎么发生的讲一个我实际排查过的案例这个过程比任何理论都直观。4.1 症状CPU不高请求却全部卡住某服务部署在8核16G的机器上突然收到告警接口平均响应时间从50ms飙升到15秒部分请求超时。登录服务器一看CPU只有30%左右内存也正常数据库连接池也没有打满。从表象看你甚至会觉得服务器很闲。4.2 误诊一开始以为资源不够当时第一反应是数据库慢查询查了慢日志没有异常又怀疑下游HTTP调用变慢看了依赖服务的监控也没问题。最后用dotnet-dump抓了一个内存快照分析线程栈这才发现问题。4.3 真相线程池线程全部被阻塞线程栈的分析结果非常典型几乎所有的线程池工作线程都卡在同一个模式上——等待一个同步操作完成。具体来说代码里有一个第三方SDK的旧版本接口是同步的而我们在异步上下文里调用了它。这个同步调用内部又使用了Task.Wait()或者.Result去等异步操作直接把线程池线程阻塞在原地。当时线程池的实际可用线程数大概是这个状况满负荷时线程池最大线程数配置到1000而活跃线程数一直维持在120左右全部处于WaitOne状态。新进来的请求任务排队在线程池队列里无人处理。线程池尝试注入新线程但每500毫秒才注入一个面对每秒几十个新任务的增长速度完全是杯水车薪。这个现象就是标准的线程池饥饿Thread Pool Starvation)。4.4 收集证据的完整路径排查线程池饥饿我建议按下面这个路径来能少走很多弯路看性能计数器观察.NET CLR Threading下的Current Thread Count和Total Thread Count。如果当前线程数接近最大值配置而活跃线程数很高说明线程池线程被长期占用不释放。Queue Length如果持续增长说明任务排队。抓线程栈用dotnet-dump collect抓内存转储然后dotnet-dump analyze执行threads看线程列表再用clrstack -all查看所有线程的托管调用栈。重点找有没有大量线程停在Monitor.Wait、ManualResetEventSlim.Wait、Task.Wait、.Result这类同步阻塞点。查异步上下文是否被错误包装重点排查异步方法里是否有Task.Run包裹了同步IO或者异步方法内部混用了同步阻塞调用。4.5 解决两个动作一起做定位之后做了两件事。第一件是短期的在服务启动时调用ThreadPool.SetMinThreads把最小线程数调到80——这个命令的意思不是创建80个线程而是告诉线程池一旦有任务来至少要有这么多线程可用瞬间缓解了排队压力。第二件是长期的把第三方SDK升级到异步版本替换所有.Result和.Wait()的调用彻底消除线程阻塞点。需要特别提醒SetMinThreads是缓解手段不是根治手段。只要你还有代码在异步上下文里做同步阻塞线程池饥饿会换一个时间点卷土重来。根治只有一个方向异步代码的整个调用链必须是异步的不允许中间任何一环用同步方式等待异步结果。5. 异步代码里三个高频线程坑踩中一个就够呛线程池饥饿之外还有几个具体的坑值得单独拿出来说因为它们太常见了。5.1 坑一async方法里的async all the way半途断裂很多代码是这样写的public string GetResult() { return GetResultAsync().Result; // 同步阻塞异步方法 }或者在async方法内部用.Wait()public async Taskstring GetResultAsync() { var data FetchAsync().Wait(); // 错误 return data; }这种Sync-over-Async的模式在UI线程或请求处理线程上有两个问题第一阻塞线程池线程导致饥饿第二在带有同步上下文的线程上会直接死锁。ASP.NET Core默认没有SynchronizationContext所以死锁问题没有传统Framework那么严重但线程阻塞带来的饥饿风险一个都不少。正确姿势是让调用链一层层传递Task直到UI事件处理器或Controller层public async Taskstring GetResultAsync() { return await FetchAsync(); }5.2 坑二过度使用Task.RunTask.Run适合CPU密集型的场景这点前面已经说过。但现实中经常看到有人用Task.Run去封装一个本身返回Task的方法public TaskOrder GetOrderAsync(int id) { return Task.Run(() _db.GetOrderAsync(id)); // 又一次错误示范 }这样做的结果就是白白消耗一个线程池线程让IO异步操作失去意义。要注意区分如果_db.GetOrderAsync(id)返回的是Task直接把这个Task return出去就行public TaskOrder GetOrderAsync(int id) { return _db.GetOrderAsync(id); }5.3 坑三无视SynchronizationContext导致的死锁在WPF、WinForms、ASP.NET Framework非Core这类环境中存在SynchronizationContext。await默认会捕获当前上下文并在continuation里回到这个上下文执行——目的是为了能继续安全地操作UI控件。如果上层代码用.Result同步等待异步方法流程就变成了UI线程调用异步方法的.Result阻塞等待。异步方法内部await某个操作由于Task未完成方法返回。操作完成后continuation要回到UI线程执行但UI线程已经被.Result阻塞住。两者互相等待死锁。解决方式要么是调用链保持async一路走通要么在库代码里用ConfigureAwait(false)声明我不需要回到原上下文var data await FetchAsync().ConfigureAwait(false);ConfigureAwait(false)在ASP.NET Core里意义没那么大因为没有同步上下文但在类库、中间件、桌面应用里都值得养成习惯。这里再补充一个常见的认知误区很多人以为ConfigureAwait(false)会让代码在别的线程执行实际上它不指定线程它只是告诉编译器不用帮我把continuation调度回原上下文具体在哪执行取决于调度的自然结果通常是被IO完成端口线程池的线程直接执行。它解决的是死锁和性能问题不是线程选择问题。6. 用工具和技术手段验证异步是否真的卡线程理论讲再多还得落到验证手段上。这里分享几个实际操作层面能直接用的方法。6.1 线程窗口看的是什么在Visual Studio里调试可以用线程窗口实时查看当前进程的线程列表。运行异步代码时你会发现执行到await IO操作时线程总数并没有爆发式增长线程池线程数量维持在一个稳定的低水平而方法却在正常返回数据。这本身就是异步没用新线程等待的直接证据。如果你看到每个请求都对应一个线程池线程在阻塞等待线程状态显示为等待/睡眠那你的代码肯定有同步阻塞嫌疑。6.2 关键计数器清单.NET提供了多个线程相关的计数器和运行时事件建议线上服务把这些埋点打开观察对象说明ThreadPool Thread Count当前线程池线程数量长期超过CPU核心数较多且在高位震荡需要检查阻塞ThreadPool Queue Length排队的任务数持续大于0说明线程池处理不过来IO Completion Ports 活跃数IOCP线程的活跃情况过高说明IO异步操作密集但可能在下游处理阻塞线程阻塞事件EventPipe中的ThreadBlocking事件能直接记录阻塞线程的时间点和栈dotnet-trace是另一个利器它可以实时采集线程调度事件而不需要停服务。比如要验证某个接口有没有线程阻塞可以在这个接口的入口和出口分别打点采集时间范围内所有线程的等待事件。6.3 实际写一段代码来验证最后留一个可以自己跑的验证代码。在控制台项目里跑这个using System.Diagnostics; var sw Stopwatch.StartNew(); int threadCountAtStart ThreadPool.ThreadCount; await Task.Delay(1000); int threadCountAtEnd ThreadPool.ThreadCount; Console.WriteLine($启动时线程数: {threadCountAtStart}); Console.WriteLine($结束时线程数: {threadCountAtEnd}); Console.WriteLine($耗时: {sw.ElapsedMilliseconds}ms);你会发现一个反直觉的现象调用Task.Delay(1000)等待了1秒线程池线程数量根本没有显著增加——如果按异步 新线程的理解这里应该有一个线程在傻等1秒但实际没有任何线程因为这次等待而被创建。等待期间那个空闲的线程继续干别的去了或者干脆不存在。再换一个对比实验var sw Stopwatch.StartNew(); int threadCountAtStart ThreadPool.ThreadCount; await Task.Run(() Thread.Sleep(1000)); int threadCountAtEnd ThreadPool.ThreadCount; Console.WriteLine($启动时线程数: {threadCountAtStart}); Console.WriteLine($结束时线程数: {threadCountAtEnd});这个例子用的是Thread.Sleep而不是await Task.Delay你会发现线程池线程数增加了1。虽然它也被包在await里但Task.Run Thread.Sleep这种方式才是一条真实的线程被占用的情形。一组对照实验跑下来异步本身不创建线程这件事就会非常直观。7. 写异步代码时的几个提醒最后分享几条我在实践中沉淀下来的经验不一定写在任何官方文档里但很实用。第一在代码评审时我至少会问三个问题这个Task.Run为什么要套外层方法有没有可能直接返回这个Task内部的IO调用是不是异步版本能问出这三问很多线程相关的坑在合入代码前就能堵住。第二遇到异步接口变慢先别着急调线程池配置。先做的是抓线程栈、看等待事件确认到底是不是线程阻塞、阻塞在哪一行代码。如果阻塞根源是第三方同步库配置线程池只是缓兵之计替换或封装异步接口才是出路。第三线程池饥饿的识别有一个技巧性特征排队时间很长但一旦线程被释放单个请求的执行时间极短。如果你的接口监控显示P99延迟爆炸但服务端实际处理时间很低大概率就是线程排队问题而非代码性能问题。第四如果真的要从底层验证异步IO的效果可以用dotnet-counters监控进程级计数器观察System.Threading下的ThreadPool Thread Count和IO Queue Length。操作异步IO高峰期时线程池线程数保持稳定而IO队列长度有波动——这就是异步机制在正常工作。总的来说把异步和新线程彻底解绑用正确的执行模型去思考和排查这类问题就少了一大半。如果哪天你在代码里看到一个异步方法莫名其妙地慢不妨先问自己一句是不是哪个环节偷偷让线程真的等了
返回列表