异步与并发深入

深入剖析 .NET 异步与并发模型,覆盖 Task 与 ValueTask 的取舍、async 流与取消协作、同步上下文与 ConfigureAwait、Parallel 与并发集合,以及常见死锁与性能陷阱,帮助写出高吞吐且不出错的异步代码。

1. Task 与 ValueTask 的取舍

一句话总结: Task 表示一个可能异步完成的操作,ValueTask 是零分配替代品,选择它们的依据是「同步完成概率」与「调用次数」。

Task 是 .NET 异步编程的核心抽象,代表一个可能尚未完成的操作。Task<T> 携带结果,Task 只表示完成。ValueTask<T> 在 .NET Core 3.0+ 引入,它的价值是消除堆分配——当操作经常同步完成时,没有理由每次都创建一个 Task 对象。

// 常规异步方法
public async Task<Order> GetOrderAsync(int id)
{
    await Task.Delay(10); // 模拟 IO
    return new Order(id);
}

// ValueTask:适合「经常同步返回」的热路径
public ValueTask<Order> GetOrderCachedAsync(int id)
{
    if (cache.TryGetValue(id, out var order))
        return new ValueTask<Order>(order);   // 同步完成,零分配
    return new ValueTask<Order>(LoadFromDbAsync(id));
}

private async Task<Order> LoadFromDbAsync(int id)
{
    await Task.Delay(50);
    return new Order(id);
}
类型分配限制
Task<T>每次 await 都可能堆分配可多次 await
ValueTask<T>同步路径零分配只能 await 一次,不能缓存
Task无结果,分配较小可组合、可缓存

避坑: ValueTask 只能被 await 一次。把 ValueTask 存进字段、放进集合或多次 await 都会导致未定义行为。默认接口方法、常规异步 API 请继续使用 Task,只有真正高频且经常同步完成的路径才换 ValueTask。

2. async/await 的底层状态机

一句话总结: async 方法会被编译器重写为状态机,遇到未完成的 await 就释放当前线程返回调用方,这就是异步「不阻塞线程」的根本原因。

await 的本质是:如果被等待的操作已完成,直接继续执行;否则把「后面的代码」打包成延续(continuation),注册到被等待操作上,立即返回。编译器生成一个 AsyncStateMachine 结构体来保存局部变量与执行位置。

// 表面写法
async Task<Order> GetOrderAsync(int id)
{
    var user = await GetUserAsync(id);        // 挂起点 1
    var address = await GetAddressAsync(id);  // 挂起点 2
    return new Order(user, address);
}

// 概念等价的状态机(编译器生成,示意)
// MoveNext 里保存 resume point,两个 await 之间可让出线程
阶段发生的事
首次调用同步执行到第一个未完成 await,返回未完成任务
挂起注册延续,线程释放,继续处理其他请求
恢复IO 完成,延续在捕获的上下文上重新执行 MoveNext
完成状态机推进到终点,设置任务结果

避坑: async void 只用于事件处理器。它产生的异常无法被 try/catch 捕获,会直接崩掉进程(.NET 会把异常抛到 SynchronizationContext 上)。普通方法返回 Task,让异常进入任务,由调用方或全局处理器兜底。

3. 取消与超时协作

一句话总结: CancellationToken 让取消请求沿调用链传播,配合 WaitAsync 实现超时,是构建可中断异步代码的标准方式。

取消不是「终止线程」,而是协作式的:请求方通过 Cancel() 触发令牌,被取消方在安全检查点抛出 OperationCanceledException 主动退出。超时则可以看成「到点自动取消」的取消源。

// 创建取消源并设置超时
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

async Task<string> FetchAsync(CancellationToken ct)
{
    using var http = new HttpClient();
    // 把令牌传给每个支持取消的调用
    return await http.GetStringAsync(url, ct);
}

// 显式取消
cts.Cancel();

// 超时保护:不传令牌的调用也能被 WaitAsync 兜底
var result = await longOperation.WaitAsync(TimeSpan.FromSeconds(3), ct);
// 自己实现支持取消的循环
async Task ProcessAsync(IAsyncEnumerable<int> items, CancellationToken ct)
{
    await foreach (var item in items.WithCancellation(ct))
    {
        ct.ThrowIfCancellationRequested(); // 安全检查点
        await HandleAsync(item, ct);
    }
}
场景手段
传播取消参数传递 CancellationToken
超时CancellationTokenSource(TimeSpan) 或 WaitAsync
批量取消CancellationTokenSource.CreateLinkedTokenSource
忽略取消捕获 OperationCanceledException 后按需处理

避坑: 不要用 Task.Run(...).Wait(timeout) 来「模拟超时」——那会阻塞线程。也不要在取消后继续写共享状态。取消后应尽早 return 或抛出,保持调用链上的资源(连接、句柄)及时释放。

4. 同步上下文与 ConfigureAwait

一句话总结: await 默认会把延续调度回原同步上下文,ConfigureAwait(false) 用于不依赖 UI/HttpContext 的库代码,避免不必要的上下文捕获与死锁风险。

SynchronizationContext 定义「代码该在哪跑」。WinForms/WPF 有 UI 上下文,ASP.NET Core 有每请求的 HttpContext 上下文。await 默认捕获当前上下文并在其上恢复延续——这对 UI 程序必要,但在纯库代码里是浪费,还可能引发死锁。

// ASP.NET Core 中:每请求上下文
public async Task<IActionResult> Get(int id)
{
    var data = await _repo.GetAsync(id);   // 默认捕获请求上下文
    return Ok(data);
}

// 库代码:不依赖上下文,明确放弃捕获
public async Task<string> ReadFileAsync(string path)
{
    await using var fs = File.OpenRead(path);
    using var reader = new StreamReader(fs);
    return await reader.ReadToEndAsync().ConfigureAwait(false);
}
场景建议
UI/请求处理器保留捕获,不要 ConfigureAwait(false)
通用库方法一律 ConfigureAwait(false)
两者混用只在最外层恢复上下文

避坑: 经典死锁:在 UI 线程上 .Result/.Wait() 阻塞,而延续要回到 UI 上下文执行,二者互相等待。修复方法是全程 async/await 穿透,或者在库内部 ConfigureAwait(false)。ASP.NET Core 没有 SynchronizationContext,但仍有 HttpContext 关联,混用阻塞调用同样危险。

5. Parallel 与并发集合

一句话总结: Parallel 与 PLINQ 适合 CPU 密集计算,并发集合为多线程共享数据提供无锁或轻锁容器,但线程安全不等于「业务安全」。

Parallel.For 把工作分片并行执行,ConcurrentDictionary、ConcurrentQueue 等集合在内部用无锁技术或分段锁,允许多线程安全读写。但它们只保证单个操作原子,不保证「读改写」组合操作原子。

// Parallel.For 处理 CPU 密集任务
var results = new double[1_000_000];
Parallel.For(0, results.Length, i =>
{
    results[i] = Math.Sqrt(i) * Math.Sin(i);
});

// ConcurrentDictionary 的安全更新
var counts = new ConcurrentDictionary<string, int>();
Parallel.ForEach(words, word =>
{
    // 组合操作仍要加锁:AddOrUpdate 保证原子
    counts.AddOrUpdate(word, 1, (_, n) => n + 1);
});

// 并发队列:生产消费者
var queue = new ConcurrentQueue<string>();
queue.Enqueue("task-1");
if (queue.TryDequeue(out var item))
{
    Console.WriteLine(item);
}
集合特点
ConcurrentDictionary分段/无锁读写,复杂操作需 AddOrUpdate
ConcurrentQueueFIFO,TryDequeue 安全
ConcurrentBag无序,适合任务池
BlockingCollection带阻塞边界的队列
ConcurrentStackLIFO

避坑: ConcurrentDictionary 的 ContainsKey + TryAdd 是两个操作,中间可能被别人插队。需要「检查再写」时用 GetOrAdd/AddOrUpdate 这类原子方法。并行度不是越高越好,CPU 密集任务超出物理核心数只会增加上下文切换。

6. Channel 与生产者消费者

一句话总结: System.Threading.Channels 提供高性能内存队列,作为 Producer-Consumer 的标准化实现,支持有界背压与完成传播。

后台任务常见的需求是「一个生产者放数据,多个消费者处理」。Channel<T> 把有界性、背压和完成信号都封装好了:有界 Channel 在生产过快时让生产者异步等待,防止内存无限增长。

// 有界 Channel:容量 100,生产过快会等待
var channel = Channel.CreateBounded<int>(
    new BoundedChannelOptions(100)
    {
        FullMode = BoundedChannelFullMode.Wait
    });

// 生产者
async Task ProduceAsync(ChannelWriter<int> writer, CancellationToken ct)
{
    for (int i = 0; i < 1000; i++)
    {
        await writer.WriteAsync(i, ct);
    }
    writer.Complete(); // 通知消费者结束
}

// 消费者
async Task ConsumeAsync(ChannelReader<int> reader, CancellationToken ct)
{
    await foreach (var item in reader.ReadAllAsync(ct))
    {
        Console.WriteLine(item);
    }
}

// 组合运行
var producer = ProduceAsync(channel.Writer, ct);
var consumer = ConsumeAsync(channel.Reader, ct);
await Task.WhenAll(producer, consumer);
选项作用
BoundedChannelFullMode.Wait满时等待(背压)
DropOldest/DropNewest满时丢弃旧/新消息
SingleWriter/MultiWriter声明并发度,优化锁
reader.Completion等待消费者全部完成

避坑: 忘记 writer.Complete() 会让 ReadAllAsync 永远挂起。消费者异常时调用 writer.Complete(exception) 把异常传给生产者,避免静默吞掉。有界 Channel 配合 Wait 模式是控制内存上限的常用手段,比无限 ConcurrentQueue 安全得多。

7. 常见死锁与性能陷阱

一句话总结: 死锁多来自「阻塞调用 + 上下文捕获」,性能坑多来自「异步里包同步 IO」「过多小任务」与「async void」,识别信号比背诵清单更重要。

异步死锁在 UI 与旧式 ASP.NET 里常见,ASP.NET Core 因无 SynchronizationContext 较少见,但线程饥饿与上下文滥用依然致命。性能上,Task.Run 包裹同步 IO、热点路径不必要的分配、大量短生命周期 Task 都是常见杀手。

// 陷阱 1:同步包异步 → 线程池饥饿
public string Bad()
{
    return GetAsync().GetAwaiter().GetResult(); // 阻塞线程池线程
}

// 陷阱 2:Task.Run 包同步 IO(IO 不需要额外线程)
public async Task<string> BadIo()
{
    return await Task.Run(() => File.ReadAllText("a.txt")); // 浪费线程
}

// 陷阱 3:async void 异常逃逸
async void BadEvent() => throw new InvalidOperationException();

// 正例:全程异步穿透,只在入口层阻塞
public async Task<string> Good()
{
    return await GetAsync(); // 一路 async/await
}
症状常见根因
请求全部挂起线程池饥饿(同步阻塞异步)
UI 卡死主线程 Wait() + 上下文死锁
CPU 高但吞吐低同步 IO 塞进线程池
偶发未处理异常async void
内存抖动热点路径用 Task 而非 ValueTask

避坑: 诊断异步问题时先问三个问题:有没有阻塞异步(.Result/.Wait)?有没有把同步 IO 外包给 Task.Run?有没有 async void?这三类是 .NET 异步代码 90% 疑难问题的来源。用 dotnet-trace 抓线程状态,看有没有大量线程卡在 Wait 上。

8. 总结

主题要点
Task/ValueTask常规用 Task,高频同步完成路径用 ValueTask
状态机await 让出线程,不阻塞调用方
取消CancellationToken 协作式传播,WaitAsync 兜底超时
ConfigureAwait库代码 false,UI/请求层保留捕获
并发集合线程安全不等于组合操作原子
Channel有界 + Wait 模式提供背压
死锁与性能阻塞异步、同步包异步、async void 是三大坑

异步不是「不用线程」,而是复用线程:每个 await 挂起点都让出线程去服务别的请求,IO 完成后延续再回来。要写出高吞吐的 .NET 服务,核心是让 async 穿透整个调用栈——从 HTTP 入口到数据库驱动,一路 Task,一路 await,只在最外层消费结果。在此基础上叠加取消传播、背压与正确的并发集合,异步系统才能既快又稳。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排