【.NET并发编程 – 08】异步编程最佳实践与反模式:那些坑过无数人的写法

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);
}

答案揭晓:

  1. async void → 异常无法被外部捕获,调用方也无法 await
  2. .Result 阻塞 → 在有 SynchronizationContext 的环境(WinForms/WPF/ASP.NET Classic)必然死锁
  3. 混合同步阻塞和异步 → 典型的”异步转同步”反模式

这三个问题,我们一个个来拆解。


反模式篇:这些坑,你得知道

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?

ValueTaskTask 的轻量级版本(结构体),在特定场景下能减少内存分配。但它有限制,用错了反而更差。

黄金使用场景:方法可能同步完成的情况

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
	}
}

问题清单

  1. 构造函数中 .Result → 死锁风险
  2. async void → 异常无法被捕获
  3. .Result 在异步方法内部 → 完全没必要,直接 await
  4. 同步 using 用于 IAsyncDisposable → 应该 await using
  5. 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 后缀
  • 类库代码每个 awaitConfigureAwait(false)
  • 缓存/快路径场景考虑 ValueTask
  • 异步初始化用工厂方法
  • CancellationToken 成为公共 API 的标配参数

核心心法async all the way——一旦你的代码链上有了异步,就让它从头异步到尾,不要在中途”转同步”,那是在逆流而上。

下一章:异步代码中的内存泄漏——CancellationTokenSource 忘记 Dispose、事件订阅没有取消、Channel 没有 Complete……这些看起来无害的遗漏,是如何让你的服务在运行几天后内存爆炸的。


参考资源

文章摘自:https://www.cnblogs.com/diamondhusky/p/20136270