async/await 最大的问题,是它「能跑」的门槛太低、「跑对」的门槛太高。绝大多数异步性能事故在编译期毫无征兆,直到某天下午高峰期线程池饿死、接口整体超时才暴露。这篇文章总结我在代码评审里最常抓到的五类问题,每一条都给出修复示例。

陷阱一:sync over async

第一名毫无悬念。在异步链路的中间用 .Result.Wait() 把它掰回同步:

// ❌ 死锁与线程池饥饿的高发区
var user = _userService.GetUserAsync(id).Result;

// ✅ 全链路异步,async 一路到底
var user = await _userService.GetUserAsync(id);

在 WPF / MAUI 这类有 SynchronizationContext 的 UI 框架里,这个写法可以直接死锁;在 ASP.NET Core 这类没有同步上下文的服务端,它不会死锁,但会阻塞线程池线程——高峰期每个被阻塞的请求都占着一个线程等另一个线程干活,雪崩就是这么来的。

陷阱二:类库代码里丢掉 ConfigureAwait(false)

public async Task<Config> LoadAsync()
{
    var text = await File.ReadAllTextAsync(_path).ConfigureAwait(false);
    return JsonSerializer.Deserialize<Config>(text)!;
}

这条有争议,先说结论:应用层(ASP.NET Core)不需要,通用类库建议加。ASP.NET Core 本身没有 SynchronizationContext,加不加行为一致;但你的类库如果同时被 UI 项目引用,不加就会把后续代码拽回 UI 线程执行。新项目如果启用了 CSharpAnalyzer 的 CA2007 规则,可以按项目类型统一策略,别一半一半。

陷阱三:热路径上滥用 Task

每个 async 方法被 await 且未同步完成时,都会分配一个状态机对象。在「大多数请求都能命中缓存」的热路径上,这个分配完全没必要——这正是 ValueTask 的舞台:

public ValueTask<User> GetUserAsync(int id)
{
    return _cache.TryGetValue(id, out var user)
        ? new ValueTask<User>(user)                    // 同步命中,零额外分配
        : new ValueTask<User>(LoadUserFromDbAsync(id)); // 未命中才走真正的异步
}

代价是纪律:ValueTask 只能 await 一次,不能并发 await,不能用 .Result 硬取。不满足这些纪律就老老实实用 Task,别为了省一次分配引入新的坑。

陷阱四:CancellationToken 断链

public async Task<Report> BuildAsync(CancellationToken ct)
{
    // ❌ ct 没有往下传,取消形同虚设
    var raw = await _api.FetchAsync();

    // ✅ 令牌贯穿整条链路
    var raw = await _api.FetchAsync(ct);
    var parsed = await Task.Run(() => Parse(raw), ct);
}

取消令牌是「合作关系」:调用方取消后,只有每一层都配合检查,操作才能真正停下来。断链的典型症状是客户端已经超时断开,服务端还在傻跑。评审时我会专门盯每个异步方法的签名——没有 CancellationToken 参数的公共异步方法,默认要问一句为什么。另外别忘了 ASP.NET Core 的 RequestAborted,把请求中止和它关联起来是免费的性能优化。

陷阱五:async void

// ❌ 异常会直接砸到线程上,无法 await、无法捕获
private async void OnMessage(object? s, EventArgs e)
    => await HandleAsync();

// ✅ 事件处理器是唯一例外;其他场景一律返回 Task
private async Task OnMessageSafeAsync(object? s, EventArgs e)
    => await HandleAsync();

async void 是「发射后不管」:调用方既等不到完成,也接不住异常,未处理异常会顺着线程砸进 TaskScheduler.UnobservedTaskException 甚至直接崩进程。唯一合法的场景是事件处理器签名受限——即便如此,也建议在内部套一层 try/catch。

一个可以贴在工位上的 checklist

  • 异步链路里有没有 .Result / .Wait() / GetAwaiter().GetResult()
  • 通用类库的 await 是否统一了 ConfigureAwait 策略?
  • 缓存命中的热路径要不要换 ValueTask?
  • CancellationToken 是否贯穿到底?
  • 有没有 async void 漏网之鱼?
异步代码的正确姿势没有多难,难的是团队里每个人都守纪律。把这份清单挂进 code review 模板,比事后救火便宜得多。