文章目录

C# 异步编程:async/await 与 Task

发布于 2026-04-04 10:30:45 · 浏览 4 次 · 评论 0 条

C# 异步编程:async/await 与 Task

在 C# 中处理耗时操作(如网络请求、文件读写)时,若直接在主线程执行会导致程序卡死。使用 asyncawait 关键字配合 Task 类型,能让代码在等待期间释放线程,避免界面冻结或服务阻塞。以下是零基础也能上手的实操指南。


理解核心概念

  • Task:代表一个“将来会完成的操作”,比如下载网页内容。它不是线程,而是一个能跟踪异步操作状态的对象。
  • async:标记方法为异步方法。只有被 async 修饰的方法内部才能使用 await
  • await:暂停当前方法执行,直到等待的 Task 完成,但不会阻塞线程。完成后自动恢复执行后续代码。

注意:async void 仅用于事件处理器(如按钮点击),其他情况一律返回 TaskTask<T>


编写第一个异步方法

  1. 创建一个返回 Task 的异步方法,模拟耗时操作:
    public static async Task DelayOperationAsync(int milliseconds)
    {
        await Task.Delay(milliseconds); // 模拟等待
        Console.WriteLine($"延迟 {milliseconds} 毫秒完成");
       }
       ```
    
    2. **调用**该方法并等待结果:
       ```csharp
       public static async Task Main()
       {
           Console.WriteLine("开始");
           await DelayOperationAsync(2000); // 等待2秒
           Console.WriteLine("结束");
       }
       ```
       执行后输出:
       ```
       开始
       (等待2秒)
       延迟 2000 毫秒完成
       结束
       ```
    
    ---
    
    ## 获取异步操作的结果
    
    当异步操作需要返回数据时,使用 `Task<T>`:
    
    1. **定义**返回值的异步方法:
       ```csharp
       public static async Task<string> DownloadDataAsync(string url)
       {
           using var client = new HttpClient();
           string content = await client.GetStringAsync(url);
           return content.Substring(0, 50); // 返回前50个字符
       }
       ```
    
    2. **获取结果**并处理:
       ```csharp
       public static async Task Main()
       {
           string result = await DownloadDataAsync("https://example.com");
           Console.WriteLine($"下载内容: {result}");
    }

并行执行多个异步操作

若需同时启动多个任务(而非依次等待),不要逐个 await,而是先启动所有任务,再统一等待:

  1. 启动多个任务并收集 Task 对象:

    var task1 = DownloadDataAsync("https://site1.com");
    var task2 = DownloadDataAsync("https://site2.com");
    var task3 = DownloadDataAsync("https://site3.com");
  2. 使用 Task.WhenAll 等待全部完成

    string[] results = await Task.WhenAll(task1, task2, task3);
    Console.WriteLine($"共下载 {results.Length} 个站点");
       ```
    
    > 如果只需任意一个任务完成即可继续,改用 `Task.WhenAny`。
    
    ---
    
    ## 处理异步异常
    
    异步方法中的异常会被封装在 `Task` 中,**必须通过 `await` 或访问 `.Result` 才会抛出**:
    
    1. **在 `try/catch` 中包裹 `await`**:
       ```csharp
       public static async Task Main()
       {
           try
           {
               await DownloadDataAsync("https://invalid-url");
           }
           catch (HttpRequestException ex)
           {
               Console.WriteLine($"网络错误: {ex.Message}");
        }
    }
  3. 切勿忽略未等待的 Task
    若不 await 一个可能抛异常的 Task,异常会静默丢失。编译器会警告 CS4014,务必处理。


避免常见陷阱

问题 错误示例 正确做法
在构造函数中使用 async public MyClass() { await InitAsync(); } 改用工厂模式:<br>public static async Task<MyClass> CreateAsync()
使用 .Result.Wait() string s = DownloadDataAsync().Result; 始终用 await,避免死锁(尤其在 UI 线程)
忘记 async 导致同步执行 public void DoWork() { DownloadDataAsync(); } 方法签名改为 async Taskawait 调用

取消异步操作

长时间运行的任务应支持取消:

  1. 修改方法接收 CancellationToken

    public static async Task<string> DownloadWithCancelAsync(
        string url, 
        CancellationToken token)
    {
        using var client = new HttpClient();
        string content = await client.GetStringAsync(url, token);
        return content;
    }
  2. 传递取消令牌

    var cts = new CancellationTokenSource();
    cts.CancelAfter(3000); // 3秒后自动取消
    
    try
    {
        string result = await DownloadWithCancelAsync("https://slow-site.com", cts.Token);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("操作已取消");
    }

配置异步上下文

在 ASP.NET 或 UI 应用中,默认会尝试回到原始上下文(如 UI 线程)继续执行。若后续代码无需上下文,用 ConfigureAwait(false) 提升性能

public static async Task<string> GetDataAsync()
{
    // 库代码最佳实践:避免不必要的上下文切换
    string json = await File.ReadAllTextAsync("data.json").ConfigureAwait(false);
    return Process(json);
}

应用层代码(如 MVC Controller、WPF 事件)通常不需要 ConfigureAwait,因为需要回到原始上下文更新 UI 或响应请求。


调试异步代码

  1. 启用 .NET 异步调试支持
    在 Visual Studio 中,勾选 工具 > 选项 > 调试 > 常规 > 启用 .NET Framework 源代码步进(实际名称可能略有不同,关键是开启异步调试)。

  2. 查看并行堆栈
    调试时打开 调试 > 窗口 > 并行堆栈,可直观看到多个异步任务的调用关系。

  3. 命名任务便于识别
    虽然不能直接给 Task 命名,但可通过日志关联:

    public static async Task ProcessOrderAsync(int orderId)
    {
        Console.WriteLine($"[订单 {orderId}] 开始处理");
           await Task.Delay(1000);
           Console.WriteLine($"[订单 {orderId}] 处理完成");
    }

性能监控要点

  1. 避免过度并行
    同时启动数千个 HttpClient 请求会耗尽系统资源。使用 SemaphoreSlim 限制并发数

    private static readonly SemaphoreSlim semaphore = new(10, 10); // 最大10并发
    
    public static async Task<string> LimitedDownloadAsync(string url)
    {
        await semaphore.WaitAsync();
        try
        {
            using var client = new HttpClient();
            return await client.GetStringAsync(url);
        }
        finally
        {
            semaphore.Release();
        }
    }
  2. 复用 HttpClient
    不要在每次请求时创建新 HttpClient 实例,应使用单例或 IHttpClientFactory


单元测试异步方法

  1. 测试方法标记为 async Task

    [Fact]
    public async Task DownloadDataAsync_ReturnsContent()
    {
        // Arrange
        var service = new DataService();
    
        // Act
        string result = await service.DownloadDataAsync("https://example.com");
    
        // Assert
        Assert.NotNull(result);
        Assert.Contains("Example", result);
    }
  2. 模拟依赖
    使用 Moq 等框架模拟 HttpClient 或其他异步依赖,避免真实网络调用。


何时不用异步

  • CPU 密集型操作(如循环计算):
    异步无法加速计算本身。若需不阻塞 UI,改用 Task.Run
    public async Task<double> CalculateAsync(double input)
    {
        return await Task.Run(() => HeavyCalculation(input));
    }
  • 极短操作(如内存赋值):
    异步开销可能超过收益,保持同步更高效。

迁移同步代码到异步

  1. 自底向上修改
    从最底层的 I/O 操作(如文件、数据库)开始替换为异步版本。

  2. 逐层添加 async/await
    上层方法调用异步方法后,自身也需改为 asyncawait 结果。

  3. 入口点适配
    控制台应用的 Main 方法可直接标记为 async Task;旧版框架可能需在 Main 中调用 .GetAwaiter().GetResult()(仅限控制台)。


高级场景:自定义异步操作

若需包装非标准异步操作(如回调 API),使用 TaskCompletionSource<T>

public static Task<string> WaitForEventAsync(EventPublisher publisher)
{
    var tcs = new TaskCompletionSource<string>();

    void OnEvent(object sender, EventArgs e)
    {
        tcs.SetResult("事件触发");
        publisher.Event -= OnEvent; // 避免内存泄漏
    }

    publisher.Event += OnEvent;
    return tcs.Task;
}

调用时:

string result = await WaitForEventAsync(publisher);

异步流:处理持续数据

对于持续生成的数据(如传感器读数),使用 IAsyncEnumerable<T>(C# 8.0+):

  1. 定义异步迭代器

    public static async IAsyncEnumerable<int> GenerateNumbersAsync(int count)
    {
        for (int i = 0; i < count; i++)
        {
            await Task.Delay(100); // 模拟延迟
            yield return i;
        }
    }
  2. 消费异步流

    await foreach (int number in GenerateNumbersAsync(5))
    {
        Console.WriteLine(number);
    }

异步与线程的关系

  • await 不等于新线程
    大多数 I/O 操作(如网络、磁盘)依靠操作系统异步机制,无需额外线程await 只是注册回调,线程在等待期间被释放。
  • CPU 绑定操作才需线程
    如前述,用 Task.Run 将计算移到线程池。

内存与资源管理

  1. 及时释放资源
    在异步方法中使用 using 声明(C# 8.0+ 支持异步 using):

    await using var stream = File.OpenRead("data.bin");
  2. 避免闭包捕获
    在循环中启动异步任务时,将循环变量复制到局部变量

    for (int i = 0; i < 10; i++)
    {
        int localI = i; // 关键!
        tasks.Add(ProcessAsync(localI));
    }

版本兼容性

  • .NET Framework 4.5+:完整支持 async/await
  • 旧项目升级
    若目标框架低于 4.5,可通过 NuGet 安装 Microsoft.Bcl.Async 包(仅限 .NET 4.0)。

诊断异步死锁

典型死锁场景:
在 UI 线程调用 .Result,而异步方法试图回到 UI 线程继续执行。

解决方法

  • 应用层:始终用 await,绝不 .Result
  • 库代码:在非 UI 层使用 ConfigureAwait(false)

异步编程的黄金法则

只要有一个方法是异步的,从它到入口点的所有调用链都应该是异步的。不要混合同步等待(如 .Result)和异步代码,这是绝大多数问题的根源。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文