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 |
ConcurrentQueue | FIFO,TryDequeue 安全 |
ConcurrentBag | 无序,适合任务池 |
BlockingCollection | 带阻塞边界的队列 |
ConcurrentStack | LIFO |
避坑:
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,只在最外层消费结果。在此基础上叠加取消传播、背压与正确的并发集合,异步系统才能既快又稳。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。