C# 异步编程完整指南

1. Task 类

  • Task 类是对异步操作、并行任务等的封装。
  • 通过语法糖 async/await 来启用异步编程。
  • 通过 Task.Run() 可以将代码放在线程池线程上执行(适用于 CPU 密集型任务)。

2. 异步编程和同步编程

  • 同步编程:代码按顺序从上到下执行,遇到耗时操作(如 I/O、网络请求)会阻塞当前线程。
  • 异步编程:使用 async/await,遇到耗时操作时不会阻塞线程,线程可以被释放去做其他工作。

3. 异步编程和多线程

  • 异步是一种编程模型,核心是“不阻塞”。异步操作不一定需要新线程,例如 I/O 异步依赖于硬件和操作系统的通知机制,无需占用额外线程。
  • 多线程是一种技术,指同时运行多个线程(通常是 CPU 密集型并行)。
  • 只有使用 Task.Run() 或显式创建线程时,异步操作才会真正涉及多线程(线程池)。

4. I/O 密集和 CPU 密集

  • I/O 密集型:涉及数据库、Redis、HTTP 请求、文件读写等。推荐使用原生 async/await(如 HttpClient.GetStringAsyncStream.ReadAsync),不需要 Task.Run
  • CPU 密集型:涉及复杂计算、加密算法、图像处理等。推荐使用 Task.Run 将其放到线程池线程执行,避免阻塞 UI 或请求线程。

5. 异步的不阻塞机制

当遇到 await 时:

  • 当前方法暂停(yield),线程被释放回线程池或消息循环。
  • await 后面的代码需要等待异步操作完成后才会继续执行。
  • 外层调用者如果也 await 了这个方法,外层也会暂停;但如果不 await(或使用其他方式),外层可继续执行。
  • 异步操作完成后,线程池会分配某个线程(不一定是原先的线程)回来继续执行 await 之后的代码。

示例:

public async Task MainAsync()
{
    Console.WriteLine("start main method.");
    await TestAsync();          // 暂停,释放线程
    Console.WriteLine("End main method"); // 等待 TestAsync 完成后才执行
}

public async Task TestAsync()
{
    Console.WriteLine("Start Test method");
    await Task.Delay(2000);     // 暂停,释放线程
    Console.WriteLine("End Test method"); // 2秒后恢复执行
}

6. 同步上下文 SynchronizationContext

  • SynchronizationContext 表示代码运行所在的“环境”(如 UI 线程、ASP.NET 请求上下文等)。
  • await 默认会捕获当前 SynchronizationContext,异步操作完成后,会尝试回到原来的上下文执行剩余代码(例如在 WPF 中回到 UI 线程)。
  • 这个机制是实现“自动切回 UI 线程”的基础。

7. ConfigureAwait 控制是否切回上下文

  • ConfigureAwait(true):默认行为,尝试回到原始上下文(UI/请求上下文)。
  • ConfigureAwait(false):不切回原始上下文,后续代码在线程池线程上执行。
  • 使用建议
    • 在 UI 或 ASP.NET Core(旧版)之后需要操作 UI 或 HttpContext 时,必须 true
    • 在通用库代码、不需要上下文的地方,使用 false 可减少线程切换开销,提升性能。

8. 异步取消:CancellationToken 和 CancellationTokenSource(详细)

概念区分

类型角色能否主动触发取消用途
CancellationTokenSource信号的发起者✅ 可以(Cancel() 方法)创建取消信号,控制取消的时机(例如超时、用户点击取消按钮)。
CancellationToken信号的监听者❌ 不可以,只读传递给异步方法,让方法内部可以定期检查是否应该停止。

简单比喻:CancellationTokenSource 是一个开关(你可以按下它),CancellationToken 是连接到这个开关的电线(被通知的设备只能看信号,不能改变开关)。

核心使用总结

  • CancellationTokenSourceCancellationToken 必须配套使用
  • 外部控制取消:通过 CancellationTokenSource 调用 Cancel()(立即取消)或 CancelAfter(毫秒)(延时取消)。
  • 内部响应取消:在异步方法内部拿到 CancellationToken 后:
    • 主动抛出异常token.ThrowIfCancellationRequested() – 如果已取消,则抛出 OperationCanceledException,外部可捕获该异常。
    • 条件判断if (token.IsCancellationRequested) – 可自行决定如何退出(如直接 return、记录日志、执行清理后再抛异常等)。
⚠️ 重要ThrowIfCancellationRequested() 仅当外部已经调用了 Cancel() 或超时触发时才会抛异常。如果外部从未取消,该方法什么也不做。内部无法通过它主动抛异常

超时取消的特别说明

  • 超时(CancelAfterCancellationTokenSource(TimeSpan))只是定时发出取消信号,不会强制终止任务。
  • 任务不会自动停止,必须内部检查 token 并响应(如调用 ThrowIfCancellationRequested)。
  • 如果任务从未检查 token,即使超时已过,它仍会继续运行直到完成。

错误示范:

var cts = new CancellationTokenSource(1000);
var token = cts.Token;
Task badTask = Task.Run(async () =>
{
    await Task.Delay(5000); // 5秒后才完成,超时被忽略
    Console.WriteLine("完成");
});

正确示范:

Task goodTask = Task.Run(async () =>
{
    for (int i = 0; i < 10; i++)
    {
        token.ThrowIfCancellationRequested(); // 超时1秒后会抛出异常
        await Task.Delay(500, token); // Delay 内部也会响应取消
    }
}, token);

完整代码示例

using System;
using System.Threading;
using System.Threading.Tasks;

public class CancellationDemo
{
    public static async Task Main()
    {
        using var cts = new CancellationTokenSource();
        CancellationToken token = cts.Token;

        Task longRunningTask = DoLongWorkAsync(token);

        await Task.Delay(1000);
        Console.WriteLine("[Main] 用户取消了操作!");
        cts.Cancel();

        try
        {
            await longRunningTask;
            Console.WriteLine("[Main] 任务正常完成。");
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("[Main] 任务已被取消。");
        }
    }

    static async Task DoLongWorkAsync(CancellationToken token)
    {
        for (int i = 1; i <= 10; i++)
        {
            if (token.IsCancellationRequested)
            {
                Console.WriteLine($"[DoLongWork] 检测到取消,退出循环");
                token.ThrowIfCancellationRequested();
            }
            Console.WriteLine($"[DoLongWork] 进度:{i}/10");
            await Task.Delay(500, token);
        }
        Console.WriteLine("[DoLongWork] 任务完成");
    }
}

输出示例:

[DoLongWork] 进度:1/10
[DoLongWork] 进度:2/10
[Main] 用户取消了操作!
[DoLongWork] 检测到取消,退出循环
[Main] 任务已被取消。

高级用法

  • 超时取消var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); 或者 cts.CancelAfter(5000);
  • 链接多个 Tokenvar linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token1, token2);
  • 注册取消回调token.Register(() => Console.WriteLine("取消时执行此动作"));

9. WhenAll 和 WhenAny

  • 单个 await 不会缩短总执行时间,只是不阻塞线程。
  • 并发执行多个异步任务:先启动所有任务(不立即 await),然后使用 Task.WhenAllTask.WhenAny 等待。
  • Task.WhenAll:等待所有任务完成,所有结果聚合。
  • Task.WhenAny:任意一个任务完成即返回。
var task1 = DoWorkAsync();
var task2 = DoOtherAsync();
await Task.WhenAll(task1, task2);

10. 异步转同步、同步转异步(注意事项与反模式)

异步转同步(⚠️ 容易导致死锁)

  • 使用 .Result.Wait() 将异步方法变为同步阻塞。
  • 危险:在 UI 线程或 ASP.NET 旧版请求上下文中容易造成死锁(因为上下文线程被阻塞,异步操作无法回到该线程)。
  • 安全做法:使用 GetAwaiter().GetResult() 风险相同;最佳方案是自底向上全部改为异步

同步转异步

  • 不要用 Task.Run 包装同步方法来伪装异步(这只会浪费线程)。
  • 真正的异步应使用原生 async 方法(如 File.ReadAllTextAsyncHttpClient.GetStringAsync)。
  • 如果要包装一个同步但耗时的 CPU 方法,可以用 Task.Run,但调用方应理解这是 CPU 密集型任务。

11. 异步捕获异常

  • async 方法内的异常会被包装到返回的 Task 中。
  • 使用 try-catch 包围 await 即可捕获异常。
  • 同时等待多个任务时(WhenAll),异常会聚合到 AggregateException 中,但 await WhenAll 只会抛出第一个异常。若要获取所有异常,需要捕获并访问 task.Exception.InnerExceptions
try
{
    await Task.WhenAll(task1, task2);
}
catch (Exception ex)
{
    // 只会捕获第一个发生的异常
    // 要获取所有异常:检查 task1.Exception, task2.Exception
}
  • 若忘记 await 或未观察 Task 上的异常,会引发 UnobservedTaskException(.NET 4.5+ 默认会吞噬并触发进程退出)。

12. 返回值

  • Task:表示一个没有返回值的异步操作。调用方可以 await 等待完成。
  • Task<T>:表示返回类型为 T 的异步操作。
  • void仅限 UI 事件处理程序(如按钮点击)。其他任何地方都不要使用 async void,因为异常无法被捕获,且调用方无法等待它完成。

13. 死锁(同步与异步混用)

  • 典型死锁场景:在有同步上下文的环境(WPF UI、WinForms、旧版 ASP.NET)中,调用 Task.ResultTask.Wait() 等待一个尚未完成的异步方法。
  • 原因:异步方法完成后需要回到原上下文,但原线程被 Result 阻塞,无法处理消息,导致互相等待。
  • 解决方案
    • 全链路使用 async/await
    • 或者在子方法中使用 ConfigureAwait(false),但这不一定完全可靠。
    • 最彻底:避免使用 Result/Wait

14. ValueTask

  • ValueTaskValueTask<TResult> 是值类型的任务表示,用于性能敏感结果经常同步返回的场景。
  • 优点:减少堆内存分配。
  • 限制:
    • 只能被 await 一次
    • 不能并发 await
    • 不能使用 WhenAll/WhenAny
    • 通常由框架/库作者使用,一般应用开发中直接用 Task 即可。

15. 面试题补充:WPF 的 Dispatcher.Invoke vs async/await

  • 老旧代码:使用 Dispatcher.Invoke 将操作派送到 UI 线程,需要手动传递委托。
  • async/await:通过 SynchronizationContext 自动捕获 UI 上下文,await 之后天然回到 UI 线程(除非使用 ConfigureAwait(false)),更简洁、现代、灵活。

✅ 本指南已包含完整取消机制示例(含配套关系、外部取消、内部响应),以及超时取消的常见误区说明。

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *