浏览知识库目录

C#

异步、并发与取消

正确传播 async/await、取消和异常,并为批量同步设置明确的并发上限。

异步、并发与取消

异步不是让代码“自动更快”,并发也不是同时启动越多越好。异步让等待 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

上层会误以为成功。传播取消,或在拥有策略的边界转换。


十一、练习与自测

练习:

  1. 把一个 .Result 调用链改成完整异步。
  2. 为批量同步加入最大并发和外部取消。
  3. 分别测试用户取消、用例超时和一个子任务失败。

自测:

  • async 对 I/O 等待做了什么?
  • Task.WhenAll 是否自动取消兄弟任务?
  • 为什么数据库提交后不能假装取消成功?

十二、官方资料

上一篇:HTTP、JSON 与 API 客户端 | 下一篇:测试、调试与代码质量