写 C# 的人多半踩过这几个场景:业务明明不慢,压测一上来线程池就被占满;代码里到处 await,结果还是卡住 UI;多个任务改同一个集合,偶发数据错乱又复现不出来。这篇不讲语法教程,只讲工程上怎么选写法、哪些坑最容易踩、上线前拿什么数据验证,以及出问题时按什么顺序查。
写 C# 的人多半踩过这几个场景:业务本身不慢,压测一上来线程池就被占满;代码里到处 await,界面还是卡住;多个任务改同一个集合,偶发数据错乱,本地怎么都复现不出来。这篇不讲语法教程,只讲工程上怎么选写法、哪些坑最容易踩、上线前拿什么数据验证,以及出问题按什么顺序查。
先把一句话放前面:异步不等于并发,并发不等于多线程。async/await 解决的是「等待期间别占着线程」,并行库解决的是「把计算摊到多个核」,两者经常一起用,但混用方式错了,性能会比纯串行还差。下面按选型、线程安全、常见症状、性能验证、日志与降级五段走。
先选写法:async/await、Task、并行库分别适合什么活
判断标准其实只有一条:这段耗时是在等外部,还是在烧 CPU。等外部(调接口、读库、读文件)就用 async/await 一路透传到底;烧 CPU(计算、序列化、大批量转换)才考虑并行库,并且必须显式限定并行度。中间态最容易被写坏。
| 任务类型 | 推荐写法 | 不要这么做 |
|---|---|---|
| I/O 密集 | async/await 全链路透传 | 用 Task.Run 包同步阻塞调用 |
| CPU 密集 | 并行库 + 显式限定并行度 | 让框架自己决定并行度 |
| 混合 | 外层 async 编排,内层交计算 | 两层互相嵌套等待 |
| 超短小任务海量 | 串行或批量处理 | 无脑拆成并发 |
- I/O 密集优先 async/await,别用 Task.Run 去包同步阻塞调用,那只是把线程从你的代码挪到线程池,池子照样被占满
- CPU 密集型才考虑并行库,并且要显式限定并行度,默认值在上线机器上往往偏大
- 混合场景的常用切法:外层 async 负责编排,内层把纯计算部分交出去,两段之间不要互相嵌套等待
- 什么情况下不适用:单个任务耗时极短且量又很大时,拆成并发的调度开销可能比省下的时间还多,这时候串行反而更快
线程安全的坑集中在哪几处
并发 bug 的特点是低频、难复现、一旦出现就是数据问题。按经验,绝大多数都出在共享可变状态上,其次是 await 之后线程上下文变了,再其次是循环里捕获了同一个变量。先把这三类排查一遍,能省很多时间。
- 共享可变状态
- 静态字段、单例里的字典、缓存对象,多任务同时读写就会错;先想能不能干脆不共享,再想加锁还是换并发集合
- await 之后的上下文
- 代码可能跑在另一个线程上,别默认还在原来的上下文;涉及界面或请求上下文要显式处理
- 闭包捕获
- 循环里起并发任务,每轮拿到的常常是同一个变量;要么按轮次传参,要么每轮新建局部变量
- 锁的持有范围
- 别把锁开在 await 外面长期持有,等待期间占着锁,别的任务全排队,等于把并发写成了串行
判断「要不要加锁」的实用办法:问这个数据是不是必须被多个并发单元同时看到。如果每个任务能拿自己的副本、最后统一合并,那就别共享;如果确实要共享,优先考虑并发集合,其次才是锁,并且锁的粒度尽量小、持有时间尽量短。
常见问题与排查顺序
并发问题最怕一上来就调参数。先分清楚是「变慢」还是「不返回」还是「数据不对」,这三类的根因完全不同,排查路径也不一样。下面按现象列一下先看哪里。
| 现象 | 先查什么 | 常见根因 |
|---|---|---|
| 响应时间断崖上涨 | 异步链路里有没有同步阻塞 | 在线程里睡眠、同步等待结果 |
| 任务永远不返回 | 有没有互相等待的锁 | 忘记在异常分支释放信号量 |
| 偶发丢数据或计数不对 | 共享集合有没有并发保护 | 先怀疑数据库往往是查错方向 |
- 单请求先跑通保证一个请求的正确性和基本耗时,这是后面所有对比的基线
- 并发数逐档加一档一档往上加,记录每档的延迟和错误,不要直接上满
- 看是延迟涨还是报错延迟平滑上涨通常是有资源被占住,直接报错多半是撞了限流或连接上限
- 定位到具体某一段判断这一段是 CPU 忙还是线程被占住,两种情况的修法完全不同
性能怎么验证:先量基线再压并发
没有基线的压测只能说明「很慢」,说不清从哪一档开始慢。正确顺序是先测单个请求的耗时基线,再按倍数加压,观察延迟和错误率在哪一档开始变形。测速思路可以参考 /tools/speed-test,接口层延迟量出来才有对照。具体到各家模型接口的额度口径和并发限制,去 /official-api 和 /providers 核对当下的说明,或到 /ai-nav/category/ai-api 横向比一比。
- 先测单个请求的耗时基线,再按倍数加压,否则你不知道变慢是从第几档开始的
- 压测分两段看:吞吐(每秒完成多少)和延迟分位(慢的那部分有多慢),只看平均值会被少数慢请求掩盖
- 如果链路上有大模型接口,上游的并发上限会直接决定你的表现,额度说明以站内页面当时的数字为准
- 验证清单:并发档位、错误类型、内存占用趋势、线程池是否打满,四样都记,才谈得上定位
日志、超时与降级怎么配合
并发一多,日志会变成一团乱麻,出问题时连一次请求经过了哪些任务都串不起来,更别说定位。所以并发单元必须带可追踪标识,外部调用必须有超时,批量任务必须能失败重投而不是整批重跑。
- 每个并发单元都带上可追踪的标识,日志里能串起一次请求经过的所有任务
- 所有外部调用都要有超时,并区分连接超时和读取超时;具体设多久要看链路特点,没有通用值
- 批量任务要能失败重投:记录失败项而不是整批重跑,重跑时控制节奏,避免把上游打成限流,思路参考 /guides/429-concurrency-backoff
- 降级要提前设计:并行多路时允许部分失败,别让一个分支超时拖垮整个请求
什么情况下不适用:链路本身很短、依赖很少、并发量也不高时,加一整套追踪和重投机制反而增加复杂度,这时候简单的超时加重试就够。判断标准是「出问题时你需不需要还原单次请求的完整路径」,需要就加,不需要就先别加。
下一步把你现在的并发写法跟上面的对照表过一遍,重点看有没有同步阻塞混在异步链路里;有模型接口的链路,顺手去核一下上游的并发口径。→常见问题
async/await 是不是就等于开了新线程?
不是。await 不创建线程,它只是在等待期间把当前线程让出去,等结果回来再由线程池安排继续执行,所以同一段 async 代码前后可能跑在不同线程上。真正开额外线程的是显式起任务或走并行库。这也意味着:想在异步代码里省线程,关键是别用同步阻塞把线程占住。
并发数开多大才合适?
没有万能数字,取决于三件事:你的机器有多少可用核心、每个任务是吃 CPU 还是等 I/O、下游允许多少并发。做法是从小往大逐档加压,观察延迟分位和错误率,找到延迟开始明显恶化的那一档,再往回退一点作为默认值。CPU 密集型的经验起点是核心数量级,I/O 密集型可以更高,但不设上限一定出事。
本地跑得好好的,上线就出问题,先查什么?
按这个顺序:一,线上机器核心数和你本机不一样,并行度默认值可能严重偏大;二,本地少并发的共享状态问题,线上才暴露;三,线上有超时和限流,上游变慢会连锁放大线程占用;四,日志和监控没打开,看不到真实分布。先把并发档位降到保守值跑通,再逐步放开,比直接猜参数快得多。