一个函数是否应该设计成在协程中运行的判断标准是什么?
这是一个比看起来深得多的设计问题。很多人凭感觉决定,实际上有清晰的判断框架。
核心判断标准:这个函数会不会”等待”某个外部事件?
函数执行期间,CPU 是否在做有用的工作? 是 → 不需要协程(CPU 密集型,用线程并行) 否 → CPU 在空转等待外部响应 → 这就是协程的适用场合
“外部事件”具体是指:网络响应、磁盘 I/O、数据库查询、定时器、进程间通信、硬件信号——任何需要等待进程外部的东西。
决策树
这个函数要做什么?
│
├── 纯计算(排序、加密、图像处理、物理模拟)
│ └── ❌ 不要 async
│ 原因:协程不能让 CPU 跑得更快,
│ async 开销反而是净损耗
│ 如果耗时 > 几十毫秒:
│ └── 用线程池执行,然后 await 那个线程
│
├── I/O 操作(网络、磁盘、数据库)
│ └── ✅ 必须 async
│ 原因:等待期间线程什么都不做,
│ 释放线程让别人用是纯收益
│
├── 等待时间/信号(sleep、timer、条件变量)
│ └── ✅ 必须 async
│ 原因:同上,等待期间 CPU 完全空闲
│
├── 调用了其他 async 函数
│ └── ✅ 必须 async(传染性决定的)
│ 唯一例外:用 .GetAwaiter().GetResult() 同步等待,
│ 但这会阻塞线程,几乎总是错的
│
└── 纯内存操作(读写缓存、数据转换、状态查询)
└── ❌ 不要 async
原因:纳秒级操作,async 状态机开销比操作本身还大
三条具体原则
原则一:以”是否跨进程边界”为界
进程内部(内存速度): 读写变量、操作集合、调用本地函数 → 不需要 async,几纳秒到几微秒,协程开销不值得 跨进程边界(I/O 速度): 数据库查询、HTTP 请求、读文件、消息队列 → 必须 async,毫秒级等待,线程释放价值极高
这条原则在绝大多数情况下给出正确答案。
原则二:区分”并发”和”并行”
协程解决并发(同时等多件事),不解决并行(同时算多件事)。
csharp
// 错误用法:CPU 密集型任务包上 async 没有意义
async Task<int> WrongAsync(int[] data) {
// 这里根本没有 await,async 是假的
// 编译器会警告,而且完全没有性能收益
return data.Sum();
}
// CPU 密集型的正确做法:扔到线程池,然后 await
async Task<int> CpuBoundCorrect(int[] data) {
return await Task.Run(() => HeavyComputation(data));
// 这里 await 等的是"线程池里的一个线程完成计算"
// 当前线程在这期间可以做别的事
}
// I/O 的正确做法:直接 async
async Task<string> IoCorrect(string url) {
return await httpClient.GetStringAsync(url);
// 等待期间不占用任何线程
}
两者的本质差异:
- CPU 任务:等待期间 CPU 在别处忙着算,总 CPU 利用率没变
- I/O 任务:等待期间 CPU 完全空闲,释放线程是纯收益
原则三:函数耗时决定 async 的经济性
协程状态机本身有开销(约 100ns 级别,包括堆分配、状态机跳转、调度器介入)。
函数实际执行时间 async 是否值得? < 1 μs ❌ 绝对不要,开销比工作本身大 1 μs ~ 1 ms ⚠️ 取决于并发量,通常不值得 > 1 ms(含 I/O) ✅ 必须,线程释放价值远超开销
几个容易误判的场景
场景一:很快完成的 I/O
csharp
// Redis 本机查询耗时约 0.1ms,要 async 吗?
async Task<string> GetFromLocalRedis(string key) {
return await redis.GetAsync(key);
}
答案:要。 虽然 0.1ms 很短,但如果你有 10 万并发请求同时查 Redis,同步版本需要 10 万个线程(800GB 内存),async 版本只需要几十个线程。判断标准不是单次耗时,而是并发规模下的总线程占用。
场景二:有时同步有时异步
csharp
// 先查内存缓存(纳秒),没有再查数据库(毫秒)
async Task<User> GetUser(int id) {
if (cache.TryGet(id, out var user))
return user; // 同步路径,async 开销白付了
return await db.QueryAsync<User>(id); // 异步路径
}
这种情况在 C# 里用 ValueTask 优化——同步完成时不产生堆分配:
csharp
ValueTask<User> GetUser(int id) {
if (cache.TryGet(id, out var user))
return new ValueTask<User>(user); // 零分配
return new ValueTask<User>(db.QueryAsync<User>(id));
}
Rust 的 async fn 也有类似的静态分派优化。
场景三:纯 CPU 任务但耗时极长
csharp
// 处理一个 10GB 的文件,纯 CPU 解析,耗时 30 秒 // 直接 async 没意义,但也不能阻塞调用者 30 秒
正确做法是显式扔到线程池,让调用者可以 await:
csharp
Task<Result> ProcessHugeFile(string path) {
return Task.Run(() => {
// 在线程池线程上做 CPU 密集型工作
return SynchronousHeavyParsing(path);
});
}
// 调用者:
var result = await ProcessHugeFile(path); // 不阻塞 UI/请求线程
库设计时的特殊考量
如果你在写供他人使用的库,还有一个额外维度:你的用户是谁?
用户主要是 UI 应用(WPF、MAUI):
→ 必须提供 async,同步版本会冻结 UI
用户主要是命令行工具或脚本:
→ async 收益有限,同步版本更简单
用户是高并发服务器:
→ 必须 async,这是生死线
用户不确定:
→ 提供 async 版本,同步版本通过封装提供
(但不要 sync-over-async,会死锁)
.NET 生态里”同步版 + 异步版并存”的双轨困境,正是因为早期没有想清楚这个问题,导致后来不得不为每个 API 维护两套实现。
一张决策卡片
┌─────────────────────────────────────────────────────┐ │ 这个函数要不要 async? │ ├─────────────────────────────────────────────────────┤ │ │ │ 会等待进程外部(网络/磁盘/DB/Timer)? │ │ → 是 → ✅ async │ │ │ │ 只做内存计算,耗时 < 1ms? │ │ → 是 → ❌ 不要 async │ │ │ │ CPU 密集,耗时 > 几十 ms? │ │ → 是 → Task.Run() 包裹,返回 Task(可被 await) │ │ │ │ 调用了 async 函数? │ │ → 是 → ✅ 必须 async(传染性) │ │ │ │ 同一个操作有时快(命中缓存)有时慢(穿透 I/O)? │ │ → 是 → ValueTask / 条件性 async │ │ │ └─────────────────────────────────────────────────────┘
最终直觉:问自己”这个函数执行时,线程有没有在空等?”有空等就应该 async,让线程去做别的事。没有空等(纯 CPU 工作),async 是无效甚至有害的包装。
