在这里插入图片描述

“async/await 是 C# 的甜蜜陷阱,它看起来简单优雅,却常常被误用、滥用,甚至造成性能问题和逻辑混乱。”

前言

欢迎关注【dotnet研习社】,今天我们聊聊“ 为什么说 async/await是C#最被“滥用”的特性之一”。

自从 C# 5.0 引入 async/await 以来,异步编程变得前所未有的容易。开发者只需在方法签名上加上 async,在耗时操作前加上 await,就能写出类似同步的异步代码。但正因为如此,很多开发者对其“过度信任”,导致 async/await 在实际项目中被严重滥用

这篇文章我们就来“唱反调”,从工程实践的角度批判 async/await 被滥用的几种典型场景,并探讨其背后隐藏的设计陷阱。

二、滥用场景盘点

1. 不该异步的地方也加 async

很多人有个坏习惯:哪怕方法内没有真正的异步操作,也要强行写成 async Task

// 实际上是同步的
public async Task DoSomethingAsync()
{
    Console.WriteLine("No await here");
}

这种做法有如下副作用:

  • 增加了不必要的状态机开销;
  • 编译器警告“此 async 方法缺少 await”;
  • 容易让调用者以为这个方法包含 IO 操作,从而错误推断其性能影响;
  • 破坏了代码的意图表达。

2. 盲目使用 async void

public async void Button_Click(object sender, EventArgs e)
{
    await DoSomethingAsync(); // 可能会抛出异常却无法被捕获
}

async void 本质上是一种“失控”的异步调用:

  • 异常无法被捕获,容易导致程序崩溃;
  • 无法 await,无法组合任务;
  • 违反了良好的异常传播机制。

除了用于事件处理器,async void 应该被禁止使用!

3. 在循环中 await,导致串行执行

foreach (var item in items)
{
    await ProcessItemAsync(item); // 串行等待
}

很多人以为这样写是并发处理,其实这是串行等待每个任务执行完毕。如果你希望并发处理:

await Task.WhenAll(items.Select(item => ProcessItemAsync(item)));

滥用 await 让性能悄悄“滑坡”。

4. 把 async/await 用作“异步糖”,掩盖 IO 开销

错误示例:

public async Task<string> GetWeatherDataAsync()
{
    // 错误:每次调用都创建新的 HttpClient 实例
    using var client = new HttpClient();
    return await client.GetStringAsync("https://api.example.com/weather");
}

乍一看似乎没问题,但问题在于:

  • 每次调用都创建新的 HttpClient,导致端口耗尽;
  • 没有设置超时、取消机制,容易拖垮线程池;
  • 没有对异常进行明确处理。

正确实践:

重用 HttpClient 实例,或者使用 HttpClientFactory(推荐在 ASP.NET Core 中)。

// 最佳实践:将 HttpClient 作为单例或通过依赖注入管理
private static readonly HttpClient _httpClient = new HttpClient();

public async Task<string> GetWeatherDataAsync()
{
    return await _httpClient.GetStringAsync("https://api.example.com/weather");
}

// 在 ASP.NET Core 中,推荐使用 IHttpClientFactory
// public class MyService
// {
//     private readonly HttpClient _httpClient;
//
//     public MyService(HttpClient httpClient)
//     {
//         _httpClient = httpClient;
//     }
//
//     public async Task<string> GetSomeDataAsync()
//     {
//         return await _httpClient.GetStringAsync("https://api.example.com/data");
//     }
// }
  • HttpClient 内部管理着连接池,重用实例可以有效利用这些连接。
  • 在 ASP.NET Core 中,IHttpClientFactory 是管理 HttpClient 实例生命周期的推荐方式,它能处理连接池、DNS 更新等复杂问题。

async/await 并不等于高性能!它只是语法糖,隐藏不了底层的资源管理问题。

5. 在异步方法中阻塞调用 (.Result.Wait())

在异步方法中使用 .Result.Wait() 会强制异步操作同步完成,这在 UI 应用程序或 ASP.NET Core 应用中极易导致死锁。

错误示例:

public async Task LoadDataAsync()
{
    // 这是一个糟糕的实践,可能导致死锁
    var data = GetDataFromApiAsync().Result; 
    Console.WriteLine(data);
}

public async Task<string> GetDataFromApiAsync()
{
    await Task.Delay(1000); // 模拟耗时操作
    return "Hello, Async!";
}

正确实践:

始终使用 await 来等待异步操作的完成,确保异步链的完整性。

public async Task LoadDataAsync()
{
    // 正确的做法:使用 await 保持异步性
    var data = await GetDataFromApiAsync();
    Console.WriteLine(data);
}

public async Task<string> GetDataFromApiAsync()
{
    await Task.Delay(1000); // 模拟耗时操作
    return "Hello, Async!";
}
  • 在任何需要响应性的应用(如桌面应用、Web 应用)中,避免使用 .Result.Wait()
  • 如果你的方法是异步的,那么调用它的方法也应该尽可能地是异步的,形成一个“异步传染”链。

6. 声明 async 方法但未 await 任何操作

一个 async 方法如果没有 await 任何 Task,它将同步执行,并且 async 关键字带来的开销是无谓的。编译器不会报错,但代码并非真正异步。

错误示例:

public async Task DoSomethingAsync()
{
    // 错误:Task.Delay(1000) 返回一个 Task,但没有被 await,
    // 方法会立即返回,不会等待延迟。
    Task.Delay(1000);
    Console.WriteLine("Done?");
}

正确实践:

确保 async 方法中至少有一个 await 操作,或者如果不需要异步操作,则移除 async 关键字。

public async Task DoSomethingAsync()
{
    // 正确:await Task.Delay(1000) 确保方法会等待延迟完成
    await Task.Delay(1000);
    Console.WriteLine("Done!");
}

// 如果没有实际的异步操作,则无需 async
public int GetMeaningOfLife()
{
    return 42;
}
  • async 关键字的目的是允许你在方法中使用 await。如果不需要 await,请重新评估是否真的需要 async
  • 对于返回一个已完成 Task 的情况,可以使用 Task.FromResult<T>()Task.CompletedTask 来避免不必要的 async 开销。

7.从异步方法返回 void 而非 Task (事件处理器除外)

async void 方法主要用于事件处理器,因为它们无法被 await,也无法捕获其中抛出的异常。在其他场景下使用 async void 会导致难以调试的错误和应用程序崩溃。

错误示例:

public async void SaveDataToDatabaseAsync()
{
    // 错误:async void 方法无法被 await,异常也无法被外部捕获
    await Task.Delay(500); // 模拟保存数据
    throw new InvalidOperationException("Database error!");
}

// 调用方无法等待或捕获异常
public void CallSave()
{
    SaveDataToDatabaseAsync(); // “fire and forget”
    // 这里的 try-catch 无法捕获 SaveDataToDatabaseAsync 中的异常
    try
    {
        // ...
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Caught: {ex.Message}");
    }
}

正确实践:

除了事件处理器,所有异步方法都应该返回 TaskTask<TResult>

public async Task SaveDataToDatabaseAsync()
{
    // 正确:返回 Task,可以被 await,异常可以被捕获
    await Task.Delay(500); // 模拟保存数据
    throw new InvalidOperationException("Database error!");
}

// 调用方可以等待并捕获异常
public async Task CallSaveAsync()
{
    try
    {
        await SaveDataToDatabaseAsync();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Caught: {ex.Message}");
    }
}
  • async void 方法中抛出的异常会直接回到 SynchronizationContext,如果没有合适的处理,通常会导致应用程序崩溃。
  • 仅在事件处理器(如按钮点击事件)中使用 async void,因为事件签名通常是 void

三、为何会被滥用?

1. 语法太简单,误导开发者

async/await 的语法极度简洁,导致开发者误以为“只要加上它,就万无一失”,忽略了背后的状态机、线程切换和上下文恢复成本。

2. 缺乏异步思维模型

很多人把 async/await 当作“可选装饰器”,而不是设计思维的一部分,忽略了:

  • 线程池资源有限;
  • IO 操作本身需要控制;
  • 异步操作应该以任务调度为核心思路,而非逐行等待。

3. 滥用库设计者的“善意”

一些框架和库(如 ASP.NET Core、EF Core)对 async/await 有良好的支持,但这并不意味着你可以到处乱用。例如:EF Core 的异步查询在不恰当的场景下反而会变慢。

四、正确使用 async/await 的建议

  • ✅ 只在真正需要异步 IO 时使用 async/await;
  • ✅ 避免在同步方法中引入 async 包装器;
  • ✅ 拒绝 async void,使用 async Task 并链式组合;
  • ✅ 充分利用 Task.WhenAll、Task.WhenAny 实现并发;
  • ✅ 对 HttpClient、DbContext 等资源对象要有生命周期管理;
  • ✅ 理解 SynchronizationContext 的切换成本,可使用 ConfigureAwait(false) 优化后台处理。

五、结语:async/await 是一把“双刃剑”

async/await 极大地提升了 C# 异步编程的可读性和可维护性,但也因为“太方便”,成为了被滥用的重灾区。

如果你把 async/await 当成一种语法装饰,而不是设计思维的一部分,那它终将反噬你的系统稳定性和性能。

“工具本无罪,滥用才是罪。”

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐