异步编程

async/await

一、async/await 基础使用规范

1. 异步方法的定义

  • 修饰符:使用 async 关键字修饰的方法,称为异步方法。
  • 返回值约定
    • 有返回值时,返回值类型声明为 Task<T>,其中 T 是实际返回的数据类型,例如 Task<int>Task<string>
    • 无返回值时,也建议声明为非泛型 Task(而非 void),便于异常捕获与任务等待。
  • 命名惯例:异步方法名称建议以 Async 结尾,如 ReadAllTextAsyncWriteAllTextAsync

2. 调用与 await 关键字

  • 调用泛型异步方法时,在方法前加上 await 关键字,可直接获取 Task<T> 中包裹的 T 类型结果,无需手动解析 Task 对象。
  • 异步的“传染性”:一个方法内部如果使用了 await 调用异步方法,那么这个方法本身也必须被 async 修饰。
    • 典型示例:控制台程序的入口方法需要声明为 static async Task Main(string[] args)

基础示例

static async Task Main(string[] args)
{
    string fileName = "d:/1.txt";
    File.Delete(fileName);
    // 异步写入文件
    await File.WriteAllTextAsync(fileName, "hello async");
    // 异步读取文件,await 直接拿到 string 结果
    string s = await File.ReadAllTextAsync(fileName);
    Console.WriteLine(s);
}

3. 同步与异步方法的选择原则

同一功能同时存在同步实现和异步实现时,优先使用异步方法,以提升程序的并发能力与资源利用率。

4. 异步转同步的风险用法

对于不支持异步调用的场景,可通过以下方式同步等待,但存在死锁风险,生产环境尽量避免使用:

  • Wait():用于无返回值的 Task,同步阻塞等待任务完成。
  • Result:用于 Task<T>,同步阻塞获取结果。
  • 风险:在带有同步上下文(SynchronizationContext)的环境(如 WinForms、WPF、旧版 ASP.NET)中极易引发死锁。

二、async/await 底层原理

1. 本质是语法糖

async / await 是 C# 提供的语法糖,并非运行时原生实现。编译器会将异步代码重写为复杂的状态机结构,最终执行的仍然是普通 IL 代码。

  • 验证方式:使用 ILSpy 等反编译工具,将编译后的 dll 反编译为 C# 4.0 版本(不支持 async/await 的版本),即可看到底层的状态机实现代码。

2. 编译后的状态机结构

C# 编译器会对 async 方法做以下转换:

  1. 生成一个实现了 IAsyncStateMachine 接口的内部类(状态机类)。
  2. 将原方法中的代码按 await 调用的位置切分为多个“状态片段”,每个片段对应状态机的一个状态。
  3. async 方法的调用,最终会被拆分为对状态机 MoveNext() 方法的多次调用:每遇到一个 await 就暂停并记录当前状态,等待任务完成后再次调用 MoveNext() 继续执行下一段代码。

3. “等待”的真相

代码层面 await 看起来是“等待”任务完成,但经过编译后:

  • 没有真正的线程阻塞(wait),执行到 await 时会立即返回,剩余代码被封装为“延续(continuation)”,等异步操作完成后再被调度执行。
  • 这也是异步编程能够释放线程、提升吞吐量的核心原因。

三、异步方法的线程切换机制

1. 线程池复用机制

await 等待异步操作完成的期间:

  • .NET 运行时会把当前占用的线程归还到线程池,供其他任务使用。
  • 当异步操作执行完毕后,框架会再从线程池中取出一个可用线程,继续执行 await 之后的代码。

这意味着:await 前后的执行线程可能不是同一个

2. 常见误区:async 不会自动创建新线程

异步方法的代码不会自动在新线程中执行,它只是把代码切分成了多个可暂停的片段。

  • 只有显式使用 Task.RunTask.Factory.StartNew 等方式将代码放到线程池执行时,才会开启新的线程。
  • 对于 IO 密集型异步操作(如文件读写、网络请求),底层基于 IO 完成端口实现,等待期间全程不占用线程,这才是异步最大的价值。

3. async 方法的性能代价

异步编程并非没有成本,主要缺点有:

  1. 额外的内存与性能开销:编译器会生成状态机类,对象分配、状态切换都会带来少量性能损耗,运行效率低于普通同步方法。
  2. 线程占用可能增加:高并发场景下,大量异步任务的延续执行可能会占用更多的线程池线程,增加调度开销。

注:尽管有上述开销,但对于 IO 密集型场景,异步带来的吞吐量提升远大于这些损耗;而对于纯 CPU 计算场景,异步未必能带来收益。


四、线程阻塞与异步等待

1. Thread.Sleep 与 Task.Delay 的区别

方式 类型 行为 适用场景
Thread.Sleep(3000) 同步阻塞 阻塞当前线程,线程在此期间无法执行其他任务 同步代码中暂停
await Task.Delay(3000) 异步等待 不会阻塞线程,将线程归还线程池,时间到后再调度执行 异步方法中暂停

2. 使用原则

  • 在同步方法中暂停执行,可以使用 Thread.Sleep
  • 在异步方法中需要暂停/等待时,必须使用 await Task.Delay(),否则会阻塞线程,失去异步的意义。

五、任务提前终止:CancellationToken

1. 作用与使用场景

在实际开发中经常需要提前终止异步任务,例如:

  • 网络请求超时
  • 用户主动取消操作
  • 程序关闭时终止后台任务

.NET 中绝大多数内置异步方法都提供了 CancellationToken 参数,用于接收提前终止的信号。

2. CancellationToken 核心成员

CancellationToken 是一个结构体,核心属性与方法:

  • None:表示一个空的、永远不会被取消的令牌。
  • IsCancellationRequested:只读布尔属性,判断当前是否已收到取消请求。
  • Register(Action callback):注册一个回调方法,当收到取消信号时自动执行该回调。
  • ThrowIfCancellationRequested():如果任务已被取消,执行到该语句时会立即抛出 OperationCanceledException 异常,终止执行流。

3. 两种常用取消模式

  1. 主动轮询模式:在循环或分段代码中定期检查 IsCancellationRequested 属性,若为 true 则主动清理资源并退出方法。
  2. 异常终止模式:调用 ThrowIfCancellationRequested(),通过抛出异常的方式立即中断执行,由上层调用者捕获取消异常。

4. 基础使用示例

// 调用方:创建取消源,2秒后触发取消
using var cts = new CancellationTokenSource();
cts.CancelAfter(2000); 

try
{
    await DoLongWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("任务已被取消");
}

// 执行方:接收令牌并响应取消
async Task DoLongWorkAsync(CancellationToken cancellationToken)
{
    for (int i = 0; i < 10; i++)
    {
        // 方式1:检查取消状态后主动退出
        if (cancellationToken.IsCancellationRequested)
            return;
        
        // 方式2:直接抛出异常终止
        // cancellationToken.ThrowIfCancellationRequested();
        
        await Task.Delay(500, cancellationToken);
        Console.WriteLine($"执行第 {i+1} 步");
    }
}

六、多任务组合:并行执行与竞速

1. Task.WhenAll:等待所有任务完成

用于批量执行多个异步任务,等待全部完成后统一获取结果,适合并发 IO 操作(批量下载、批量查询)。

async Task<string[]> ReadAllFilesAsync(string[] filePaths)
{
    // 启动所有异步任务(并发执行)
    var tasks = filePaths.Select(p => File.ReadAllTextAsync(p)).ToArray();
    
    // 等待所有任务全部完成
    string[] allContents = await Task.WhenAll(tasks);
    return allContents;
}

2. Task.WhenAny:等待任一任务完成

等待任务集合中第一个完成的任务,常用于超时控制、多节点竞速请求。

// 向两个服务器发请求,谁先返回就用谁的结果
async Task<string> FastestApiRequestAsync()
{
    var taskA = RequestFromServerAAsync();
    var taskB = RequestFromServerBAsync();
    
    // 等待任意一个任务完成
    var firstCompleted = await Task.WhenAny(taskA, taskB);
    return await firstCompleted;
}

3. 进阶:带超时的任务控制

结合 Task.Delay + WhenAny 实现简易超时控制:

async Task<string> RequestWithTimeoutAsync(int timeoutMs = 3000)
{
    var requestTask = LongTimeRequestAsync();
    var timeoutTask = Task.Delay(timeoutMs);
    
    var winner = await Task.WhenAny(requestTask, timeoutTask);
    if (winner == timeoutTask)
        throw new TimeoutException("请求超时");
    
    return await requestTask;
}

七、异步异常处理

1. 单任务异常捕获

await 会将 Task 中包裹的异常重新抛出,使用普通 try-catch 即可捕获,写法与同步代码一致。

async Task TestSingleExceptionAsync()
{
    try
    {
        await RiskyReadFileAsync("not-exist.txt");
    }
    catch (FileNotFoundException ex)
    {
        Console.WriteLine($"文件不存在:{ex.FileName}");
    }
    catch (IOException ex)
    {
        Console.WriteLine($"IO 错误:{ex.Message}");
    }
}

注意:如果不使用 await,异常会被“藏”在 Task 对象中,不会主动抛出。

2. 多任务异常与 AggregateException

使用 Task.WhenAll 时,如果多个任务都抛出异常,最终会被包装为 AggregateException,通过 InnerExceptions 可遍历所有异常。

async Task TestMultipleExceptionsAsync()
{
    var task1 = ThrowErrorAsync("错误1");
    var task2 = ThrowErrorAsync("错误2");

    try
    {
        await Task.WhenAll(task1, task2);
    }
    catch (AggregateException ex)
    {
        foreach (var inner in ex.InnerExceptions)
        {
            Console.WriteLine($"异常:{inner.Message}");
        }
    }
}

async Task ThrowErrorAsync(string msg)
{
    await Task.Delay(100);
    throw new Exception(msg);
}

八、ConfigureAwait 与同步上下文

1. 核心作用

默认情况下,await 完成后会尝试回到原来的同步上下文(例如 UI 线程、ASP.NET 请求上下文)继续执行。

  • ConfigureAwait(false):告诉运行时不需要回到原上下文,直接在线程池线程上执行后续代码。

2. 使用原则

  • 类库代码:几乎都应该加 ConfigureAwait(false),减少上下文切换开销,同时大幅降低死锁风险。
  • UI 业务代码:如果后续代码需要操作界面控件,则不能加,必须回到 UI 线程。
// 类库中的标准写法
async Task<string> ReadFileForLibraryAsync(string path)
{
    // 不回到原上下文,在线程池继续执行
    string content = await File.ReadAllTextAsync(path).ConfigureAwait(false);
    return content.Trim();
}

九、async void 的坑与适用场景

1. 为什么不推荐 async void

  • 无法使用 await 等待方法执行,调用方无法获知任务何时完成。
  • 异常无法被常规 try-catch 捕获,会直接上升到全局异常,可能导致程序崩溃。
  • 无法追踪任务状态,难以进行错误处理和取消控制。

2. 唯一合法场景:事件处理器

async void 唯一被推荐的用法是UI 事件处理程序(如按钮点击、窗体加载),因为事件的签名本身就是 void 返回类型。

// ✅ 正确:WPF/WinForms 按钮点击事件
private async void btnDownload_Click(object sender, RoutedEventArgs e)
{
    btnDownload.IsEnabled = false;
    try
    {
        await DownloadLargeFileAsync();
        MessageBox.Show("下载完成");
    }
    catch (Exception ex)
    {
        MessageBox.Show($"下载失败:{ex.Message}");
    }
    finally
    {
        btnDownload.IsEnabled = true;
    }
}

// ❌ 错误:普通业务方法用 async void
async void BadBusinessMethodAsync()
{
    await Task.Delay(1000);
    throw new Exception("这个异常很难被捕获");
}

十、ValueTask:减少异步内存分配

1. 适用场景

当异步方法存在同步完成的快速路径(例如缓存命中、内存读取)时,使用 Task<T> 会每次分配一个堆对象,增加 GC 压力。
ValueTask<T> 是值类型,同步完成时不会产生堆分配,可优化性能。

2. 使用示例

private string _cachedConfig;

// 缓存命中时同步返回,不分配 Task 对象
async ValueTask<string> GetConfigAsync()
{
    if (_cachedConfig != null)
    {
        // 同步完成路径:直接返回值
        return _cachedConfig;
    }
    
    // 真正异步路径:从数据库读取
    _cachedConfig = await LoadConfigFromDbAsync();
    return _cachedConfig;
}

3. 注意事项

  • 一个 ValueTask 只能被 await 一次,多次 await 会产生未定义行为。
  • 仅在“同步完成路径占比高”的热点路径上使用,普通场景用 Task 即可。

十一、Task.Run 的正确使用

1. 正确场景:CPU 密集型任务

Task.Run 的作用是将CPU 密集型计算放到线程池线程中执行,避免阻塞主线程(如 UI 线程)。

// ✅ 正确:把耗 CPU 的计算交给线程池
async Task<long> CalculateSumAsync(int max)
{
    return await Task.Run(() =>
    {
        long sum = 0;
        for (int i = 0; i < max; i++)
            sum += i;
        return sum;
    });
}

2. 常见误区:包装 IO 异步方法

不要用 Task.Run 包裹一个已经是异步的 IO 操作,这是多此一举,还会白白占用一个线程池线程。

// ❌ 错误:没必要用 Task.Run 包一层异步方法
async Task<string> BadReadAsync(string path)
{
    return await Task.Run(async () => await File.ReadAllTextAsync(path));
}

// ✅ 正确:直接 await 异步方法
async Task<string> GoodReadAsync(string path)
{
    return await File.ReadAllTextAsync(path);
}

十二、异步死锁的成因与避免

1. 死锁产生条件

在带有同步上下文的环境(WinForms、WPF、旧版 ASP.NET)中,同时满足以下两点就会死锁:

  1. 调用方使用 .Result.Wait() 同步阻塞等待异步任务。
  2. 异步方法内部 await 后默认要回到原同步上下文继续执行。

结果:上下文被 .Result 阻塞住,await 结束后回不去,形成永久等待。

2. 死锁示例

// 此代码在 UI 线程中调用会发生死锁
public void DeadlockDemo()
{
    // 1. 阻塞 UI 同步上下文
    string result = GetDataAsync().Result; 
}

async Task<string> GetDataAsync()
{
    // 2. 等待结束后想回到 UI 上下文,但被阻塞了
    await Task.Delay(1000); 
    return "data";
}

3. 避免方案

  1. 全程异步:调用方也用 await,不混用 .Result / .Wait(),这是最根本的方案。
  2. 切断上下文:异步方法内部使用 ConfigureAwait(false),不回到原上下文。
// 方案1:全程 await
public async Task NoDeadlock1()
{
    string result = await GetDataAsync();
}

// 方案2:ConfigureAwait(false)
async Task<string> GetDataSafeAsync()
{
    await Task.Delay(1000).ConfigureAwait(false);
    return "data";
}

十三、异步流:IAsyncEnumerable

1. 作用

用于逐步产生数据的异步场景(如分页查询、流式读取、实时推送),可以一边生产一边消费,不用等所有数据生成完毕。

2. 使用示例

// 生产异步流:逐批返回数据
async IAsyncEnumerable<int> GenerateNumbersAsync(int count)
{
    for (int i = 0; i < count; i++)
    {
        await Task.Delay(500); // 模拟异步获取数据
        yield return i;
    }
}

// 消费异步流
async Task ConsumeStreamAsync()
{
    await foreach (var num in GenerateNumbersAsync(10))
    {
        Console.WriteLine($"收到数据:{num}");
    }
}

十四、取消令牌进阶

1. 自动超时取消

通过 CancellationTokenSource 的超时构造,实现“到期自动取消”。

async Task<string> RequestWithAutoTimeoutAsync()
{
    // 3 秒后自动触发取消
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
    try
    {
        return await LongTimeRequestAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        return "请求超时";
    }
}

2. 令牌联动:合并多个取消信号

使用 CreateLinkedTokenSource 将多个令牌合并,任意一个触发取消,最终令牌都会取消。

async Task DoWorkWithLinkedTokenAsync(CancellationToken userCancelToken)
{
    // 超时令牌 + 用户取消令牌 合并
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linkedCts = CancellationTokenSource
        .CreateLinkedTokenSource(timeoutCts.Token, userCancelToken);
    
    // 传入合并后的令牌
    await LongTimeWorkAsync(linkedCts.Token);
}
posted @ 2026-06-27 14:34  古月秋筠  阅读(21)  评论(0)    收藏  举报