1. 异步编程的本质:async/await 到底解决了什么问题
1.1 同步模型的瓶颈在哪里
聊到 C# 异步编程,我脑子里最先冒出来的不是 async/await 的语法,而是几年前一次线上事故。某内部管理系统的查询接口,刷新页面时整个界面卡死,用户反馈“点一下要转三秒圈”。排查之后发现,罪魁祸首是一个同步调用链:页面加载时要依次查询用户信息、组织架构、最近操作记录,三个数据库查询串行执行,每个平均耗时 800 毫秒,加起来快三秒。更麻烦的是,这三个查询彼此没有依赖关系,同步模型硬生生把本可以并行的事情做成了排队。
这就是同步模型的本质问题:线程在等待 I/O 时被白白占用。数据库查询、文件读写、HTTP 请求这类操作,绝大多数时间都花在内核态等待设备响应上,CPU 侧其实无事可做。同步代码里,一个线程发起 I/O 后就会一直阻塞在那里,直到操作系统把数据送回来。线程本身不便宜,一个线程默认栈空间就有 1MB,线程上下文切换也有开销。当并发量上来,线程池线程被这些“等待中”的任务占满,后续请求只能排队,吞吐量和响应时间双双恶化。
我常拿餐厅经营来打比方。同步模型就像每个顾客配一个专职服务员,顾客点菜后服务员就站在桌边等菜上齐,期间不再接待其他人。如果顾客多,餐厅只能不断招服务员,成本高不说,厨房出菜慢时所有服务员全都无所事事地站着。异步模型则是一个服务员同时照看多桌客人,点完 A 桌的菜就去接待 B 桌,等后厨通知“A 桌菜好了”,再端着菜过去上桌。一个人干了好几个人的活儿,餐厅接待能力自然上去了。
1.2 异步模型:不空转,才是真高效
async/await 正是为了实现“不空转”而生的。它并不会让单个 I/O 操作本身变快——你在数据库上发一个查询,从发起到拿到结果的时间几乎不变——它改变的是“线程的利用方式”。在异步模型里,当一个异步操作处于等待状态时,当前线程会被释放回线程池,去处理其他请求;当异步操作完成时,会从线程池中重新调度一个线程来继续执行后续代码。线程始终在被真正使用,而不是闲着等数据。
用数字来说话。假设某服务需要同时处理 100 个请求,每个请求要发一次外部 HTTP 调用,耗时 500 毫秒。同步模型下,这 100 个线程在等待期间全部被占住,哪怕 CPU 只有 5% 的使用率,也无法腾出线程去处理新请求。异步模型下,100 个请求的等待期间只占用少量线程,空闲线程可以继续接收和处理新请求。吞吐量不是一个量级的事情。
这里要澄清一个常见误解:async关键字本身不创建新线程,也不做任何异步操作。它只是标记一个方法,告诉编译器“这个方法里有 await 表达式,请你帮我把它改造成状态机”。真正异步的是 await 的操作——比如Task.Run、HttpClient.GetStringAsync、File.ReadAllBytesAsync这类 API 内部使用的 I/O 完成端口或线程池调度。Task.Run确实会用线程池线程,但它适合的是 CPU 密集型任务的并行化,不适合用在线程等待 I/O 的场合。理解了这一点,你就不会被初学阶段“async 是不是就是多线程”的困惑卡住。
2. 深入 async/await:编译器在背后做了什么
2.1 状态机与异步方法分解
要写出高质量的异步代码,只记住“加了 async 就能异步”是远远不够的。你必须知道编译器拿到一段异步代码后,到底干了些什么。
当你写下:
public async Task<string> FetchAsync(HttpClient client, string url) { var response = await client.GetStringAsync(url); return Process(response); }这段代码在编译时会变成一个状态机类,原来方法体被拆解为若干状态块。每个await表达式的位置都对应一个状态块边界。执行流程是这样的:第一次调用方法时,先执行到第一个await之前的部分;如果 await 的 Task 尚未完成,就把当前状态(比如“我在第几行”)和局部变量保存到状态机里,然后返回一个未完成的 Task 给调用方;当底层 I/O 完成,状态机会被回调,恢复现场,继续执行 await 之后的代码。
这个机制解释了很多现象。比如为什么async方法里的局部变量能在await之后继续使用——因为编译器把它们变成了状态机的字段。为什么异常能在await之后正常抛出——因为状态机的MoveNext方法会捕获异常并设置到返回的 Task 上。这也是“await 之前和 await 之后,不一定在同一线程上执行”的原因——在哪个线程上恢复执行,由同步上下文和调度策略决定。
我在实际排查问题时发现一个特别容易忽视的细节:状态机是会分配内存的。每个 async 方法调用都会在堆上创建一个状态机对象,如果方法频繁被调用,这会带来不小的 GC 压力。好消息是,从 .NET Core 2.1 开始,运行时做了一个优化——如果 await 的任务在大多数情况下已经同步完成,编译器会把状态机直接保存在栈上,避免堆分配。这也是为什么在高性能场景下,你应该避免无谓的 async 包装,能直接拿值就不要绕一圈 Task。
2.2 同步上下文:为什么死锁会找上门
同步上下文是 async/await 机制里最具欺骗性的一环,也是面试和实际排查中出现频率最高的坑点。
在 UI 线程(WinForms、WPF)、ASP.NET Classic 的请求上下文环境中,存在一个同步上下文(SynchronizationContext)。它的作用是把任务的回调调度回特定线程。WinForms/WPF 的同步上下文把回调调度回 UI 线程,这样才能安全地更新控件。当你在 UI 线程执行await时,await 之后的代码默认会尝试通过同步上下文回到 UI 线程继续执行。
这里就埋了雷。看这段代码:
public string GetUserInfoSync() { var task = GetUserInfoAsync(); var result = task.Result; // 阻塞当前线程等结果 return result; } public async Task<string> GetUserInfoAsync() { // 内部有一个 await,默认要回到 UI 线程 await Task.Delay(100); return "user"; }当 UI 线程调用GetUserInfoSync,执行到task.Result时,UI 线程被阻塞等待任务完成。而GetUserInfoAsync内部的Task.Delay完成后,状态机尝试通过 UI 同步上下文把回调调度回 UI 线程。结果 UI 线程正在阻塞等待,回调进不去——这就是典型的死锁:UI 线程等 Task 完成,Task 等 UI 线程空闲。两边互相等,谁也放不下。
在 ASP.NET Core 和 .NET Core 的普通 console 程序里,不存在这种默认同步上下文,所以同样的代码不会死锁。但如果你在处理桌面应用或旧框架,死锁问题随时可能出现。解决方案也简单:要么一路 async/await,绝不阻塞;要么在库代码里用ConfigureAwait(false),告诉编译器不用回到原同步上下文。具体什么时候用,后面的实践部分细讲。
3. 异步编程最佳实践:每个关键决策背后的为什么
3.1 命名与签名:让异步意图一目了然
很多人觉得命名规范只是小事,但它在异步代码里承担的责任比想象中大得多。方法命名为GetUserAsync而不是GetUser,这样函数调用方一眼就能看出这是异步操作,应该用 await 调用,而不是像同步方法一样直接把返回值拿到手。
几个硬性规范值得刻进肌肉记忆:
- 方法名以
Async结尾。这是 .NET 生态的共识,也便于静态分析工具检查你是否在调用处正确 await 了。 - 返回值类型优先用
Task<T>或Task,除非你在写事件处理器或接口实现有不得已的约束,避免async void。async void方法的异常无法被调用方 catch 到,会直接抛到同步上下文,导致程序崩溃。 - 不要返回
nullTask。异步方法如果还没有启动就返回 null,调用方await会立即抛NullReferenceException。正确做法是返回Task.FromResult<T>(default)或ValueTask<T>封装结果。 - 方法内如果没有 await,就不要标记为 async。编译器会警告且有额外的状态机开销,说明这个方法可能设计错了。
还有一个容易被忽略的点:不要在参数里塞一个“是否同步执行”的布尔开关,比如GetDataAsync(bool sync),这类接口很容易让调用方无意间选择同步路径,然后把整个异步链路断掉。真需要支持两种模式,分开两个方法。
3.2 ConfigureAwait(false):库代码的必修课
ConfigureAwait(false)是异步编程里被讨论最多、误解也最多的 API。它的作用非常具体:告诉 await 之后的代码不要尝试回到捕获到的同步上下文,直接在线程池线程上继续执行。
什么时候该用ConfigureAwait(false)?在库/框架代码里,几乎应该默认使用。因为库代码很少需要访问 UI 控件或请求上下文,没必要为回调回原线程付出调度成本和潜在死锁风险。我自己写公共组件时,所有await后面都接上ConfigureAwait(false),只有最外层与 UI 交互的方法才用默认行为。
什么时候不该用?UI 事件处理器、View 层代码、需要访问 UI 控件的代码。比如一个 WPF 窗口的按钮点击事件里,await LoadDataAsync(); _textBlock.Text = result;——这里的 await 必须回到 UI 线程,否则更新控件会抛异常。如果在 ViewModel 里写代码,不直接操作控件,可以把业务逻辑部分的 await 都加上ConfigureAwait(false),只在最后更新可通知属性时切回 UI。
需要强调一个常见误区:ConfigureAwait(false)不会把 await 之后的代码放到另一个线程。它只是改变了“恢复执行时的上下文策略”。如果你的代码原本就是在 UI 线程执行的,await 一个未完成的 Task 后又想继续更新 UI,加不加ConfigureAwait(false)都不会自动让代码回到 UI 线程——加了这个,回调在线程池跑,更新 UI 照样崩。正确做法是保持默认,让它回到 UI 线程。
3.3 一以贯之:不要让异步在边界断掉
“async 会传染”这句话不是玩笑。一个异步操作从初始源头到最底层,全程都应该是异步的。
最典型的问题就是用.Result、.Wait()或.GetAwaiter().GetResult()去同步等待异步方法的结果。这在简单场景下看起来能“凑合跑通”,但它锁死了一个线程,剥夺了异步带来的并发优势,而且与async方法内部的同步上下文机制叠加,极易触发死锁。我自己吃过一次亏:某集成项目里,一个中间件为了图省事,把异步的 HTTP 调用包装成了同步接口给老代码调用,结果高并发时线程池被占满,接口响应从 200ms 飙升到 8 秒。后来把那层同步包装全部拆掉,接口直接暴露成异步方法,问题才缓解。
这里的经验是:你要么做减法,把调用链全部改写成异步;要么做隔离,在异步边界处使用专门的适配机制。对于老代码,更现实的方案是使用SemaphoreSlim.WaitAsync或自定义的异步兼容层,而不是粗暴地.Result。
同样的逻辑也适用于async void。事件处理器是没有办法才用async void的唯一合法场景,比如按钮点击事件。除此之外,任何async void方法都应该被替换为async Task。因为只有Task返回类型才能让调用方 catch 异常,才能用Task.WhenAll等方法组合多个异步操作。
3.4 CancellationToken:响应取消,优雅退出
用户点了刷新,结果页面还在慢慢加载上一次的请求;服务端配置了 5 秒超时,但一个调用链里的所有 HttpClient 请求都没有传超时时间。这类问题的通用解药就是 CancellationToken。
我的习惯是任何可能耗时的异步方法都默认带上 CancellationToken 参数。即使当前没有真实的取消场景,API 签名里预留这个参数,也意味着将来调用方需要时不需要破坏签名来添加。比较完善的异步循环代码会长这样:
public async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken cancellationToken = default) { foreach (var item in items) { // 每处理一项前检查一次取消 cancellationToken.ThrowIfCancellationRequested(); await ProcessOneAsync(item, cancellationToken); } }把令牌传给底层的库方法(HttpClient、File、DbCommand等 API 都支持)有两个好处:一是真正的 I/O 可以提前中止,不必等它超时返回;二是取消是协作式的一一你抛出 OperationCanceledException,上层可以选择捕获后把结果标记为取消,或者转换成一个用户可理解的错误信息。
有时还需要把多个取消源合并起来,比如“用户主动取消”和“全局超时”任何一个触发都要停下来,用CancellationTokenSource.CreateLinkedTokenSource把两个 token 合二为一,再传给异步方法。这个技巧在实现类似“轮询等待外部状态变化且限时”的场景时特别实用。
3.5 并发组合:WhenAll/WhenAny 的正确用法
回到开头那个三查询串行的问题,它本质上是没用好并发组合。Task.WhenAll可以同时启动多个异步任务,然后等待它们全部完成。于是三个互相无依赖的查询可以并行执行,总耗时从三者之和降低到三者最慢的那个。
实际用法上有个细节值得注意:WhenAll 并不保证任务在哪个线程上执行。对于 I/O 密集的异步操作(数据库查询、HTTP 调用),直接调用异步方法返回 Task,它们就会并发执行,不需要额外开线程。对于 CPU 密集的任务,如果要并行执行,可以考虑Task.Run包装后再交给WhenAll,否则相当于在同一个线程上轮转执行,并不会加速。
举个例子,加载仪表盘的四个板块,数据源互相独立:
var userTask = GetUserAsync(token); var ordersTask = GetOrdersAsync(token); var messagesTask = GetMessagesAsync(token); var metricsTask = GetMetricsAsync(token); await Task.WhenAll(userTask, ordersTask, messagesTask, metricsTask); var user = await userTask; var orders = await ordersTask; // 后续组装注意,在WhenAll之前就把四个方法都调用了,这样四个 Task 才会同时运行。如果你写的是await GetUserAsync(); await GetOrdersAsync(); ...,本质上又变成了串行。这个顺序错误是异步代码里很常见的 bug,代码审查时我会特别留意。
Task.WhenAny则用于“多个任务任意一个完成就继续”的场景,比如实现超时、竞速请求多个数据源选最快者返回。它和CancellationTokenSource搭配,能实现“第一个成功就取消其余”的高级控制流,这在微服务调用、多活数据源切换时非常实用。
3.6 资源与异常处理:别让后台任务偷偷崩溃
异步方法的异常处理和同步代码有本质区别。同步代码的异常是立即抛出、立即被 catch;异步代码的异常存放在返回的 Task 里,只有当你await这个 Task 或显式调用task.GetAwaiter().GetResult()时,异常才会被抛出。
这意味着:
- 忘了 await 一个 Task,异常会被静默吞掉。比如某个 fire-and-forget 的调用,任务内部抛了异常,没人看见。
- 多个任务用 WhenAll 组合,异常是聚合的(AggregateException)。如果你想第一时间找出根因,可以在 catch 中遍历
ex.InnerExceptions,或者用await Task.WhenAll(...)时直接捕获具体的子异常类型。实际上,await一个Task.WhenAll抛出的是第一个实际失败的异常(WhenAll源码有优化),而不是聚合异常,这点很多人不知道。 - 没有观察者的异常任务,最终会触发
TaskScheduler.UnobservedTaskException。这个事件默认环境下不会导致程序崩溃,但会输出日志。稳妥做法是在应用入口注册该事件,记录异常,避免异常信息丢失。
写异步方法时,原则是尽量让异常在合适层级被捕获和处理,而不是一路抛到无人区。内部 try-catch 要明确:能恢复的就恢复(比如重试、降级),不能恢复的就包装上下文重新抛出,不要让底层的 IO 异常裸奔到 UI 层。
此外,异步场景下的资源释放要用await using(对应IAsyncDisposable),比如SemaphoreSlim、异步流、数据库连接和网络流等。某些对象实现了IDisposable和IAsyncDisposable两套接口,释放逻辑里如果有异步工作,务必用await using,否则异步释放路径会被同步包装转回阻塞调用,性能损耗不小。
4. AsyncLazy 实战:从原理到完整实现
4.1 为什么需要 AsyncLazy :延迟初始化的痛点
聊完基础实践,我们来做一个进阶设计——AsyncLazy<T>。这个组件解决的问题是:以一个重量级且构造过程本身是异步的对象,如何进行延迟初始化且保证并发安全。
经典场景是配置中心客户端。应用启动时,我们不想立刻加载配置,希望在第一次真正读取配置时才去拉取远程配置;配置项又是全局共享的,多个线程/异步任务可能同时第一次访问它。我们希望这个初始化过程只发生一次,之后所有调用者都直接拿到缓存结果。
.NET 自带的Lazy<T>可以解决同步场景的延迟初始化和线程安全,但它的Value属性是同步的,无法等待一个异步的初始化过程。如果你在Lazy<T>里塞一个HttpClient.GetStringAsync的调用,要么用.Result阻塞(违反异步最佳实践),要么把整个初始化逻辑塞进Task.Run,都不是合理方案。
于是有了AsyncLazy<T>:它把“延迟初始化”和“异步初始化”两个需求统一在同一个类里。
4.2 第一版实现:懂得原理,但不具备并发安全
一个最直觉的实现,是利用Lazy<Task<T>>做个包装:
public class AsyncLazy<T> { private readonly Func<Task<T>> _factory; private readonly Lazy<Task<T>> _lazy; public AsyncLazy(Func<Task<T>> factory) { _factory = factory ?? throw new ArgumentNullException(nameof(factory)); _lazy = new Lazy<Task<T>>(() => _factory()); } public Task<T> Value => _lazy.Value; }用法是:
var lazyConfig = new AsyncLazy<Config>(() => LoadConfigFromRemoteAsync()); var config = await lazyConfig.Value;这个版本“能跑”,但有几个问题。
第一,Lazy<Task<T>>的ExecutionAndPublication模式能保证只有一个线程初始化 Task,但它不保证 Task 内部的操作是线程安全的。如果_factory()本身没有额外保护,多个线程同时调用_lazy.Value,只会得到同一个 Task 实例——这是线程安全的。但是如果你试图在_factory里做两步异步操作呢?比如先请求配置,再初始化数据库连接池,这就不受Lazy保护了。
第二,缓存的是Task 实例本身,如果_factory()抛异常,异常会被缓存在 Task 里;如果初始化失败,后续所有调用都会拿到同一个失败 Task,无法重试。在配置中心这类场景里,网络抖动导致的初始化失败很常见,不能重试的设计不够健壮。
第三,如果_factory是异步 lambda(比如async () => await ...),编译器生成的异步状态机也会带来额外分配。异步工厂的性能问题在频繁调用场景会被放大。
4.3 完善并发与异常处理:一个可用于生产环境的版本
我在实际项目中经过几轮迭代,最终使用的版本有几个核心改进。关键在于自己用锁或信号量来保护初始化,而不是完全依赖Lazy<T>:
public class AsyncLazy<T> { private readonly object _lock = new object(); private readonly Func<Task<T>> _factory; private Task<T> _task; private bool _initialized; public AsyncLazy(Func<Task<T>> factory) { _factory = factory ?? throw new ArgumentNullException(nameof(factory)); } public Task<T> GetValueAsync() { // 快速路径:已初始化,直接返回缓存 Task if (Volatile.Read(ref _initialized)) { return _task; } lock (_lock) { if (!_initialized) { _task = CreateTaskFromFactory(); _initialized = true; } return _task; } } public void Reset() { lock (_lock) { _task = null; _initialized = false; } } private Task<T> CreateTaskFromFactory() { try { return _factory(); } catch (Exception ex) { // 同步异常也要封装进 Task,而不是在锁内抛出 return Task.FromException<T>(ex); } } }设计决策逐个说。
用 lock 而不是 Lazy。因为Lazy<T>的默认行为对异常处理和重置控制不够灵活。lock 的开销只在首次初始化的短暂瞬间存在,之后走的是快速路径——Volatile.Read(ref _initialized)一次 volatile 读,没有任何锁开销。高并发下性能表现很好。
用Volatile.Read而不是直接读_initialized。这样保证内存可见性。lock中写、锁外读,如果不做 volatile 读,可能读到过期值。这是多线程编程里容易忽略的细节。
工厂方法的同步异常也封装进 Task。如果_factory()在锁内同步抛出异常(比如工厂方法内部有个 null 检查),_initialized虽然被设成 true 了,但_task却是 null(因为没有赋值成功),后续调用者直接return _task会拿到 null,然后 await 就崩了。封装成Task.FromException<T>,保证_task永远非 null,这是我在一次线上故障里踩出来的教训。
提供Reset()方法。失败后可以重置,重试初始化。这个能力对配置中心、动态切换数据源这类场景极其重要。重置时不需要把正在使用旧 Task 的调用者打断,只需要把缓存字段清零,下次GetValueAsync会重新初始化。这就是“从旧值平滑迁移到新值”的降级策略。
4.4 实际应用场景:配置加载与缓存刷新
AsyncLazy<T>在我项目里的典型用法是配置对象。假设有一个全局配置类AppConfig,初次访问时要从远程配置中心拉取,后续希望一直复用同一个实例,直到显式刷新。
public class ConfigService { private readonly AsyncLazy<AppConfig> _configLazy; private readonly IRemoteConfigClient _client; public ConfigService(IRemoteConfigClient client) { _client = client; _configLazy = new AsyncLazy<AppConfig>(LoadConfigAsync); } public Task<AppConfig> GetConfigAsync() => _configLazy.GetValueAsync(); public void Invalidate() => _configLazy.Reset(); private async Task<AppConfig> LoadConfigAsync() { var json = await _client.FetchAsync(); return AppConfig.Parse(json); } }调用方就用一句await _configService.GetConfigAsync(),不管是不是第一次调用,拿到的都是同一个配置实例。管理后台点击“刷新配置”时调用Invalidate(),下一次访问就会重新拉取。
另一个值得一试的场景是缓存令牌。比如某个第三方服务要求先获取 access token,再用它调用业务 API。token 有过期时间,且获取 token 本身也需要几十毫秒。可以用AsyncLazy<Token>缓存当前 token,配合定时器周期调用Reset()实现提前刷新。因为AsyncLazy<T>缓存的是 Task,多个并发调用者同时访问时会共享同一个 token Task,不会出现同一秒内请求了 50 次 token 接口的情况。
这里提醒一下:AsyncLazy<T>缓存的是异步操作的结果,不是“值类型快照”。如果T是可变的引用类型,多个组件同时持有同一个实例,修改它的属性会互相影响。需要不可变对象的地方,要么让T设计成不可变类型,要么返回时做浅拷贝/深拷贝。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 根本原因 | 排查与解决 |
|---|---|---|
| UI 卡死或程序无响应 | 在某处用.Result/.Wait()阻塞了带同步上下文的环境 | 一路 async/await;分离 UI 层和库层;库代码加ConfigureAwait(false) |
| 异步方法里异常被吞 | 调用方没有 await 返回的 Task,或者使用了async void | 用Task返回类型;await所有异步调用,或者显式记录task.ContinueWith日志 |
| WhenAll 抛出的异常信息不直观 | Task.WhenAll默认会包装成AggregateException(在特定聚合场景),如果直接await task.WhenAll(...)也可能只看到第一个异常 | 捕获后在调试器看InnerExceptions;使用await Task.WhenAll(...).ConfigureAwait(false)并结合日志逐一记录子任务异常 |
| 异步代码性能退化 | 每个方法都标记 async,即使内部没有 await;或大量Task.Run包装 CPU 密集型任务 | 移除无 await 的 async;CPU 密集任务用Task.Run并发而非异步包装 |
| 线程池饥饿,高并发响应变慢 | 某些同步阻塞调用占用大量线程池线程,导致其他异步任务无法调度 | 找出并替换.Result/.Wait();用dotnet-counters观察线程池队列长度 |
| 初始化成本高且失败无法重试 | 缓存的 Task 失败被永久缓存 | 改成AsyncLazy<T>+Reset()的设计,失败后重置再试 |
| 静态构造函数里调用异步方法 | 静态构造不能 await,导致用.Result阻塞 | 把初始化放入显式的异步初始化方法,或使用IAsyncLazy<T>模式延迟到首次使用时执行 |
5.2 定位异步问题的方法与工具
排在第一位的是学会用调试器的并行堆栈和任务窗口。Visual Studio 和 Rider 都提供了“并行堆栈”“任务”调试窗口,能直接看到每个线程正在等待哪个 Task,哪些线程处于阻塞状态,哪些 Task 处于等待调度状态。死锁的现场往往一眼就能看清:线程 A 在task.Result处阻塞,Task B 在等待回到线程 A。
第二位是事件日志。在应用启动阶段注册TaskScheduler.UnobservedTaskException,打印所有“没有人观察到的异常任务”的堆栈。大量异步代码里,这个问题隐蔽性极强,不注册这个事件,异常信息会在 GC 回收时悄悄消失。我见过不少线上问题,最终都是从这个事件日志里找到根因的。
第三位是 dotnet 命令行工具。dotnet-counters可以实时监控线程池队列长度、活跃线程数、内存分配等指标。当你怀疑线程池饥饿时,跑到生产环境抓一组计数器数据,看threadpool-queue-length和threadpool-thread-count的变化曲线,基本能立即确认。如果队列长度持续累积且线程数打满,就是同步阻塞调用太多挤占了线程池。
第四位是dotnet-dump做离线分析。在故障现场抓一个内存快照,然后用dotnet-dump analyze进入交互式分析,可以用clrstack查看线程调用栈,用dumpheap -type找可疑对象。我处理过一个异步内存泄漏问题:某个异步循环里不断创建新的HttpClient,用 dump 看到堆上几千个 HttpClient 实例,一下就明白了。
写异步代码半年后,我的体会是一句话:代码能不能看懂是能不能改对的前置条件。很多异步 bug 都是“看起来正确但实际并发行为不对”——恰好是async/await这种看似同步实则异步的模型的宿命。把状态机、同步上下文、异常语义这三件事彻底吃透,比多背几条规范都管用。就像AsyncLazy<T>这种组件,价值不在于它本身有多复杂,而在于它把一个反复出现的并发难题封装好,让使用者不再被细节绊倒。
最后再分享一个我自己的小经验:写完异步代码,过一个星期回过头来再加一个新调用方,顺手把await补全、标注取消、检查异常链路一一过一遍,常常能发现最初版本里自己都没注意到的漏洞。这不算什么技巧,但确实帮我避免过不止一次线上故障。