C#
异步、并发与取消
正确传播 async/await、取消和异常,并为批量同步设置明确的并发上限。
发布于 2026年7月23日
异步、并发与取消
异步不是让代码“自动更快”,并发也不是同时启动越多越好。异步让等待 I/O 时释放线程;并发让多个工作生命周期重叠。设计前必须明确任务类型、并发上限、取消传播、共享状态和部分失败策略。
一、学习目标
- 理解 Task、async 与 await 的控制流
- 让异步从入口传播到最底层 I/O
- 正确传递和观察 CancellationToken
- 使用 Task.WhenAll 并发独立操作
- 设置并发上限并处理共享状态
- 区分 I/O 异步和 CPU 并行
二、异步方法的基本契约
public async Task<IReadOnlyList<StudyTask>> SyncAsync(
CancellationToken cancellationToken)
{
IReadOnlyList<StudyTask> remote =
await remoteClient.FetchAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
repository.ReplaceFromRemote(remote);
return remote;
}
调用异步方法通常先同步执行到第一个尚未完成的 await,然后把未完成工作表示为 Task 返回。任务成功、失败或取消都会在 await 时传播。
除事件处理器外避免 async void。调用方无法等待它,也难以捕获异常和协调生命周期。
三、异步一路传播
错误:
IReadOnlyList<StudyTask> tasks =
client.FetchAsync(CancellationToken.None).Result;
.Result、.Wait() 和 GetAwaiter().GetResult() 会阻塞线程,在带同步上下文环境中可能死锁,也会把异常包装或隐藏异步结构。
正确:
static async Task<int> Main(string[] args)
{
return await RunAsync(args);
}
System.CommandLine 支持异步动作时,让命令动作返回任务并由框架等待。整个调用链保持异步,直到真实同步边界,例如短暂 SQLite 事务。
四、取消令牌
public async Task ExportAsync(
string path,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<StudyTask> tasks = repository.List();
await exporter.WriteAsync(path, tasks, cancellationToken);
}
约定:
- 可取消公共异步方法接受
CancellationToken - 参数通常放在最后
- 令牌传给所有支持取消的下层 API
- 循环中的纯计算定期检查
- 不把
CancellationToken.None偷换给下层
取消是协作式请求,不会强制终止线程。已经提交的数据库事务不能假装被撤销;要么在提交前检查,要么完成原子提交后报告结果。
五、超时
超时是由调用方拥有的取消策略:
using var timeoutSource =
CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
timeoutSource.CancelAfter(TimeSpan.FromSeconds(10));
try
{
await remoteClient.FetchAsync(timeoutSource.Token);
}
catch (OperationCanceledException)
when (!cancellationToken.IsCancellationRequested &&
timeoutSource.IsCancellationRequested)
{
throw new TimeoutException("同步超过 10 秒");
}
外部取消和内部超时必须区分。底层库通常只传播取消,让拥有超时配置的用例层转换语义。
测试超时应使用可控时间或短、确定的同步原语,不依赖长时间 Task.Delay。
六、并发独立操作
顺序等待:
RemoteTask a = await client.GetAsync(1, cancellationToken);
RemoteTask b = await client.GetAsync(2, cancellationToken);
若彼此独立,可以先启动再一起等待:
Task<RemoteTask> first =
client.GetAsync(1, cancellationToken);
Task<RemoteTask> second =
client.GetAsync(2, cancellationToken);
RemoteTask[] tasks = await Task.WhenAll(first, second);
WhenAll 在全部成功后按输入任务顺序返回结果。任一任务失败时,返回任务最终失败;其他任务不会自动因兄弟失败而取消,除非共享策略令牌被取消。
创建任务列表时不要丢弃返回的 Task。未观察异常会让错误脱离用例生命周期。
七、限制并发
对大量远端 ID 不应一次启动无限请求:
static async Task<RemoteTask[]> FetchAllAsync(
IReadOnlyList<int> ids,
RemoteTaskClient client,
int maxConcurrency,
CancellationToken cancellationToken)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxConcurrency);
using var gate = new SemaphoreSlim(maxConcurrency);
Task<RemoteTask>[] operations = ids.Select(async id =>
{
await gate.WaitAsync(cancellationToken);
try
{
return await client.GetAsync(id, cancellationToken);
}
finally
{
gate.Release();
}
}).ToArray();
return await Task.WhenAll(operations);
}
并发上限来自配置并有合理最大值。对持续数据流,Channel<T> 或 Parallel.ForEachAsync 能更自然地表达生产、消费和背压。
八、部分失败策略
批量同步前先决定契约:
- 任一失败即整体失败并取消其他操作
- 收集每项成功和失败,返回结构化结果
- 只允许特定暂时错误重试
public sealed record SyncItemResult(
int Id,
bool Success,
string? ErrorCode);
不要简单 catch 后忽略。调用方必须知道哪些任务已写入、哪些可重试,以及再次执行是否幂等。
如果最终数据库写入要求全部成功,就先完成所有网络读取和验证,再开启一个短事务提交。
九、共享状态
错误:
int completed = 0;
await Task.WhenAll(operations.Select(async operation =>
{
await operation;
completed++; // 竞态
}));
可用:
Interlocked.Increment(ref completed);
更好的设计常常是让每个任务返回结果,最后统一统计,减少共享可变状态:
bool[] results = await Task.WhenAll(operations);
int count = results.Count(static success => success);
普通 List<T>、Dictionary<TKey,TValue> 不能在多个任务中无锁写入。并发集合解决数据结构安全,但不自动保证跨多步业务不变量。
十、常见错误
同步阻塞异步
.Result 和 .Wait() 浪费线程并可能死锁。异步一路传播。
启动任务却不等待
异常、取消和生命周期脱离用例。保存并等待所有 Task。
无上限 Task.WhenAll
会耗尽连接、句柄或内存。设置基于资源的并发上限。
吞掉 OperationCanceledException
上层会误以为成功。传播取消,或在拥有策略的边界转换。
十一、练习与自测
练习:
- 把一个
.Result调用链改成完整异步。 - 为批量同步加入最大并发和外部取消。
- 分别测试用户取消、用例超时和一个子任务失败。
自测:
- async 对 I/O 等待做了什么?
- Task.WhenAll 是否自动取消兄弟任务?
- 为什么数据库提交后不能假装取消成功?
十二、官方资料
上一篇:HTTP、JSON 与 API 客户端 | 下一篇:测试、调试与代码质量