线程池把上一次的上下文带进这一次:共享执行单元要显式清场|洹拓科技

一、三处瓶颈之外,还有一处不进指标的陷阱
把模型调用做成并发之后,会遇到的瓶颈有三类是显性的:连接池滥用、线程池膨胀、上游配额超限。这三类都会在某些指标上留下痕迹——连接等待、队列堆积、被拒次数,看得到就不容易漏。真正麻烦的是第四类,它不表现为任何一种资源不足:线程池复用导致线程局部变量数据串扰。同一根线程被下一个任务复用时,前一个任务写进去的线程局部值还挂在原处。
这类问题的表现形态很不像性能问题。它更像业务逻辑偶尔出错:某个租户的请求被计到了另一个租户头上,某次调用用了别人的密钥,某笔请求的超时值莫名其妙比配置里的长。因为出错的前提是“这一笔恰好被复用了那一笔的线程”,它天然是低概率、随线程调度浮动、与负载相关的,压测环境里常常复现不出来,测试用例单独跑又永远是对的。洹拓科技在给客户做并发改造评审时,把这一类称为“共享执行单元的历史包袱”,检查项不是资源够不够,而是任务边界上有没有清场。
二、串扰发生的确切条件:线程本地不等于请求作用域
要把这件事讲清楚,得区分两个常被混用的词。线程本地的语义是“这份数据依附于执行它的线程”,它的生命周期跟着线程走;请求作用域的语义是“这份数据依附于这一次业务请求”,生命周期跟着请求走。线程池的设计目的恰恰是让线程活得比请求久——任务结束,线程归还池中等待下一个任务。于是这两个语义在池化执行下必然不等长:请求已经走完,线程上的槽位还留着。
串扰发生需要三个条件同时成立:任务在这根线程上结束时没有清除写入的线程局部值;下一个任务被调度到同一根线程上;下一个任务在读取自己那份值之前,先读了这个槽位,或者它压根没写、以为读到的一定是自己这一笔的。三个条件里只有第一个是我们能在代码结构上消灭的,第二和第三个都属于调度结果,改不了也不该改。所以清场这件事只能钉在任务边界上,而不能寄望于“复用习惯”——比如指望业务代码每次都先写后读,或者指望线程池永远不复用。
还有一个容易忽略的放大面:同一个请求在链路里可能跨越多种执行单元。入口线程上写的上下文,被提交给异步任务时不会自动跟过去;如果为了“方便”在提交前把上下文塞进新线程的槽位,那么清除责任就同时落在两根线程上,漏掉任何一根都会留残留。跨执行单元的传递要靠显式携带的上下文对象,而不是靠两边各自写同名的槽位。
三、最怕残留的字段:按会不会串到别的租户排两档
不是所有线程局部值都值得同样紧张。判据只有一条:这个值如果被下一笔任务读到,会不会让这一笔动作落到别人的身份、别人的资源或者别人的账上。
| 档位 | 典型字段 | 残留后的后果形态 | 出现位置 |
|---|---|---|---|
| 高危,必须强清 | 调用方标识与租户号、API 密钥或凭证句柄、计费与日志维度键 | 请求被记到别的租户账上、用别的租户的凭证发出、日志与账单归属错位 | 入口拦截器写入,模型调用层读取 |
| 次高危,会造成行为漂移但不越界 | 超时与重试预算、模型名与参数档、流式开关 | 这一笔用了不该用的更长超时、更贵的模型档,成本与失败形态变化 | 配置解析层写入,客户端封装读取 |
高危那一档的特征是它决定了“这笔请求算谁的”。租户号残留会让一次正常的推理调用在计费和配额核算上归错主体;密钥或凭证句柄残留更糟,它不是记账错误,而是身份错误——请求确实带着别人的授权发出去了,即便随后失败,也已经把外部系统的可见度用掉了。这一档字段一旦残留,事后从调用结果上往往看不出异常,只能从上下文快照比对里发现。
次高危那一档影响的是行为而不是归属:超时值残留会让重试预算变宽,一笔本该快速失败的请求拖成慢请求;模型名与参数档残留,会让本该走便宜档的流量走了贵档,账单和效果一起漂移。这一档不一定引发事故,但会持续污染归因——性能数据和对账数据都不再能代表配置声明的行为。
按这两档排一遍自己的线程局部写入点,是并发改造里成本最低的一项检查:写入位置通常在入口过滤器、鉴权组件和调用封装里,数量有限,逐个标注归属档位比逐个补清理更快见效。
四、清场动作写在哪儿才算可靠
写在业务代码末尾不算可靠,这是这类缺陷反复出现的原因。业务路径上有两种常见的离开方式:提前返回和抛出异常。把清理放在方法体的最后几行,异常会直接跳过它;为了省事在分支里 return,也会跳过它。于是清场变成一种“只有在一切顺利时才执行的保证”,而恰恰是不顺利的那条路径最需要把槽位腾空。
可靠的位置有两个层次。第一层是任务包装器:所有提交进池的任务都经过同一层包装,包装在进入时绑定上下文、在退出路径上无条件清除,无论任务是正常返回还是异常退出。这一层的价值在于它对业务代码不可见也不可绕过,新增调用点自动被覆盖。第二层是上下文对象自身:把上下文做成需要显式关闭的作用域对象,由语言机制在离开作用域时自动析构并清除;装饰器同样归在这一层,把“进入即绑定、离开即清除”做成一个可复用的切面。
两层的关系不是二选一。第一层管“凡是经过池的任务一定被清”,覆盖面广;第二层管“上下文对象的生命周期与业务块一致”,语义更准。只做第二层的话,仍然会有绕过包装器的提交路径;只做第一层的话,请求内部大段异步逻辑的作用域边界还是模糊的。我们评审时的判据是一句话:能否向客户证明,任何一个任务从线程上离开的每一条路径,都必然经过同一段清除代码。证明不了,就说明清理还散落在业务分支里。
顺带一提,连接复用与线程复用的清场是两件事。连接池归还的是一条网络通道,需要清的是连接上的会话状态;线程池归还的是执行单元,需要清的是依附于它的上下文槽位。只做了前者的改造,后者照样串扰。
五、定容与分池:线程数和在途数是两个旋钮
线程池膨胀之所以成为显性瓶颈,多数不是线程本身贵,而是线程数被当成并发上限去调:模型调用是高延迟任务,等待时间远大于计算时间,用 CPU 核数推出来的线程数放不进这种负载,于是把池越开越大,队列深度和内存占用一起上去。正确做法是先定容再分池。
定容的意思是线程数由明确的容量结论决定,并且不随瞬时压力自动增长;专用异步池的意思是把模型调用这类高延迟等待型任务,与序列化和模板拼接这类 CPU 型任务分开放。分池的理由是两者的排队代价不同:CPU 型任务排队浪费的是吞吐,等待型任务排队浪费的是端到端时间,混在一池里,慢任务会把快任务的线程占走,两类任务的延迟曲线互相污染,最后谁也看不出是谁拖了谁。
分池之后要有各自的观察口径。线程侧看三件:活跃线程数是否长期贴着配置上限、池内队列长度随时间的增量、因队列或线程耗尽被拒绝的任务计数。上下文侧看两件:单笔任务从入队到开始执行的等待时长,以及同一线程上相邻两笔任务的归属标识是否一致。前者是容量问题,后者是串扰问题,两条线不要合成一个“平均延迟”。
双层限流与分池是配合关系,各挡一层。信号量管在途并发数,也就是同时有多少请求真正发向模型服务,它挡的是“同一瞬间压出去太多”;令牌桶管提交速率,也就是单位时间允许进入多少笔新任务,它挡的是“匀速但持续超额地灌进来”。两者的职责划分要写清楚:在途数超了要阻塞或拒绝在发送前,速率超了要平滑掉而不是排队堆积。本篇只界定这两层的分工,配额本身如何划分不在这里展开。两层之外还需要有界背压:队列必须有界,无界队列会把拒绝动作推迟到内存耗尽,那时既看不到限流生效的痕迹,也丢了串扰证据。
六、串扰暴露之后:比对与最小复现
串扰被怀疑时,第一件事不是加日志量,而是把每条日志补上两个字段:执行它的线程标识,以及这一笔读到的上下文归属标识。有了这两个字段,归因方法就是比对同一根线程上相邻两笔任务——如果这一笔的租户标识与它之前在同一线程上执行的那笔相同,而与请求入口记录的租户标识不同,那基本可以确认读到的是残留而不是自己的值。这里有个前提:入口记录必须在绑定上下文之前取,否则比对会拿同一个错误值自我印证。
第二类证据是时间戳关系。串扰型错误的发生时刻与线程复用窗口重合:残留被读取的时间点落在前一笔任务结束之后、本任务显式写入之前。把任务开始、上下文绑定、模型调用发出、上下文清除四个时刻打出来,这个窗口就看得见。若四个时刻里有几个压根没打点,说明观测缺口本身要先补,别急着讨论结论。
最小复现要刻意构造复用条件:把池缩到单线程,让两笔不同租户的任务先后落在同一根线程上,第一笔走完刻意不清场的路径,第二笔只读取不写入上下文。在这个配置下,第一笔的归属标识会稳定出现在第二笔的行为里,缺陷变成确定性的,而不是靠负载撞概率。复现脚本要固定线程数与提交顺序,否则它验证的是调度器而不是清场逻辑。修好之后,同一脚本应当失败在“第二笔读到空值或自己的值”,而不是简单地跑得更快。
需要提醒的是,这类缺陷不适合用压测收口。压测改变的是复用概率,不是复用条件,压测通过不代表清场正确,只代表那台机器在那种调度序列下没撞上。收口手段是结构检查加确定性复现:先确认每条离开路径都经过同一段清除代码,再用单线程双笔脚本验证一次,才算把这条改完。