资讯详情

资讯详情

C#异步编程完全指南:async/await原理、实战与避坑

写Async/Await这个话题我其实一直有点犹豫。技术文章很容易写得像文档翻译罗列完语法就完事但对实际项目帮助有限。我见过太多人在界面卡死、死锁、异常莫名消失之后才开始认真琢磨这俩关键字而网上零散的资料又很难一次讲透。这篇文章我想换个思路从底层原理讲到实战细节把async和await拆开揉碎把我自己踩过坑之后沉淀下来的经验一次说清楚。先说清楚这篇是什么、给谁看如果你是刚接触C#异步编程想知道async方法里到底该写什么、Task.Run和await有什么区别这篇能帮你建立完整认知如果你已经在用但总遇到死锁、超时、并发控制的问题后面几个章节可以直接当排查手册用。我会尽量用大白话解释原理该上代码的地方也绝不含糊。1. 异步编程的演进为什么我们离不开Async和Await要理解async和await的价值得先回到没有它们的日子看看。1.1 同步代码的“卡顿”是怎么来的先看一个最经典的场景WinForms或WPF界面里的按钮点击事件。你写了一段读文件的代码private void Button_Click(object sender, EventArgs e) { string content File.ReadAllText(C:\data\report.txt); textBox.Text content; }看起来没毛病但文件稍微大一点或者网络磁盘慢一点界面就“假死”了。原因是UI线程是唯一的界面线程它被ReadAllText阻塞住了。界面线程忙着等文件读完没办法处理鼠标消息、重绘窗口用户体感就是程序未响应。服务器端也一样甚至更严重。Web请求处理线程被同步IO堵住时线程池里的线程会用完后面的请求只能排队整个服务吞吐量断崖式下降。我见过某内部系统压测时线程数打满、CPU却很低的情况典型的“线程饥饿”罪魁祸首就是大量同步IO调用。同步代码的最大问题是线程在等待期间什么都没干。资源、时间全浪费了。异步的核心思想是等待IO时不占着线程先把线程释放出去干别的活等IO完成了再回来继续。1.2 异步编程的三代演进从回调地狱到关键字在async/await出现之前.NET其实已经有了异步编程模式只是用起来都比较反人类。第一代叫APM模式Asynchronous Programming Model用BeginXXX和EndXXX方法。逻辑绕还容易出现回调嵌套。第二代叫EAP模式Event-based Asynchronous Pattern用事件和AsyncOperation来通知完成。好写一些但代码被拆得支离破碎。一直到.NET 4.0引入Task再到.NET 4.5推出async/await异步代码的写法才算“正常”了。核心变化是你按同步的思维去写代码编译器负责处理异步调度的细节。曾经需要回调函数嵌套的地方现在一行await搞定可读性天差地别。我经常跟团队里新人说async/await不是让你不写异步代码而是让你能像写同步代码一样去写异步代码难点交给编译器和运行时。2. Async与Await的语法核心与工作原理这一节把语法规则和底层机制一次讲透。语法很简单但原理值得多花几分钟。2.1 基本语法与三个硬性规则async修饰方法表示其内部可以用awaitawait用于等待一个异步操作完成并取得结果。看一个最标准的方法public async Taskstring ReadFileContentAsync(string path) { string content await File.ReadAllTextAsync(path); return content; }这里有几个规则需要注意async方法可以返回Task、TaskT、ValueTaskT或void。前三种是常态void只在事件处理器里用后面细讲。await后面跟的东西必须是“可等待对象”最常见的是Task和TaskT也可以是自定义的TaskAwaiter。async方法里至少要有一个await否则编译器会警告CS1998意思是你写了异步方法却全是同步逻辑。不可以在非async方法里使用await。这个属于入门就会踩的问题编译直接报错。很多人问async方法是不是要加Async后缀。这是. NET框架设计指南里的建议目的是一看方法名就知道它是异步版本需要调用方用await去等待。自己项目里要么全加要么全不加保持一致最重要我见过同一个项目里两种风格混杂的阅读起来非常难受。2.2 编译器在背后做了什么状态机await能“暂停并恢复”方法靠的是编译器生成的状态机。这个名字听起来高级实际可以用一个生活例子理解你正在看一部连续剧第8集结尾是悬念下一集还没更新。你不可能一直坐在沙发上等正常人会先去干别的事等新剧集上线了再回来继续看。状态机干的就是这件事。async方法在执行到await时如果发现等待的操作还没完成就立刻把当前线程还回去即返回一个未完成的Task到调用方并注册一个“续集通知”——等异步操作完成后再回来执行剩下的代码。还是用前面读文件的方法编译后的逻辑大致长这样伪代码帮助理解class MoveNextStateMachine : IAsyncStateMachine { public int state 0; public string result; public TaskAwaiterstring awaiter; public void MoveNext() { if (state 0) { // 第一次进入执行到await前的逻辑 awaiter File.ReadAllTextAsync(filePath).GetAwaiter(); if (!awaiter.IsCompleted) { state 1; // 注册continuation等待IO完成后再调用MoveNext return; } } if (state 1) { state -1; // 完成 result awaiter.GetResult(); } } }真实生成的代码比这个复杂得多但核心精神就是用状态编号记录执行到哪儿了需要等待时就退出完成后再从对应状态继续。这个设计意味着整个异步方法不是占着一个线程从头跑到尾而是多个线程可能在不同阶段接力执行。这也是为什么async方法里不能使用lock关键字来保护资源——lock依赖线程身份而状态机中断后有可能会切到另一个线程继续执行Monitor的持有关系就乱了。遇到需要异步互斥的场景得用SemaphoreSlim的WaitAsync()方法后面实战章节会写到。2.3 返回类型怎么选Task、Task 、ValueTask新手最容易纠结返回值类型。我的建议很简单没有返回值就返回Task。需要返回一个值就返回TaskT比如Taskstring、Taskint。需要极高性能并且等待结果大概率已经同步完成比如从内存缓存中取数据可以返回ValueTaskT。下面是几种场景的对比返回类型特点适用场景Task最通用开销稳定绝大多数无返回值异步方法TaskT等待后得到T结果读取数据、请求接口等ValueTaskT可减少堆分配高频调用、结果往往同步可用void无法被调用方捕获仅限事件处理器、UI回调ValueTaskT为什么能减少分配因为TaskT是类每次分配都会产生堆对象而ValueTaskT是结构体如果异步操作很快就完成了它可以直接以值的形式返回结果连Task对象都不需要创建。这个问题在每秒几十万次调用的热点路径上才会明显常规业务代码用TaskT完全没有问题不必为了炫技强行用ValueTask。3. 关键机制同步上下文与线程调度这是Async/Await最容易出问题却最少被讲解清楚的部分。很多死锁和界面卡死都源于对同步上下文缺乏理解。3.1 SynchronizationContext线程的“传话员”SynchronizationContext可以理解成一个线程调度器它规定了“某个异步操作完成后延续代码应该跑在哪个线程上”。举例子WinForms/WPF的UI线程启动时会有一个SynchronizationContext把UI线程的调度封装起来。你在UI线程上调用await时编译器生成的代码会捕获当前的同步上下文。当异步操作完成后默认会通过这个上下文把后续代码“送”回到UI线程执行。这也是为什么await之后你还能直接更新界面控件而不用像BackgroundWorker时代那样手动Invoke。这背后是Task延续机制在起作用。我画不出这里面的完整流程图但可以这么记await的恢复逻辑本质上就是“把剩余代码注册成当前任务的后续动作”而这个后续默认是带着同步上下文跑的。3.2 ConfigureAwait高性能场景的必修课正因存在“把后续代码送回原线程”这回事在库代码或服务器端代码里这往往是多余的开销。服务器端的同步上下文通常不需要回传白白多做一次线程切换反而浪费资源。解决办法是ConfigureAwait(false)public async Taskstring LoadDataAsync(string url) { string json await httpClient.GetStringAsync(url).ConfigureAwait(false); // 后续代码不强调回到原上下文 return json; }这句代码的意思是我不会依赖原同步上下文等待完成之后在哪条线程上执行都可以。对通用类库而言几乎总是应该加上ConfigureAwait(false)因为库的作者不知道调用方是UI程序还是控制台程序不依赖上下文是最安全和高效的选择。这里有例外如果是UI层代码需要更新控件、需要访问UI线程专属对象就不应该用ConfigureAwait(false)因为后续代码必须在UI线程上跑。我在给某团队做代码审查时看到过一个性能问题几十个异步IO操作全都没加ConfigureAwait(false)导致大量线程切换压测P95延迟高了一倍。后来批量加上之后延迟降下来了就是因为每次await少了一次无意义的上下文恢复。3.3 死锁是怎么发生的经典场景还原认识死锁最有效的方式就是亲手触发一次。打开一个WinForms或WPF项目在事件处理器里写这么一段private void Button_Click(object sender, EventArgs e) { string result GetDataAsync().Result; // 错误示范 textBox.Text result; }其中GetDataAsync内部是这样public async Taskstring GetDataAsync() { await Task.Delay(1000); // 模拟IO return done; }运行后会怎样程序直接卡死任务管理器里显示“无响应”。原因是UI线程调用.Result把自己阻塞同步上下文被这个线程占据接着GetDataAsync里的await检测到Task.Delay未完成就注册了一个“完成后续代码回到UI线程”的延续1000毫秒后Task.Delay完成尝试回到UI线程却发现UI线程正卡在.Result等它两者互相等待死锁达成。正确的做法是让异步“一路向上”即调用方也await而不是阻塞等待private async void Button_Click(object sender, EventArgs e) { string result await GetDataAsync(); textBox.Text result; }把这条原则记牢写异步代码就一路await到底不要用.Result或.Wait()去同步阻塞异步任务。服务器端遇到死锁的概率稍低一点但会造成线程池线程阻塞同样影响吞吐量属于需要坚决杜绝的坏味道。4. 实战操作几个高频场景的完整实现理论铺垫得差不多了开始上硬菜。下面这几个场景是我在实际项目中反复用到、也反复被同事问到的。4.1 并发请求的正确打开方式Task.WhenAll与WhenAny业务里经常会遇到一次要拉多个接口数据的情况。错误写法是循环里逐个await串行执行foreach (var url in urls) { var result await httpClient.GetStringAsync(url); Process(result); }如果请求耗时各自2秒五个请求下来就是整整10秒。而它们之间没有依赖关系完全应该并行执行var tasks urls.Select(url httpClient.GetStringAsync(url)).ToArray(); string[] results await Task.WhenAll(tasks); foreach (var result in results) { Process(result); }Task.WhenAll会启动所有请求等全部完成后一次性返回。这样五个请求耗时只相当于最慢的那一个而不是五个串联之和。Task.WhenAny则用来“谁先完成先处理谁”适合做超时控制、也适合在多个候选结果直接取最快返回的场景var firstFinished await Task.WhenAny(requests); var winner await firstFinished;注意一个容易踩的坑如果并发量很大比如同时发出几百个请求要在循环外先用SemaphoreSlim限制最大并发度否则一瞬间创建太多连接和线程容易把下游服务打挂。限制并发的写法通常是每个任务前先WaitAsync拿不到信号量就等着。4.2 超时与取消没有超时的异步是不完整的异步IO有个特点你等它的时候不知道它会等多久。没有超时机制用户可能遇到一个永远转圈的界面。.NET提供了CancellationToken来协作式取消异步操作最常用的超时写法是using var cts new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { var response await httpClient.GetStringAsync(url, cts.Token); // 正常处理 } catch (OperationCanceledException) { // 超时后进入这里提示用户稍后重试 }关键点在OperationCanceledException。当取消令牌在等待期间被触发异步方法会通过这个异常通知调用方“我不干了”。捕获它再做对应的业务降级处理。还有一种更通用的超时手段适合等待的不是标准异步API的场景。用Task.WhenAny让实际操作和延时任务赛跑var realWork DoSomethingAsync(); var timeoutTask Task.Delay(3000); var completed await Task.WhenAny(realWork, timeoutTask); if (completed timeoutTask) { // 超时处理注意此时realWork并未取消 } else { // 正常拿到结果 }这种写法的一个副作用是超时发生时DoSomethingAsync还在后台跑。需要取消时还是优先用CancellationToken配合靠超时“放弃等待”只是应急方案。我推荐的生产级做法是两者结合外层用CancellationTokenSource设置超时业务方法内部定期检查token.IsCancellationRequested实现真正的中途退出。4.3 异步环境下的锁SemaphoreSlim实战前面提过lock在异步方法里不能乱用那需要互斥怎么办用SemaphoreSlim它提供了WaitAsync方法等待信号量的时候不会阻塞线程。典型场景多个请求需要同时刷新一个缓存但同一时刻只允许一个请求真正去数据库读取其余请求等待并复用新缓存。代码大致是这样private readonly SemaphoreSlim _refreshLock new SemaphoreSlim(1, 1); private DateTime _lastRefreshTime; public async Task RefreshCacheIfNeededAsync() { if (DateTime.UtcNow - _lastRefreshTime TimeSpan.FromMinutes(5)) { return; // 缓存新鲜直接返回 } await _refreshLock.WaitAsync(); try { // 双检锁等锁期间可能已经有其他调用方刷新过了 if (DateTime.UtcNow - _lastRefreshTime TimeSpan.FromMinutes(5)) { return; } await ReloadDataFromDatabaseAsync(); _lastRefreshTime DateTime.UtcNow; } finally { _refreshLock.Release(); } }这里有两个细节值得说明一是必须用try/finally保证Release()一定执行否则信号量泄漏会逐步导致所有等待者卡死二是进入锁之后再次检查一次条件因为等锁期间其他调用方可能已经把活干完了这是经典的双检锁模式。还有一点SemaphoreSlim的实例要复用不要在方法内部new。放到字段或静态字段里否则并发场景下每个方法调用各持一把锁锁等于白写。5. 避坑指南最全的Async/Await事故现场最后这部分是我自己几次线上事故换来的教训逐条整理好了。5.1 async void是万恶之源async void方法在普通业务代码里几乎只有负面效果。最大的问题在于异常处理普通Task方法里的异常会被await捕获并包装调用方可以try/catch但返回void的异步方法没有可等待的任务异常一旦发生会直接抛到同步上下文或线程池的顶层直接导致进程崩溃根本没有地方去捕获。我记得排查过一次线上偶发崩溃最后定位到就是一个事件处理async函数里的数据库连接异常。打印日志显示进程被未观察异常终止而代码里怎么写try/catch都没用因为异常根本不会传回给调用链。什么场景可以用async voidUI事件处理器是唯一的例外因为事件本身的签名就是void。但哪怕是在事件处理器里我也建议把逻辑主体放到一个独立的async Task方法里事件壳只负责调用这样主体逻辑可以测试、异常也更容易观察private async void Button_Click(object sender, EventArgs e) { try { await HandleButtonClickAsync(); } catch (Exception ex) { // 记录日志提示用户 } } private async Task HandleButtonClickAsync() { // 真实业务逻辑 }5.2 异常处理细节AggregateException去哪了调用await时如果任务抛了异常await会直接抛出原始异常对象不需要你手动处理AggregateException。区别在于同步等待。如果某段代码用了.Result抛出的会是AggregateException它把内部异常包了一层新手经常在catch (Exception ex)里查看ex.Message发现根本不是预期异常还得去翻InnerException。另外一个高频坑是“未观察的Task异常”。如果你启动了一个异步任务但从未await它然后任务内部抛异常默认情况下这个异常最终会被抛出到TaskScheduler.UnobservedTaskException如果无人订阅运行时可能直接终止进程。这在“Fire-and-Forget”的写法里特别常见。正确做法是给这种任务挂一个延续用于吞掉或记录异常_ FireAndForgetTaskAsync().ContinueWith( t Log.Error(t.Exception), TaskContinuationOptions.OnlyOnFaulted);5.3 不要封装同步代码Task.Run的正确使用边界关于Task.Run很多人有个误解以为用它能提升性能。public Taskstring ReadFileAsync(string path) { return Task.Run(() File.ReadAllText(path)); // 错误示范 }这样写确实不会阻塞调用线程但Task.Run只是把同步操作丢给线程池做本质是把阻塞从一个线程转移到另一个线程并没有真正释放IO等待。对真正的异步IO比如文件、网络、数据库应该使用官方提供的异步方法比如File.ReadAllTextAsync、HttpClient.GetStringAsync。Task.Run的正确场景是有一个CPU密集型计算要在后台执行避免它占用UI线程或请求线程。对于纯粹的CPU计算没有异步IO可做Task.Run是正解。我遇到过团队里有人把所有的EF查询都改成.AsNoTracking().ToListAsync()性能却更差了。仔细排查发现数据库驱动本身不支持真正的异步命令那个异步方法底层也是同步执行的问题不在异步API而在于驱动实现。所以用异步API之前最好确认底层驱动是真的异步IO否则只是心理安慰。5.4 性能调优的几个真实经验异步性能问题有时候不明显我总结三个高频观察点第一个是线程切换开销。高频调用路径上的每个await都可能引发上下文切换对于类库代码统一加ConfigureAwait(false)能省掉一大部分无谓切换。第二个是异步方法的数量。异步不是越多越好。一个方法内部如果只是简单计算没有任何IO等待写成同步方法就好。每多一个异步方法就多一层状态机开销单个看微乎其微放大到百万次调用就非常可观。第三个是ValueTask的使用。当你的方法结果可能从缓存直接返回时ValueTaskT能避免不必要的Task对象分配。实测在每秒几万次调用的热点接口上内存分配量能明显降低GC压力也随之减小。分享一个压测时总结的配置如果跑在ASP.NET Core环境中对绝大多数业务处理的异步方法都可以放心使用ConfigureAwait(false)因为ASP.NET Core默认没有自定义同步上下文回传本来也是线程池执行。这一点跟传统.NET Framework的ASP.NET完全不同新项目里很多人纠结的上下文问题其实已经简单很多了。6. 给新手的最后提醒与经验总结技术点讲完了最后闲聊几句我自己的体会。async/await这套机制刚上手时觉得太方便写起来比回调优雅太多但深入了解之后会发现它带来的并发问题、生命周期问题远比同步代码复杂。我的经验是一个办法遇到想不通的异步问题先别急着调代码花点时间想想状态机在干什么。把“await之后的代码何时被执行”这个问题弄明白了一半的坑都能提前踩掉。另外多提一句调试异步代码时可以用调试器的“并行堆栈”窗口观察各线程执行位置Windows上还经常需要强制所有线程显示才能真正看清死锁时各个线程卡在哪。我以前排查死锁习惯于在可疑的.Result调用处加日志输出“before wait”和“after wait”配合线程ID很快就能定位互相等待的关系。这种笨办法往往比分析工具更直观。这篇算是把C#异步编程从原理到实战做了个完整梳理。如果你是按着文章里的代码自己敲过一遍的相信你对async和await的认知已经能超过大多数日常写业务的开发者了。接下来就是大量练习和总结经验遇到新的坑也别慌回头再看看状态机和同步上下文多半能找到答案。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →