i007.cc

i007.cc

优先队列-降维打击

05.价值资料

一个函数是否应该设计成在协程中运行的判断标准是什么?

这是一个比看起来深得多的设计问题。很多人凭感觉决定,实际上有清晰的判断框架。


核心判断标准:这个函数会不会”等待”某个外部事件?

函数执行期间,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 是无效甚至有害的包装。

发表回复