08. 异步编程最佳实践与反模式:那些坑过无数人的写法
本章 GitHub 仓库:csharp-concurrency-cookbook ⭐
欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。
本章导读
本文目标:掌握异步编程的 Do’s and Don’ts,识别并避免生产代码中的常见陷阱,写出健壮、高效、可维护的异步代码。
说实话,async/await 的语法已经够简单了——async 修饰方法,await 等待结果,这有什么难的?
但这只是表面。
真正的坑在于,你以为你在写”正确的”异步代码,但其实已经埋下了定时炸弹。这些炸弹平时不响,偶尔在生产环境 QPS 一上去,或者用户操作稍快一点,就砰的一声——进程崩溃、界面卡死、数据丢失,然后你对着一行看起来无懈可击的代码发呆三个小时。
今天,我们来集中消灭这些隐患。
️ 前置建议:本章内容是前面几章的综合应用,建议先掌握 Task、async/await、SynchronizationContext 和 CancellationToken 的基础(第 03-06 章)。
0️⃣ 开胃菜:一个”看起来没问题”的代码
看看这段代码,你能找出几个问题?
public class OrderService
{
public async void ProcessOrder(int orderId)
{
var order = FetchOrderAsync(orderId).Result;
await SaveOrderAsync(order);
Console.WriteLine("订单处理完成");
}
private async Task<Order> FetchOrderAsync(int id)
{
await Task.Delay(100);
return new Order { Id = id };
}
private async Task SaveOrderAsync(Order order) => await Task.Delay(50);
}
答案揭晓:
async void→ 异常无法被外部捕获,调用方也无法await它.Result阻塞 → 在有 SynchronizationContext 的环境(WinForms/WPF/ASP.NET Classic)必然死锁- 混合同步阻塞和异步 → 典型的”异步转同步”反模式
这三个问题,我们一个个来拆解。
反模式篇:这些坑,你得知道
1. async void —— 异步代码里的”哑炮”
1.1 它到底有什么问题?
来看一个简单的例子:
// 千万别这么写
public async void SendNotificationAsync()
{
await Task.Delay(100);
throw new Exception("通知发送失败!");
}
// 调用方
public void SomeMethod()
{
try
{
SendNotificationAsync(); // 这里不会抛异常
Console.WriteLine("调用成功?");
}
catch (Exception ex)
{
// 永远执行不到这里!!!
Console.WriteLine($"捕获到了:{ex.Message}");
}
}
运行结果:SomeMethod 执行完毕,甚至输出了”调用成功?”,然后……某个时刻程序崩溃了,或者在 .NET 的 UnhandledException 事件里出现了这个异常。
为什么? async void 方法在 await 之后如果抛出异常,这个异常会被扔到当前的 SynchronizationContext 上——在控制台程序里就是直接崩进程,在 WinForms/WPF 里会触发 Application.ThreadException。调用方的 try-catch 根本就包不住它。
还有一个问题:你无法 await 一个 async void 方法。调用后方法立即返回(一个 void),你不知道它什么时候完成,也无法等它完成再继续。
// async void 无法 await
SendNotificationAsync(); // 就是普通调用,没法知道何时完成
// 返回 Task 就可以 await 了
await SendNotificationTaskAsync();
1.2 async void 唯一的合法用途
你可能要问:”那 async void 有没有可以用的场景?”
有,而且只有一个:UI 事件处理器。
// 这是合理的!事件处理器的签名就是 void,没得选
private async void Button_Click(object sender, EventArgs e)
{
button.Enabled = false;
try
{
await DoSomeLongWorkAsync();
label.Text = "完成!";
}
catch (Exception ex)
{
// 在 async void 里,try-catch 是必须的!
MessageBox.Show($"出错了:{ex.Message}");
}
finally
{
button.Enabled = true;
}
}
记住这个规则:如果你写了一个 async void 方法,而它不是事件处理器,90% 的概率你写错了。立刻把返回值改成 Task。
1.3 正确做法:始终返回 Task
// 正确:返回 Task,调用方可以 await,异常可以正常传播
public async Task SendNotificationAsync()
{
await Task.Delay(100);
throw new Exception("通知发送失败!");
}
// 调用方可以正常捕获异常
try
{
await SendNotificationAsync();
}
catch (Exception ex)
{
Console.WriteLine($" 捕获到了:{ex.Message}"); // 正常工作!
}
2. 异步转同步(Async over Sync)—— 死锁的温床
2.1 经典死锁场景
这是最臭名昭著的反模式,每年不知道坑了多少开发者。
// 在同步方法中用 .Result 或 .Wait() 阻塞异步方法
public string GetUserName(int id)
{
return GetUserNameAsync(id).Result; // 死锁!
}
private async Task<string> GetUserNameAsync(int id)
{
// 注意:这里没有 ConfigureAwait(false)
await Task.Delay(100);
return $"用户_{id}";
}
死锁的死亡循环(在 WinForms/WPF/ASP.NET Classic 中):
1. UI 线程调用 GetUserName()
2. GetUserName() 调用 .Result → UI 线程被阻塞
3. GetUserNameAsync() 的 await 完成了
4. 它需要回到 UI 线程继续执行(因为没有 ConfigureAwait(false))
5. UI 线程正在被 .Result 阻塞,永远释放不了
6. 死锁!双方都在等对方
这就像两个人同时卡门——A 说”你先进”,B 说”你先进”,然后两个人就这么站着,谁也进不去。
2.2 .Result、.Wait()、GetAwaiter().GetResult() 有什么区别?
很多人以为 GetAwaiter().GetResult() 是”安全的同步调用”,其实本质上没有区别,都会阻塞当前线程,都有死锁风险。
唯一的区别是异常处理方式:
| 写法 | 异常包装 | 死锁风险 |
|---|---|---|
.Result |
包装成 AggregateException |
有 |
.Wait() |
包装成 AggregateException |
有 |
.GetAwaiter().GetResult() |
不包装,直接抛原始异常 | 有 |
所以如果你确实需要同步调用(迫不得已),GetAwaiter().GetResult() 在异常处理上更友好,但死锁风险一样存在。
关于SynchronizationContext以及死锁原理的详细分析,请参考第 05 章。
2.3 如何破局?
方案一(最佳):一路 async 到底
// 把调用链上所有方法都改成 async
public async Task<string> GetUserNameAsync(int id)
{
return await FetchUserFromDbAsync(id);
}
方案二(不得已时):Task.Run + 确保 ConfigureAwait(false)
// 降低死锁风险,但仍然会阻塞线程
public string GetUserName(int id)
{
return Task.Run(() => GetUserNameAsync(id)).GetAwaiter().GetResult();
// Task.Run 在线程池线程上执行,没有 SynchronizationContext,死锁风险大大降低
}
关键原则:如果你的代码里有
.Result或.Wait(),请认真想想能不能把调用方也改成async。大多数情况下答案是”可以”。
3. Fire-and-Forget 的正确姿势
3.1 什么是 Fire-and-Forget?
“点火就忘”——调用一个异步方法后不等待它完成,让它在后台自己跑。典型场景:发送通知邮件、记录日志、触发清理任务等。
// 你想要的效果:触发后台任务,主流程不等待
SendWelcomeEmailAsync(user);
return Ok("注册成功!"); // 立刻返回响应,邮件后台发送
听起来很简单?但这里有好几个版本,效果天差地别。
3.2 最差的版本:直接忽略 Task
// 编译器会给你一个 CS4014 警告
SendWelcomeEmailAsync(user); // 没有 await,没有赋值
这样写的问题:异常被完全吞掉。邮件发没发出去?失败了吗?你完全不知道,日志里也没有任何痕迹。这就像你交了一个任务给实习生,然后再也不问,永远不知道他有没有做、做没做好。
3.3 略好但仍然危险:async void 版本
// 用 async void 包一层
public async void SendEmailFireAndForget(User user)
{
await SendWelcomeEmailAsync(user);
}
如前所述,async void 的异常会在不可预测的地方爆炸。别这样。
3.4 可接受的版本:显式处理异常
// 用 _ 丢弃,表示"我知道我在忽略这个 Task"
_ = SendWelcomeEmailAsync(user);
// 更好的做法:用 ContinueWith 捕获异常
SendWelcomeEmailAsync(user).ContinueWith(t =>
{
if (t.IsFaulted)
logger.LogError(t.Exception, "发送欢迎邮件失败");
}, TaskContinuationOptions.OnlyOnFaulted);
3.5 推荐:封装成扩展方法(委托版)
如果你的项目里 Fire-and-Forget 比较常用,可以封装一个扩展方法。但有一个设计要点值得注意:出错后怎么处理,扩展方法自己不该管,应该交给调用方决定。
public static class TaskExtensions
{
// 重载 1:同步回调——出错时执行 Action<Exception>
public static void FireAndForget(this Task task, Action<Exception>? onError = null)
{
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
var ex = t.Exception!.GetBaseException();
onError?.Invoke(ex);
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
// 重载 2:异步回调——出错时可以 await 异步操作(写数据库、发告警接口等)
public static void FireAndForget(this Task task, Func<Exception, Task> onError)
{
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
var ex = t.Exception!.GetBaseException();
_ = onError(ex); // 异步回调本身也是 Fire-and-Forget,由调用方保证健壮性
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
}
调用方可以按自己的需要传入任何逻辑:
// 同步:简单记日志
SendWelcomeEmailAsync(user).FireAndForget(
ex => logger.LogError(ex, "发送欢迎邮件失败"));
// 同步:记日志 + 发告警
SendWelcomeEmailAsync(user).FireAndForget(ex =>
{
logger.LogError(ex, "发送邮件失败");
alertService.Notify($"邮件服务异常: {ex.Message}");
});
// 异步:出错后做异步操作,比如写故障记录到数据库
SendWelcomeEmailAsync(user).FireAndForget(async ex =>
{
await failureRepo.RecordAsync(new Failure { Error = ex.Message });
logger.LogError(ex, "邮件发送失败,已记录到故障表");
});
// 什么都不传:静默忽略(不推荐,除非你真的不关心结果)
SendWelcomeEmailAsync(user).FireAndForget();
这样既不阻塞,又不会丢失异常信息,扩展方法本身也不依赖任何具体框架——只关心”怎么触发回调”,不关心”回调里做什么”,职责清晰。
4. Task.Run 的滥用:线程池不是免费的
4.1 常见误用:给 I/O 操作套 Task.Run
// 完全没有必要!浪费线程池资源
public async Task<string> ReadFileAsync(string path)
{
return await Task.Run(async () =>
{
return await File.ReadAllTextAsync(path); // I/O 操作本身就是异步的!
});
}
// 直接 await 就好,不需要 Task.Run
public async Task<string> ReadFileAsync(string path)
{
return await File.ReadAllTextAsync(path);
}
道理很简单:File.ReadAllTextAsync 这类 I/O 异步方法在等待期间不占用线程(真正的异步 I/O,由操作系统内核完成),再套一层 Task.Run 反而会额外占用一个线程池线程干等着,完全是反向优化。
4.2 Task.Run 的正确舞台:CPU 密集型任务
// 正确!CPU 密集型计算,防止阻塞 UI 线程
private async void CalculateButton_Click(object sender, EventArgs e)
{
button.Enabled = false;
// 把 CPU 密集型工作移到线程池,UI 线程保持响应
var result = await Task.Run(() => HeavyCalculation(inputData));
resultLabel.Text = result.ToString();
button.Enabled = true;
}
private long HeavyCalculation(int[] data)
{
// 纯 CPU 运算,没有任何 I/O
return data.AsParallel().Sum(x => (long)x * x);
}
选择标准简洁版:
| 操作类型 | 推荐做法 |
|---|---|
| I/O 密集(网络、磁盘、数据库) | 直接 await,无需 Task.Run |
| CPU 密集(计算、图像处理、加密) | await Task.Run(...) |
| 混合型 | 拆分,I/O 部分直接 await,CPU 部分用 Task.Run |
5. async using:别让资源跑路
5.1 await 和 using 的微妙问题
看这段代码,问题在哪儿?
// 资源已经被释放,但 Task 还没执行完
public Task<string> GetDataBad()
{
using var connection = new DbConnection();
return connection.QueryAsync("SELECT * FROM Users"); // connection 在 using 结束时就释放了!
// 但 QueryAsync 返回的 Task 可能还没开始执行...
}
这里 using 会在 GetDataBad() 返回时立即释放 connection,但返回的 Task 里面的代码可能还没执行到!这就是传说中的“先拆桥后过河”。
// 正确:用 await using,在 await 完成前不释放资源
public async Task<string> GetDataGood()
{
await using var connection = new DbConnection();
return await connection.QueryAsync("SELECT * FROM Users");
// await 确保 QueryAsync 完成后,再离开 using 块,再释放 connection
}
5.2 await using 的语法(.NET Core 3.0+)
await using 是 C# 8.0 引入的语法,用于异步释放实现了 IAsyncDisposable 接口的对象:
// 旧写法(.NET Core 3.0 之前)
var resource = new AsyncResource();
try
{
await resource.DoWorkAsync();
}
finally
{
await resource.DisposeAsync(); // 手动写,很烦,容易漏
}
// 新写法(推荐)
await using var resource = new AsyncResource();
await resource.DoWorkAsync();
// 离开作用域时自动调用 DisposeAsync(),优雅、安全
5.3 别在 using 里”返回 Task 而不 await”
这是一个很隐蔽的陷阱,很多人栽在这里:
// 非常危险!
public Task<string> DangerousMethod()
{
using var resource = new HeavyResource();
return resource.FetchAsync(); // resource 立即被释放!Task 还没执行!
}
// 正确:必须 await
public async Task<string> SafeMethod()
{
await using var resource = new HeavyResource();
return await resource.FetchAsync(); // 等 FetchAsync 完成,resource 才被释放
}
记住口诀:有 using 就有 await,有 await 才安全。
最佳实践篇:这样写,同事会竖大拇指
6. 命名规范:Async 后缀不是摆设
6.1 规范
这条规范简单到不能再简单,但落地却经常出问题:
// 正确:异步方法统一加 Async 后缀
public Task<User> GetUserAsync(int id) { ... }
public Task SaveAsync(User user) { ... }
public Task<List<Order>> GetOrdersAsync(int userId) { ... }
// 错误:异步方法没有 Async 后缀
public Task<User> GetUser(int id) { ... } // 调用方看到 GetUser(),会以为是同步的
// 更糟:同步方法加了 Async 后缀
public User GetUserAsync(int id) { ... } // 骗人!这根本不是异步方法!
为什么这个规范重要? 因为当调用方看到 GetUser(id) 时,他不会 await;看到 GetUserAsync(id) 时,他会想到”哦,要 await 一下”。后缀是一种约定俗成的信号,让代码更自文档化。
6.2 接口也要遵守
public interface IUserRepository
{
Task<User?> GetUserAsync(int id); //
Task<List<User>> GetAllUsersAsync(); //
Task<User> CreateUserAsync(CreateUserDto dto); //
Task DeleteUserAsync(int id); //
}
6.3 CancellationToken 的位置
公共 API 的异步方法,养成提供 CancellationToken 参数的好习惯:
// 标准签名:CancellationToken 放最后,给默认值 default
public Task<User?> GetUserAsync(int id, CancellationToken cancellationToken = default);
// 调用方可以选择性传入
await repo.GetUserAsync(1); // 不传,使用 default
await repo.GetUserAsync(1, cts.Token); // 传入 token,支持取消
7. ConfigureAwait(false):库代码的铁律
这个知识点在第 05 章详细讲过,这里做一个实战提炼。
7.1 一句话规则
- 你在写类库/NuGet 包:每一个
await后面都加ConfigureAwait(false) - 你在写应用程序代码:根据需要决定,不是强制的
// 类库代码(比如你发布的 NuGet 包)
public async Task<string> FetchDataAsync(string url)
{
using var client = new HttpClient();
var response = await client.GetAsync(url).ConfigureAwait(false);
// ↑ 加!防止调用方死锁
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
// ↑ 加!每一层都要加
return content;
}
7.2 为什么应用代码可以不加?
在 ASP.NET Core 中,没有 SynchronizationContext,加不加效果一样。
在 WinForms/WPF 中,如果你需要在 await 后访问 UI 控件,不能加 ConfigureAwait(false),因为你需要回到 UI 线程:
// WPF 中的正确写法
private async void LoadData_Click(object sender, EventArgs e)
{
var data = await FetchDataAsync(); // 不加 ConfigureAwait(false),保留 UI 上下文
dataGrid.ItemsSource = data; // ← 需要在 UI 线程执行
}
8. ValueTask:别乱用,但也别不用
8.1 什么时候用 ValueTask?
ValueTask 是 Task 的轻量级版本(结构体),在特定场景下能减少内存分配。但它有限制,用错了反而更差。
黄金使用场景:方法可能同步完成的情况
private readonly Dictionary<int, User> _cache = new();
// 经典场景:缓存查询
// 缓存命中时同步返回,没有 Task 对象分配;缓存未命中时走异步路径
public ValueTask<User?> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var cached))
{
return ValueTask.FromResult<User?>(cached); // 同步路径,零分配!
}
return new ValueTask<User?>(FetchFromDbAsync(id)); // 异步路径
}
private async Task<User?> FetchFromDbAsync(int id)
{
await Task.Delay(100).ConfigureAwait(false); // 模拟数据库查询
var user = new User { Id = id, Name = $"用户_{id}" };
_cache[id] = user;
return user;
}
8.2 ValueTask 的使用禁忌
// 不能多次 await 同一个 ValueTask
var vt = GetUserAsync(1);
var user1 = await vt; // 第一次 await,OK
var user2 = await vt; // 第二次 await,未定义行为!可能抛异常
// 不能把 ValueTask 存起来,稍后再 await
ValueTask<User?> stored = GetUserAsync(1);
await DoSomethingElseAsync();
var user = await stored; // 存储后延迟 await 可能有问题
// 如果需要多次使用结果,先转换成 Task
Task<User?> task = GetUserAsync(1).AsTask();
var user1 = await task; // Task 可以多次 await
var user2 = await task; // 没问题
8.3 选择 Task 还是 ValueTask?
| 场景 | 推荐 |
|---|---|
| 方法几乎总是异步的(I/O、网络请求) | Task<T> |
| 方法经常同步完成(缓存、快路径优化) | ValueTask<T> |
| 高频调用,分配成本敏感 | ValueTask<T> |
| 需要存储任务引用、多次 await | Task<T> |
| 不确定时 | Task<T>(更简单,更安全) |
记住一句话:ValueTask 是优化工具,不是银弹,不确定就用 Task。
9. 异步代码中的同步上下文:迫不得已时怎么办?
有时候你真的没办法,必须在同步代码中调用异步方法——比如你在实现一个老的同步接口,或者在构造函数里初始化。
9.1 如果真的要同步调用
// 前提:确保被调用的异步代码全程 ConfigureAwait(false)
// 方案 1(有死锁风险,但有时没得选)
string result = GetDataAsync().GetAwaiter().GetResult();
// 方案 2(较安全,把异步代码移到没有 SynchronizationContext 的线程池)
string result = Task.Run(() => GetDataAsync()).GetAwaiter().GetResult();
9.2 更优雅:异步工厂方法
C# 不支持异步构造函数,但你可以用工厂方法绕过:
// 这样不行(构造函数不能是异步的)
public class DataService
{
public DataService()
{
_config = LoadConfigAsync().Result; // 死锁风险
}
}
// 工厂方法模式
public class DataService
{
private readonly string _config;
private DataService(string config)
{
_config = config; // 私有构造函数,只被工厂方法调用
}
// 对外暴露的创建方法,是异步的
public static async Task<DataService> CreateAsync(CancellationToken ct = default)
{
var config = await LoadConfigAsync(ct).ConfigureAwait(false);
return new DataService(config);
}
private static async Task<string> LoadConfigAsync(CancellationToken ct)
{
await Task.Delay(100, ct).ConfigureAwait(false);
return "配置信息";
}
}
// 使用
var service = await DataService.CreateAsync();
本章速查表:异步编程 Do’s and Don’ts
| 场景 | 反模式 | 正确做法 |
|---|---|---|
| 后台方法 | async void Method() |
async Task Method() |
| 事件处理器 | — | async void + 内部 try-catch |
| 同步调用异步 | task.Result / task.Wait() |
改为 async,一路 await |
| 确实要同步调用 | .Result 直接用 |
Task.Run(...).GetAwaiter().GetResult() |
| I/O 操作 | await Task.Run(() => await ReadAsync()) |
直接 await ReadAsync() |
| CPU 密集操作 | await CpuWork() 阻塞 UI |
await Task.Run(() => CpuWork()) |
| Fire-and-Forget | 直接忽略 Task | task.FireAndForget(ex => logger.LogError(ex, "...")) |
| 异步释放资源 | using var res = ... |
await using var res = ... |
| 类库代码 await | await someTask |
await someTask.ConfigureAwait(false) |
| 方法命名 | GetUser() 返回 Task |
GetUserAsync() 加后缀 |
| 缓存场景返回值 | Task<T> 每次分配 |
ValueTask<T> 同步路径零分配 |
| 异步初始化 | 构造函数 .Result |
静态异步工厂方法 CreateAsync() |
️ 实战演练
练习 1:修复这段”烂代码”
下面的代码有多个问题,找出来并修复:
public class ReportService
{
private DbContext _db;
public ReportService()
{
_db = CreateDbContextAsync().Result; // 问题1
}
public async void GenerateReport(int reportId) // 问题2
{
var data = GetReportDataAsync(reportId).Result; // 问题3
using var writer = new AsyncReportWriter(); // 问题4
await writer.WriteAsync(data);
Task.Run(async () => await SendEmailAsync(data)); // 问题5
}
}
问题清单:
- 构造函数中
.Result→ 死锁风险 async void→ 异常无法被捕获.Result在异步方法内部 → 完全没必要,直接await- 同步
using用于IAsyncDisposable→ 应该await using Task.Run(...)的结果没有处理 → 应该用FireAndForget扩展方法或await
修复后:
public class ReportService
{
private readonly DbContext _db;
// 私有构造函数 + 工厂方法
private ReportService(DbContext db) { _db = db; }
public static async Task<ReportService> CreateAsync()
{
var db = await CreateDbContextAsync().ConfigureAwait(false);
return new ReportService(db);
}
// 返回 Task,而不是 async void
public async Task GenerateReportAsync(int reportId)
{
// 直接 await,不用 .Result
var data = await GetReportDataAsync(reportId).ConfigureAwait(false);
// await using,正确异步释放
await using var writer = new AsyncReportWriter();
await writer.WriteAsync(data).ConfigureAwait(false);
// Fire-and-Forget 用扩展方法,错误处理逻辑由调用方决定
SendEmailAsync(data).FireAndForget(ex => logger.LogError(ex, "发送报告邮件失败"));
}
}
本章总结
今天我们系统整理了异步编程中最容易踩的坑和最值得记住的规范:
反模式(别这么写!):
async void除事件处理器外禁用.Result/.Wait()是死锁制造机- I/O 操作不要套
Task.Run - Fire-and-Forget 不能忽略异常
- 异步资源用同步
using
最佳实践(就这么写!):
- 异步方法加
Async后缀 - 类库代码每个
await加ConfigureAwait(false) - 缓存/快路径场景考虑
ValueTask - 异步初始化用工厂方法
- CancellationToken 成为公共 API 的标配参数
核心心法:async all the way——一旦你的代码链上有了异步,就让它从头异步到尾,不要在中途”转同步”,那是在逆流而上。
下一章:异步代码中的内存泄漏——
CancellationTokenSource忘记Dispose、事件订阅没有取消、Channel 没有 Complete……这些看起来无害的遗漏,是如何让你的服务在运行几天后内存爆炸的。
参考资源
- Microsoft Docs – Async/Await Best Practices
- Stephen Toub – ConfigureAwait FAQ
- David Fowler – Async Guidance
文章摘自:https://www.cnblogs.com/diamondhusky/p/20136270
