c# 多线程并发执行的最佳实践

接入与开发

给 C# 开发者讲多线程并发调接口的落地做法:并发度怎么定、超时与重试怎么配、日志记什么、出问题按什么顺序查。

用 C# 批量调 AI 接口时,最容易踩的坑不是语法,是一上来就开一堆 Task 把接口打限流,或者一个超时就把整批任务拖死。这篇按工程落地顺序讲:并发度怎么定、超时和重试怎么分档、失败怎么降级、日志该留哪几个字段、上线前怎么小规模验证,最后给一份出问题时的排查顺序。

用 C# 批量调 AI 接口,翻车现场基本长一个样:以为开了几十个 Task 就能快,结果第一批就撞上对方限流;或者某一两个请求卡住不返回,整批 WhenAll 一直挂在那。语法本身没错,错的是没先把并发这件事当成工程问题来设计。

这篇按落地顺序走一遍:并发度怎么定、超时和重试怎么分档、失败往哪降、日志留哪几个字段、上线前怎么小规模试水,最后给一份出问题时的排查次序。不承诺任何一套参数放之四海皆准,接口上限和线路质量变了,结论就得跟着调。

先定并发度,再写代码

并发度不是拍脑袋定的,得先从对面倒推。先搞清楚目标接口允许的 QPS 或并发连接上限,站内 /qps-concurrency-limits-dev 有口径说明,具体数字到 /ai-nav/category/ai-model-platform 逐家核对当时的值。拿到上限后乘一个安全系数,才是你能用的调用方并发数。

  • 本地小团队从个位数并发起步,跑稳一两轮再加,别一上来就对齐上限
  • 用 SemaphoreSlim 做闸门,控制的是同时在跑的请求数,不是 Task 的创建数量
  • 给每批任务设总量上限,队列堆到几十万条内存先炸,不是接口先炸
  • 对方是中转站时并发口径常和官方不一样,/sites 里的检测信息能帮你判断它扛不扛得住你的量
var gate = new SemaphoreSlim(maxDegree);
var tasks = items.Select(async item => {
    await gate.WaitAsync(ct);
    try { return await CallAsync(item, ct); }
    finally { gate.Release(); }
});
var results = await Task.WhenAll(tasks);

超时、重试与退避怎么配

HttpClient 的默认超时对批量场景偏宽,一个慢连接能把整批拖住。做法是分两层:连接超时管握手快慢,整体超时管一次调用最多花多久,两个都显式设。具体秒数档位可以参照 /api-timeout-tuning 的分档思路,别直接抄某个固定值,网络环境不同结论不同。

错误类型是否重试处理动作
连接失败 / 超时是计入退避队列,重试有次数上限
5xx 服务端错误是先小退避,连续多次则切备用线路
429 限流是,但要降温降并发加退避,不是原地硬重试
401 / 403 鉴权否先走 /401-auth-error-troubleshoot 排查,重试没意义
400 参数错误否回看请求体,重试一万次也还是错
  • 退避用指数增长加随机抖动,避免一批任务在同一秒集体重试,把对方再打一遍
  • 重试次数要设死上限,超过就丢进失败队列,不要让单个请求无限循环
  • 每个请求挂 CancellationToken,整批结束时能干净取消掉还在跑的请求,别让线程池被占住
  • 换线路或换 Key 后先单独测通再合进批处理,参考 /relay-switch-api-verification 的验证思路

降级路径与日志字段

降级不是把失败的请求吞掉,而是准备好备用模型或备用线路,主线路连续错误到阈值才切。切换这个动作必须留痕,哪一批、从哪条切到哪条、切之前错误码是什么,都要记下来,别做成静默替换,否则线上表现会变得没人看得懂。

日志字段为什么记
请求 ID把一次调用从发起串到结果,便于单点复盘
模型名 / 线路区分是某家出问题还是全线出问题
批次号能按批看成功率,定位是哪一秒开始变坏
耗时区分慢和错,慢请求往往先于报错出现
HTTP 状态码限流、鉴权、服务端错误的直接证据
是否重试及次数判断退避配置是否在起作用

异常消息不够用,响应体里通常带错误码,截一段存下来。限流和参数错的响应体长得不一样,只记 Exception.Message 会把这两类混成一条。失败任务落盘或进表,支持单独重投,别整批重跑;重投前先确认原请求是否幂等,不幂等的接口重投要人工确认。

上线前验证与排查顺序

  1. 小批量试水先跑几十个请求、低并发一轮,看成功率、平均耗时和错误分布,再决定放量节奏
  2. 逐步加压并发翻倍跑一轮,观察错误率拐点出现在哪一档,把拐点前的位置当生产并发上限
  3. 单请求复验怀疑代码时先把并发闸门调到 1 跑一遍,能复现说明是请求本身的问题,不能复现才往并发控制上查
  4. 按序排查单请求能否成功 → 并发降到 1 是否正常 → 是否集中报 429/5xx → 是否只有某条线路出问题 → 最后才怀疑代码
下一步拿不准某家中转站能不能扛住你的并发量、价格和线路稳不稳,到站内目录按当时的检测信息核对一遍再决定放量节奏。

常见问题

C# 里用 Task.WhenAll 批量调接口,为什么会大面积超时?

通常是并发数超过了对方上限,请求在服务端排队,最后一起超时。先确认接口的并发口径,用 SemaphoreSlim 把同时在飞的请求压到合理数量,并给每个请求单独设超时和取消令牌,而不是让整批任务共用一个等待。

同步 HttpClient 和 async/await 混着用会有什么问题?

混用容易造成线程池饥饿:同步等待会把线程占住,async 任务拿不到线程继续跑,表现就是程序明明没干多少活却很慢。并发调接口的场景建议从入口到出参整条链路走 async,别在中间用 .Result 或 .Wait() 卡住。

怎么判断是本地并发写错了,还是对方限流?

把并发降到 1 单独跑同样的请求:能稳定成功,说明代码写法有问题;仍然失败且返回限流类状态码,基本就是对端限制或线路问题。也可以只换一条线路、Key 不变再跑一遍,对照结果就能分清是哪一侧的事。

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