ARTICLE DETAIL

资讯详情

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

深入理解.NET Task与async/await:从线程池到异步编程实战

深入理解.NET Task与async/await:从线程池到异步编程实战 1. 先搞懂一件事Task不是线程是异步操作的抽象1.1 线程的代价为什么新开一个线程解决不了高并发很多刚接触.NET异步编程的开发者对Task的第一反应是这不就是一个线程吗。我刚入行的时候也这么想直到有一次在一个高并发的API网关项目里被现实狠狠教育了一顿。线程确实是操作系统提供的最基本的并发执行单元但它不是免费的。每个线程默认分配1MB的栈空间这1MB是虚拟内存可一旦线程真正跑起来物理内存的占用也会上去。你开1000个线程光栈空间就吃掉1GB虚拟内存。更麻烦的是线程的创建和销毁涉及内核态与用户态的切换这个开销在低并发场景下感觉不明显一旦并发量上来线程切换就成了系统的瓶颈。那时候我做了一个压测一个接口里用Thread.Sleep(100)模拟耗时操作每来一个请求就new Thread()处理。结果线程数飙到两千多的时候响应时间从50毫秒涨到了800毫秒CPU倒是没满但上下文切换的损耗已经让系统不堪重负。所以高并发下的异步核心思路不是多开线程而是少占线程。异步编程追求的是让有限的线程尽可能别闲着让线程在处理I/O等待时能去干别的活儿。而Task就是.NET用来实现这种不占用线程的等待的核心抽象。1.2 Task的底层三要素状态、回调和SynchronizationContextTask本质上描述的是一个将来会完成的操作。它跟线程最大的区别在于线程是执行流Task是操作句柄。你可以把Task想象成一张快递单号——它不代表快递员本人但它能告诉你快递到哪儿了、什么时候能到、到了之后该通知谁。在CLR层面一个Task的核心包含三个东西状态Status记录当前任务处于什么阶段比如WaitingForActivation、Running、RanToCompletion、Faulted、Canceled等。这个状态是线程安全的状态机内部通过Interlocked操作来保证多线程环境下的可见性。延续回调Continuations任务完成或失败后需要执行的后续动作列表。await操作的本质就是告诉Task等你有结果了记得调用我注册的这个回调函数。SynchronizationContext它决定了任务完成后的延续代码应该跑在哪个线程上。WinForms、WPF中有UI消息循环上下文ASP.NET Core旧版中有请求上下文控制台程序里则是默认的线程池上下文。这个设计确保了比如你在UI线程上await一个任务任务完成后后续代码还能回到UI线程修改控件不会因为跨线程操作抛出异常。注意很多人以为await后面的代码一定在UI线程执行这个理解不够准确。准确说法是在SynchronizationContext允许的前提下尽量回到原来的上下文。如果SynchronizationContext为空代码就会继续跑在线程池线程上。1.3 Task是怎么跟线程池打配合的线程池ThreadPool是Task运行时的劳动力池。它跟普通的new Thread()最大的不同是线程池里的线程会被复用而且它会根据系统负载动态调整线程数量。具体运行机制是这样的当你调用Task.Run()时CLR会把工作项排队到全局队列中。线程池里每个线程都有自己的本地队列工作线程会优先执行自己队列里的任务空闲时再去全局队列偷任务。这个叫做窃取工作Work Stealing的机制大大减少了线程之间的竞争。线程池还有一个很关键的特性当所有线程都处于阻塞状态比如执行了Task.Wait()时线程池会尝试每500毫秒注入一个新的线程。这个设计的初衷是防止死锁但它也是线程饥饿ThreadPool Starvation的温床——如果阻塞请求的速度快于线程注入的速度系统性能就会急剧下降。我个人的经验是不要用阻塞式等待的方式去消费异步任务比如.Result或.Wait()这会把线程池的优势全部毁掉。代码里出现.Result的地方往往是需要重点重构的信号。2. async/await编译后的真面目状态机代码逐行拆解2.1 编译器生成了什么MoveNext与状态字段async/await是C#里非常优雅的语法糖但它的底层一点也不优雅。编译器会把一个async方法重写成一个状态机结构体里面有一个核心方法叫MoveNext()还有一个表示当前执行位置的state字段。来看一个最简单的例子public async Taskint GetValueAsync() { Console.WriteLine(开始执行); await Task.Delay(1000); Console.WriteLine(延迟结束); return 42; }编译器大致会生成这样的逻辑结构private struct StateMachine : IAsyncStateMachine { public int state; public TaskAwaiterint awaiter; public void MoveNext() { if (state 0) { Console.WriteLine(开始执行); awaiter Task.Delay(1000).GetAwaiter(); if (!awaiter.IsCompleted) { state 1; // 注册回调等任务完成后再次调用MoveNext awaiter.OnCompleted(this.MoveNext); return; } } else if (state 1) { awaiter.GetResult(); Console.WriteLine(延迟结束); } return 42; } }看明白了吗await不是停下等待而是**登记一个回调然后立刻返回**。真正阻塞等待由同步方式完成异步操作的返回值靠回调通知恢复。这就是异步非阻塞的核心也是它能用少量线程支撑高并发的原因。还需要注意的是整个状态机是一个结构化体struct不是类class。这是编译器刻意做的优化为了减少堆分配。但如果状态机里捕获了大量的局部变量和参数它也可能被提升为引用类型。这就是为什么异步方法里的局部变量访问速度会略慢于普通方法——因为那些变量很可能被放到了堆上的一个对象里而不是线程栈上。2.2 线程亲和性await之后代码跑在哪个线程上只要你了解了状态机的工作原理await之后代码跑在哪个线程这个问题就变得很好回答。当await一个尚未完成的任务时状态机会检查当前的SynchronizationContext。如果有上下文比如WPF/WinForms的UI上下文它会把这个上下文保存到状态机里任务完成后通过上下文.Post()把MoveNext回调投递回去。如果没有上下文控制台程序、ASP.NET Core 6 默认场景回调就会直接在线程池线程上执行。这就解释了为什么在UI程序里await之后你能直接操作控件——不是CLR帮助你自动封送到UI线程而是编译器通过SynchronizationContext.Post实现了上下文恢复。你也就能理解如果代码里压根没有SynchronizationContext那么延续代码的执行线程是不确定的。我调试的时候发现一个很有意思的现象同一个async方法在WinForms里await后线程ID和之前一致在控制台程序里却很可能不一致。很多新手被这个弄糊涂了其实背后就是上下文是否存在的问题。2.3 ConfigureAwait什么时候可以不带ConfigureAwait(false)可能是被讨论最多、也误解最多的一个配置了。它的作用是告诉状态机我不要回到原来的SynchronizationContext任务完成后你直接在线程池线程上执行延续就好。什么时候该用在类库代码里如果你确定不需要回到UI上下文那么ConfigureAwait(false)能减少一次上下文切换性能略有提升。尤其是在高吞吐量的服务端代码里这个优化累积起来是可观的。什么时候绝对不能乱用如果你在一个UI事件处理器里写了async void Button_Click(object sender, RoutedEventArgs e) { var data await _service.GetDataAsync().ConfigureAwait(false); TextBox.Text data.ToString(); // 这里会抛异常 }因为ConfigureAwait(false)导致延续代码跑在线程池线程上而直接访问UI控件是禁止的。这种错误在发布前通常很难发现因为UI程序里这个场景一般不会有人加ConfigureAwait(false)。注意ASP.NET Core尤其是6.0以后默认没有SynchronizationContext所以在服务端代码里用不用ConfigureAwait(false)基本没有区别。不要听网上老文章说服务端必须用false——那是针对旧版ASP.NET Framework时代的经验现在照搬会得不偿失。3. Task并行库的实战手法不止await这么简单3.1 Task.Run与StartNew的区别用错很隐蔽Task.Run和Task.Factory.StartNew都能创建任务但很多老代码会混用它们。实际上这俩有非常关键的区别而且StartNew是一个容易被误用的API。Task.Run是StartNew的简化改版它默认使用TaskScheduler.Default线程池调度器并且会自动使用更适合的默认参数。而StartNew有更多的重载允许你指定TaskCreationOptions、TaskScheduler等但也因此埋了坑。最典型的坑是传参// 错误示范闭包捕获循环变量 for (int i 0; i 10; i) { Task.Factory.StartNew(() DoWork(i)); // i被共享结果不可预测 } // 正确写法 for (int i 0; i 10; i) { Task.Factory.StartNew((state) DoWork((int)state), i); }在C# 5以后有了foreach的闭包处理改进但for循环里的闭包捕获依然有隐患。用Task.Run配合局部复制变量是更清晰的做法for (int i 0; i 10; i) { int index i; Task.Run(() DoWork(index)); }作为经验之谈凡是能用Task.Run的场景就不要用StartNew。需要自定义调度器的场景比如限定并发度的自定义TaskScheduler才值得碰StartNew。3.2 WhenAll与WhenAny聚合同类异步任务的典型场景Task.WhenAll和Task.WhenAny是处理多个并行异步任务的两个基本API但它们解决的问题完全不同。WhenAll会等待进入参数列表的任务全部完成然后返回一个代表聚合结果的新任务。它特别适合扇出/扇入模式把一个大任务拆成多个独立子任务并行执行等所有子任务完成后再合并结果。async TaskSummary GetReportAsync() { var userTask _userService.GetUserAsync(id); var orderTask _orderService.GetOrdersAsync(id); var statsTask _statsService.CalculateAsync(id); await Task.WhenAll(userTask, orderTask, statsTask); return new Summary( userTask.Result, orderTask.Result, statsTask.Result ); }注意WhenAll之后直接取.Result是安全的因为此时任务必然已经完成了。但一些人习惯写await后再取结果其实用上面的写法也完全没问题并且避免了额外的await开销。WhenAny则只需要等待任意一个任务完成即可继续。它经常被用在超时控制里var result await Task.WhenAny(DoWorkAsync(), Task.Delay(TimeSpan.FromSeconds(5))); if (result ! workTask) { // 超时了 }但这个写法有个坑超时后原来的DoWorkAsync()任务仍然在跑只是你不再等它了。它可能会继续消耗资源甚至后续引发未观察到异常Unobserved Task Exception。正确的做法通常是在任务内部自己支持CancellationToken超时后主动取消。3.3 CancellationToken的正确使用良好协作取消很多开发者在编写异步方法时完全不关心取消等到系统上线才发现关闭应用时大量后台任务还在跑进程退不干净。CancellationToken就是为了解决协作式取消而设计的——它不是强杀线程而是通过协作让任务自己体面退出。正确的流程分三步第一步创建/传递令牌using var cts new CancellationTokenSource(TimeSpan.FromSeconds(10)); await DoWorkAsync(cts.Token);第二步在任务内定期检查async Task DoWorkAsync(CancellationToken token) { while (someCondition) { token.ThrowIfCancellationRequested(); // 取消时抛出OperationCanceledException await StepAsync(token); } }第三步在调用方捕获取消try { await DoWorkAsync(cts.Token); } catch (OperationCanceledException) { // 用户取消了 }有几个细节容易弄错不要用while (!token.IsCancellationRequested)代替ThrowIfCancellationRequested。前者只是退出循环不抛异常调用方无法区分任务完成和任务取消。Token.ThrowIfCancellationRequested()的语义是如果取消发生抛出异常。这个异常必须是OperationCanceledException类型CLR对它有特殊处理。多个并行任务中其中一个被取消时Task.WhenAll会抛出OperationCanceledException但这个异常只代表至少有一个任务取消了其他任务的结果并不会自动丢弃。3.4 延续任务与父子任务Task链式反应的细节除了awaitTask还提供了一套基于延续Continuation的APIContinueWith。虽然在现代C#里我们基本都用await替代了它但ContinueWith在一些动态构建任务链的场景仍有用武之地。ContinueWith最大的坑是它的默认执行上下文它不会像await那样自动捕获SynchronizationContext。所以在UI程序里如果用了ContinueWith里面的代码可能跑在线程池线程上操作控件照样抛异常。父子任务Parent/Child的关联方式在.NET里也有讲究。子任务默认情况下不会自动成为父任务的一部分除非你显式指定TaskCreationOptions.AttachedToParent。这种模式在Parallel.ForEach的内部实现里被大量使用。不过说实话在async/await时代的业务代码里父子任务的应用场景已经非常少了。我更建议你用更直观的Task.Runawait组合来组织并行逻辑而不是依赖隐晦的父子关系。4. 异步代码的异常与取消最容易被静默吞掉的错误4.1 async Task方法中异常如何传播异步方法抛异常和同步方法抛异常的表现力完全不同这是很多线上事故的根源。在同步方法里try { DoWork(); } catch (Exception ex) { // 一定能捕获到 }在async方法里如果你这样写try { var task DoWorkAsync(); // 这里不会立刻抛异常 // 做一些其他事情... await task; // 异常在这里才会重新抛出 } catch (Exception ex) { // 能捕获到但前提是你真的await了这个任务 }上面这个例子还算友好因为代码至少await了。真正危险的是不await也不访问结果的任务public void FireAndForget() { _ DoWorkAsync(); // 异常被吞掉了 }如果一个async Task方法内部抛出异常而调用方既没有await它也没有检查task.Exception在.NET Framework时代这个异常会在GC回收任务时勉强被观察到引发UnobservedTaskException事件。而在现代.NET中这个问题变得更加微妙——你可能会静默丢失异常没有事件通知。我的建议是发射后不管Fire-and-Forget模式必须配上异常观察机制。或者干脆不要发射后不管。如果确实不需要等结果可以在方法内部自己try-catch把异常记录到日志系统让错误可见。4.2 多个并行任务时的聚合异常当你用Task.WhenAll并行执行多个任务时一个核心问题就出现了如果多个任务同时失败异常怎么处理WhenAll返回的任务会进入Faulted状态它的Exception属性是一个AggregateException——一个异常集合。但你直接await它时它只会抛出第一个异常剩下的异常被藏在了AggregateException内部。这在排错时很讨厌因为很可能真正的关键异常恰恰是第二个、第三个。获取所有异常的标准做法是var tasks new[] { DoAAsync(), DoBAsync(), DoCAsync() }; try { await Task.WhenAll(tasks); } catch (Exception ex) { // 此时ex只是第一个异常 var aggregate tasks .Where(t t.Status TaskStatus.Faulted) .SelectMany(t t.Exception.InnerExceptions); foreach (var inner in aggregate) { // 记录每一个异常 } }更优雅的做法是给每个任务包一层包裹函数让异常提前被捕获并转成数据结构。我在项目中常用一个TryAsync辅助函数async TaskResultT TryAsyncT(FuncTaskT func) { try { var value await func(); return ResultT.Success(value); } catch (Exception ex) { return ResultT.Failure(ex); } }这样一来WhenAll永远成功所有异常都在返回结果里处理逻辑统一又干净。这个模式在做批量请求、批量消息处理时非常有用。4.3 async void事件处理器中的无奈与自救async void被整个.NET社区视为洪水猛兽因为它有两个致命问题一是异常无法被调用方捕获二是方法执行过程不受调用方控制。但现实是在WPF/WinForms/WinUI的事件处理器中void就是签名的一部分你没法返回Task。比如一个按钮的点击事件private async void Button_Click(object sender, RoutedEventArgs e) { await LoadDataAsync(); // 这里如果抛异常程序可能直接崩溃或行为诡异 }自救手段非常简单在async void方法内部进行异常完整性捕获不要让任何异常逃逸出去。private async void Button_Click(object sender, RoutedEventArgs e) { try { await LoadDataAsync(); } catch (Exception ex) { // 记录日志弹出提示 MessageBox.Show($出错了{ex.Message}); } }另一个跟async void相关的隐蔽坑如果UI线程上的async void方法里有个未捕获异常CLR会调用全局异常处理器在WPF里表现为DispatcherUnhandledException在WinForms里表现为Application.ThreadException。但这个处理是否及时、可恢复完全取决于框架和运行时状态。所以最好的做法还是源头不逃逸而不是依赖全局兜底。5. 高级异步原语TaskCompletionSource、ValueTask与自定义Awaitable5.1 TaskCompletionSource把老式回调改造成Task很多时候你面对的是一些老旧的库它们不提供Task版本的API只提供 回调地狱 风格的接口。比如某些第三方SDK在初始化完成后通过事件通知不会返回Task。这时候TaskCompletionSource就是你的救星。它的作用非常直接创建一个人为控制的Task让你能在合适的时机手动设置它的结果。public Taskbool InitializeAsync() { var tcs new TaskCompletionSourcebool(); _sdk.Initialized (s, e) tcs.TrySetResult(true); _sdk.InitializationFailed (s, e) tcs.TrySetException(e.Exception); _sdk.Start(); return tcs.Task; }值得注意的细节尽量用TrySetResult/TrySetException而不是SetResult因为后者在任务已经完成时会抛InvalidOperationException。要注意tcs.Task可能永远不会完成的情况比如SDK既不触发成功回调也不触发失败回调。所以最好设置一个超时机制var timeoutTask Task.Delay(TimeSpan.FromSeconds(10)); var completed await Task.WhenAny(tcs.Task, timeoutTask); if (completed timeoutTask) { throw new TimeoutException(SDK初始化超时); }这个模式也适用于把一个回调API封装成现代异步API场景的核心思路。我以前把某个串口通信库的读取接口用TaskCompletionSource封装后上层代码从嵌套回调变成线性await可读性一下好了好几个量级。5.2 ValueTask 与 Task的区别何时使用ValueTask是.NET Core 2.0之后引入的性能优化类型。它跟Task最大的区别在于Task是引用类型每创建一个任务都会产生堆分配ValueTask是值类型在同步完成的情况下不会产生分配。什么时候该用ValueTask呢举一个典型场景一个从缓存读数据的异步方法大多数情况下数据在缓存里方法会同步返回结果只有缓存未命中时才真的需要异步等待。public async ValueTaskstring GetDataAsync(string key) { if (_cache.TryGetValue(key, out var value)) return value; // 同步路径零分配 var data await _source.FetchAsync(key); // 异步路径 _cache[key] data; return data; }如果这里用Taskstring即使缓存命中也会创建一个完成的Task对象这是一种浪费。用ValueTaskstring时同步路径完全不分配堆内存。使用ValueTask的约束也要注意一个ValueTask只能被await一次。如果你await完之后还想再await一次或者需要多次访问就必须先转换成Task.AsTask()。因为ValueTask没有状态存储能力它不适合被复用。下表是直观的对比对比维度TaskValueTask类型引用类型值类型同步完成时的堆分配有无可多次await支持不支持缓存结果复用方便受限适用场景绝大多数异步方法热路径/高频调用/可能同步完成5.3 自定义Awaitable让等待更灵活在C#中只要一个类型具有GetAwaiter()方法并且这个awaiter满足INotifyCompletion或ICriticalNotifyCompletion接口、拥有IsCompleted属性和GetResult()方法你就可以对它使用await关键字。这个鸭子类型设计给了我们极大的自由。比如你可以写出一个等1秒的匿名对象await TimeSpan.FromSeconds(1);只需要一个扩展方法public static class TimeSpanExtensions { public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan) Task.Delay(timeSpan).GetAwaiter(); }再比如可以实现一个等待直到条件成立的awaitablepublic class WaitUntilAwaiter { private readonly Funcbool _condition; private readonly int _intervalMs; public WaitUntilAwaiter(Funcbool condition, int intervalMs) { _condition condition; _intervalMs intervalMs; } public bool IsCompleted _condition(); public void OnCompleted(Action continuation) PollAndContinue(continuation); public void GetResult() { } }虽然自定义awaitable在业务代码里不常用但理解这套协议能让你更深刻地掌握异步的底层机制——你会发现await并不绑定某个特定类型它就是一套可等待协议。5.4 同步路径短路避免不必要的异步开销异步方法不是免费的编译器生成状态机本身也有开销包括字段分配、状态检查逻辑、异步方法的栈帧分配等。所以对于有可能同步完成的方法故意加上快速返回的同步路径是性能优化里很有效的一招。举个典型例子public TaskOrder GetOrderAsync(int id) { // 校验失败直接同步返回避免状态机开销 if (id 0) return Task.FromResultOrder(null); return GetOrderCoreAsync(id); } private async TaskOrder GetOrderCoreAsync(int id) { // 真正的异步逻辑 return await _repository.FindAsync(id); }有人说这种写法过于刻意但我在做高吞吐量服务时实测过对于校验类快速失败路径直接Task.FromResult能减少约30%-50%的异步方法开销。如果你的方法在RPC调用中每秒被调用几万次这个优化就不是空谈了。还有一点async方法中如果完全没有await编译器会生成一个关于异步方法缺少操作符的警告并且状态机的执行会有额外开销。与其写一个没有await的async方法不如直接去掉async关键字手动返回Task.FromResult。6. 实测排查Task死锁、线程饥饿与性能分析6.1 经典死锁场景阻塞等待与SynchronizationContext异步代码的死锁和同步代码的死锁长得完全不一样它更隐蔽也更让人摸不着头脑。最常见的一种是UI线程 同步阻塞 SynchronizationContext 三件套导致的死锁。典型代码长这样public void Button_Click(object sender, EventArgs e) { var result GetDataAsync().Result; // 同步阻塞等待 // 后续使用result } private async Taskstring GetDataAsync() { await Task.Delay(1000); return data; }发生过程如下UI线程调用GetDataAsync().ResultUI线程被阻塞等待异步结果。异步方法执行到await Task.Delay(1000)发现任务未完成于是注册回调把延续动作包括返回UI线程的指令投递给当前的SynchronizationContextUI上下文。1秒后任务完成回调试图回到UI线程执行——但UI线程此时还在.Result那里阻塞着无法处理回调消息。结果UI线程等待TaskTask等待UI线程死锁。这个问题的根治方法就一句话永远不要用同步方式等待异步代码。只要你在UI应用程序里看见了.Result、.Wait()就有潜在死锁风险尤其当内层方法用到了ConfigureAwait之外的默认上下文时。如果确实有一段同步方法比如实现某个接口无法用async你也要先从根上把异步链路改成同步化的完整方案而不是在一个异步方法上直接加.Result强行同步。6.2 线程池饥饿当所有线程都被卡住线程池饥饿是服务端异步代码里更难发现的问题。它的特征是CPU利用率很低但所有请求的响应延迟极高而且错误率在增长日志里常常伴随TimeoutException。造成饥饿的典型场景是在异步方法中使用同步阻塞操作。比如在await之前调用了.Result或者在async方法里用了lock并配合.Wait()再或者用了Parallel.ForEach但迭代体里阻塞等待异步结果。线程池的处理机制是这样的当检测到线程不足时它会在500毫秒到1秒的间隔内逐步注入新线程但这个速度远跟不上突发流量。更糟的是如果注入的线程又立刻阻塞线程池会继续注入最终形成大量僵尸线程——线程数量上去了但每个线程都在等待效率直线下降。排查线程池饥饿有两个实用手段计数器在代码里定期输出ThreadPool.ThreadCount和ThreadPool.PendingWorkItemCount并发高时如果ThreadCount持续飙升而PendingWorkItemCount居高不下就要警惕饥饿。分析转储抓进程转储Dump看线程栈里是否有大量Monitor.Wait或Task.Wait等待。我遇到过的真实案例一个网关服务下游数据库偶尔超时开发同学在async方法里调.Result做了重试结果流量高峰期线程池被耗尽整个服务瘫痪。这就是典型的把异步代码退化成同步阻塞的惨痛教训。6.3 诊断工具与几个调试经验异步代码的调试比同步代码难但组件齐全时也没那么可怕。我最常用的诊断方式有这么几种Visual Studio 的并行堆栈Parallel Stacks窗口调试时打开它可以直观看到所有线程的调用栈标出阻塞关系。dotnet-counters命令实时监控线程池指标、GC堆大小和异常计数器。dotnet-dump命令抓取进程快照做离线分析适合线上问题。WPF/WinForms里的Dispatcher线程检查可以在UI代码开头断言当前线程是否是UI线程提早暴露上下文恢复问题。调试异步代码还有一个基本功——设置仅我的代码和首次异常断点工具菜单里打开首次异常First Chance Exceptions异常一发生就停在现场能极大加快定位速度。另外写async方法时有个小习惯非常有用所有抛异常的地方显式在异常里带上异步方法名和上下文信息。因为异步异常经常跨越线程StackTrace里能看到的信息有限自定义附加上下文能省掉大量排查时间。实操经验在团队里定一条纪律——async方法的命名必须带Async后缀同步包装方法命名保持原样。这样别的开发者在Code Review时一眼就能识别哪些方法是真正的异步避免误用同步阻塞。这个简单的约定值是很大的尤其是在异步化的迁移项目里。7. 我踩过几次坑之后的几点体会说起Task和异步编程我最大的感受是它提供的不是多线程的银弹而是一种任务编排的思维方式。真正的高级用法不是写几个async/await就完了而是要理解ThreadPool、SynchronizationContext、状态机、异常传播这些底层机制是如何把一个将来会完成的操作变成可组合、可取消、可监控的程序单元的。回顾实际项目最能提升异步代码质量的往往不是哪个API更高级而是那些朴素的纪律不用.Result阻塞等待、async void只留给事件处理器且在内部捕获异常、取消令牌从头到尾保持一致、不要制造线程池饥饿。这些几十行小规矩比掌握所有Task的进阶API更能避免线上事故。如果你正在从一个同步代码为主的存量系统往异步模型迁移我的建议是先约读书本概念本篇文章相当于浓缩了概念再把一个低频的接口作为试点改造用日志记录线程ID、执行时间和异常情况用数据验证收益然后再大规模铺开。异步编程的学习曲线不是学会语法就结束的而是用出性能、写出可靠才算真正开始。
返回列表