写 C# 的人几乎都卡过这里:明明加了 async,方法里却看不到线程;开了 Thread 反而比 await 还慢;UI 卡死时不知道该 await 还是该 Task.Run。这篇不背概念,只讲两条机制各自在干什么、边界在哪,给一份「先问哪几个问题再动手」的顺序,以及接线上服务前要核对的几项。
写 C# 的人几乎都卡过这里:明明加了 async,方法里却看不到线程;开了 Thread 反而比 await 还慢;UI 卡死时不知道该 await 还是该 Task.Run。这篇不背概念,只讲两条机制各自在干什么、边界在哪。
先记住一句:异步是「等的时候先让出执行权」,多线程是「同时铺开多份计算」。前者是调度模型,后者是执行资源模型,两者不冲突也不等价。下面按四条线拆开:各自解决什么、async 内部怎么跑、线程池和 Task 什么关系、实际选型和排查怎么做。
先分清两件事各自解决什么
异步解决的是「等待期间不占着执行资源」,典型场景是网络请求、读写文件、查数据库。多线程解决的是「同一时刻多份计算并行跑」,典型场景是大数组计算、批量图片处理。把这两句话贴在显示器上,后面很多纠结会自己消失。
判断口径只有一条:任务是 IO 密集还是 CPU 密集。IO 密集优先异步,CPU 密集才考虑并行。反过来硬套不划算——纯计算且没有任何外部等待时,async 只会多一层状态机开销,不会让计算本身变快,这种情况就不适用异步。
| 你的任务特征 | 优先考虑 | 别做的事 |
|---|---|---|
| 等网络/文件/数据库回应 | async/await 一路到底 | 在等待处用同步阻塞 |
| 本地大量数值计算 | 并行 API 加并发上限 | 用 async 包装纯计算 |
| 既等外部又算数据 | 外层异步、内层并行 | 无界地同时铺开 |
- 异步不等于另起线程,一个 async 方法在没有同步阻塞点时会沿同一个线程往下走
- 多线程不等于更快,线程创建和上下文切换本身有成本
- 两者可以叠加使用,但叠加前先问清楚哪一段在等、哪一段在算
- 不确定就先测:改前改后跑同一个场景,别凭感觉
async/await 在运行时到底发生了什么
编译器会把 async 方法切成一个状态机。遇到还没完成的 Task,它就登记一个续体然后返回,把线程让出去;等这个 Task 完成,续体再被调度回来往下执行。整个过程里你可能从头到尾看不到一个新线程,这正是「异步不等于多线程」的来历。
真正吃掉异步收益的是同步阻塞点:.Result、.Wait()、Thread.Sleep。它们会让当前线程原地干等,异步白改,还可能死锁——尤其在带同步上下文的 UI 程序或老框架里。库代码里默认加 ConfigureAwait(false) 更稳妥,只有需要回 UI 线程更新控件时才保留上下文。
- 全局搜同步阻塞在解决方案里搜 .Result、.Wait()、Thread.Sleep,逐个判断能不能换成 await。这一步收益最高。
- 确认上下文需求问自己这段代码后面要不要碰 UI 控件。不要就加 ConfigureAwait(false),要就保留。
- 加超时所有外部等待都要有超时,别让一次卡住拖垮整条链路。
- 留观测字段日志里记录一次请求的等待时长,方便事后判断是等太久还是算太久。
线程、线程池和 Task 的关系
new Thread 是独占一个操作系统线程,创建成本高、数量不可控,适合长期存在的固定工作。线程池复用线程,适合短任务。Task 是调度抽象,可能占用线程池线程,也可能完全不占线程——纯 IO 等待时它靠续体回调推进。
Task.Run 是把 CPU 活丢给线程池,不是「让方法变异步」的开关。把它套在本来就是 IO 的方法外面,只会白白占掉一个线程池线程,收益为零甚至为负。真正的并行计算看 PLINQ、Parallel.For、Task.WhenAll 组合,注意共享状态的锁和结果聚合顺序。
- 短计算任务:线程池或 Task.Run,别自己 new Thread
- 长阻塞的同步等待:塞进线程池会拖垮池子,要么改成异步,要么单独起线程
- UI 里的重活:先 Task.Run 挪走,再 await 回来更新界面
- 共享可变状态:先加锁或改成不可变,再谈并发
- 并发上限:外部依赖能扛多少,你的闸门就设多少
线程是资源,Task 是承诺。把两者当成一回事,坑就开始了。
选型清单与上线前的核对项
按这个顺序问自己:有没有在等外部资源?有就异步。要不要同时算多份?要就看并行 API。两者都有?外层异步、内层并行。这套顺序能覆盖大多数日常场景。
给一个 5 人小团队的通用做法:IO 路径统一 async 到底,CPU 密集段用并行 API 并设并发上限,别让无界并发把下游打挂。接线上服务前核对四件事——超时有没有设、并发有没有上限、异常有没有被吞掉、日志里能不能看出一次请求的等待时长。
- 超时
- 每一次外部等待都要有上限,避免雪崩
- 并发闸门
- 限制同时在跑的请求数,保护下游
- 异常
- await 会抛出异常,别用空的 catch 吞掉
- 观测
- 日志留等待时长字段,才分得清慢在哪
常见踩坑与排查顺序
现象比概念更好用。这里列三类最常遇到的症状,配一套从现象倒推原因的排序,照着走比背定义快。
第一类「加了 async 还是卡」:先看调用链里有没有同步阻塞,再看是不是在 UI 线程上做了重活。第二类「并发一上来就报错或超时」:多数是没设并发闸门和超时,不是异步写错了。第三类「偶发死锁或结果错乱」:查共享集合有没有并发写、有没有在锁里 await。
- 先定位是等待还是计算看这段时间 CPU 在不在忙。不忙就是等待,忙就是计算,两类问题的修法完全不同。
- 再看线程占用线程池线程是不是被长阻塞任务占住了,导致后面的任务排队。
- 最后看外部依赖下游是不是限流了、是不是单个请求本身就很慢,必要时查站内价格与模型资料对照当时的配置。
- 每一步留日志字段有了字段才能复盘,别等出事再补埋点。
| 症状 | 先查什么 | 再查什么 |
|---|---|---|
| 加了 async 还卡 | 同步阻塞点 | UI 线程上的重活 |
| 并发放大就超时 | 并发闸门 | 每个请求的超时设置 |
| 偶发死锁或错乱 | 共享集合的写 | 锁里有没有 await |
如果你是在挑后端 API 或中转服务,选型标准可以顺带看站内资源:各家官方 API 的价格和模型资料在资料库,中转站的可信度检测和价格追踪也有专门页面,动手前先核对当时的数字,别用记忆里的旧数据做决定。
常见问题
async/await 一定会开新线程吗?
不一定。async 只表示方法里有等待点,等待期间把线程还回去;续体可能在原线程、线程池线程或任意调度线程上执行。只有当你显式用 Task.Run 或并行 API 时,才保证有别的线程参与计算。判断别盯着线程数,盯有没有同步阻塞。
多线程一定能提高程序速度吗?
只在任务可拆分、且不抢同一份资源时才成立。CPU 核数、锁竞争、内存带宽都会封顶,加线程到一定程度反而更慢。IO 密集场景里加线程通常只是掩盖问题,正确的做法是异步化,把等待让出去。
什么时候该用 Task.Run,什么时候不该用?
CPU 密集、且调用方不希望被阻塞时用;本来就是 IO 异步的方法外面套 Task.Run 属于多余,会白占一个线程池线程。库代码里尽量别自己 Task.Run,把选择权留给调用方,否则容易在线程池紧张时雪上加霜。