c# 多线程并发执行的异步编程

接入与开发

给 C# 开发者一份异步并发落地清单:怎么选写法、怎么避线程安全坑、上线前怎么验证性能,附排查顺序。

写 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 外面长期持有,等待期间占着锁,别的任务全排队,等于把并发写成了串行

判断「要不要加锁」的实用办法:问这个数据是不是必须被多个并发单元同时看到。如果每个任务能拿自己的副本、最后统一合并,那就别共享;如果确实要共享,优先考虑并发集合,其次才是锁,并且锁的粒度尽量小、持有时间尽量短。

常见问题与排查顺序

并发问题最怕一上来就调参数。先分清楚是「变慢」还是「不返回」还是「数据不对」,这三类的根因完全不同,排查路径也不一样。下面按现象列一下先看哪里。

现象先查什么常见根因
响应时间断崖上涨异步链路里有没有同步阻塞在线程里睡眠、同步等待结果
任务永远不返回有没有互相等待的锁忘记在异常分支释放信号量
偶发丢数据或计数不对共享集合有没有并发保护先怀疑数据库往往是查错方向
  1. 单请求先跑通保证一个请求的正确性和基本耗时,这是后面所有对比的基线
  2. 并发数逐档加一档一档往上加,记录每档的延迟和错误,不要直接上满
  3. 看是延迟涨还是报错延迟平滑上涨通常是有资源被占住,直接报错多半是撞了限流或连接上限
  4. 定位到具体某一段判断这一段是 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 密集型可以更高,但不设上限一定出事。

本地跑得好好的,上线就出问题,先查什么?

按这个顺序:一,线上机器核心数和你本机不一样,并行度默认值可能严重偏大;二,本地少并发的共享状态问题,线上才暴露;三,线上有超时和限流,上游变慢会连锁放大线程占用;四,日志和监控没打开,看不到真实分布。先把并发档位降到保守值跑通,再逐步放开,比直接猜参数快得多。

本文依据各平台公开文档与官方公告整理,只做中文归纳;具体做法、参数与限制以官方文档为准。