ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

vLLM EngineCore为何用线程而非协程?线程与协程的并发选型解析

vLLM EngineCore为何用线程而非协程?线程与协程的并发选型解析 vLLM 部署 DeepSeek 或者 Qwen 这类模型时只要你在监控面板上多盯一会儿进程就一定会注意到一个有趣的现象API Server 那个进程的 CPU 曲线是毛刺状的轻飘飘典型 asyncio 协程的风格而 EngineCore 进程稳定地占着好几个核心看起来就像有三四条线程在各自忙活。于是群里隔三差五就有人问vLLM 的 EngineCore 为什么用线程不用协程Python 圈子不是到处都在吹协程省资源、切换快吗答案其实就藏在标题那句话里避免 CPU 密集型任务阻塞 IO 操作而且这里的 IO 操作主要是 C 代码。用线程才能让这些任务真正并行起来。这篇文章我把整个推导过程完整写一遍包括为什么协程扛不住、线程的并行到底从哪来、以及我在实际部署中怎么验证和避坑。1. 先看清楚 EngineCore 的三条线程到底在忙什么1.1 一次 DeepSeek 部署引发的“线程三兄弟”观察我在很早之前用 vllm/vllm-openai 镜像部署过一版 DeepSeek 模型做压测当时用的是 v0.27.1 附近的版本。启动方式很简单vllm serve一条命令拉起来但实际进程数不是只有一两个。用ps -eLf | grep engine扫一眼能看到一个 EngineCore 子进程里面挂着几条工作线程再往下还有 GPU worker 进程这些是另一回事先不展开。这里有个很容易被忽略的细节很多人在top里看到的“vLLM 用了多少 CPU”其实是 API Server、EngineCore、GPU worker 加在一起的合计。你想观察线程模型就得用top -H -p EngineCore_PID或者直接看/proc/PID/task目录下的线程数。我习惯先把 PID 找到然后ls /proc/PID/task | wc -l看到几个任务项就说明进程里起了几个线程。在常见配置下EngineCore 的工作线程数量不多标题说的“三条线程”是比较典型的形态——但请注意具体线程数会随 vLLM 版本和配置浮动有时候你会看到四条甚至更多这不重要重要的是它们的职责骨架是稳定下来的。1.2 对号入座调度、执行、输出的工种划分很多人第一次接触 vLLM 源码时会懵因为它的抽象层次挺多。但如果你像我一样用py-spy dump抓过 EngineCore 的线程栈就会发现这三条线程的定位其实非常清晰线程核心职责主要交互对象调度线程continuous batching 决策、KV cache 分配、prefill/decode 状态切换请求队列、KV cache 管理器执行线程从调度结果里取 batch驱动模型 forward、调用 CUDA/C 算子GPU、模型权重、采样器输出线程处理采样结果、logits 后处理、把生成结果写回上游队列采样器、API Server 通信队列这些名称并不是 vLLM 官方文档里统一使用的术语是我根据栈追踪和信息流方向做的归纳但对理解线程模型很有帮助。调度线程是典型的“决策密集”工种它不停地在做策略计算谁来 prefill、谁来 decode、谁能抢占、KV cache 还剩多少执行线程是“等待密集”工种它把 batch 交给 GPU 之后主要时间花在看 CUDA 有没有算完输出线程是“IO 密集”工种它要快速把结果送回 API 层同时又不能拖累前两个线程。1.3 同样是无阻塞需求为什么 API Server 不用线程这里有个很自然的疑问既然线程这么好用那 vLLM 最外层的 API Server 为什么用 asyncio 协程而不是也用线程道理其实很简单——两层的负载类型完全不同。API Server 面对的是大量流式请求每个连接都是长连接大部分时间在等网络数据属于典型的高并发 IO 等待场景。这种场景下等待 IO 的协程在线程里挂起几乎没有额外成本你要是给一万个并发连接各分配一个线程光是线程栈可能就是几百 MB 甚至上 GB 的内存开销线程切换也会把 CPU 打爆。而 EngineCore 是真正“干活”的地方它里面有调度决策、有批处理组装、有对 C/C 算子的调用再往里还有 GPU 执行。这种混合负载是协程最不擅长处理的东西。所以 vLLM 的分层设计是对外用协程管网络对内用线程干活。你要是把这两层反过来或者统一用一种模型很快就会撞上性能墙。2. 协程被“CPU 密集型任务 C 阻塞调用”夹击的完整推导2.1 从 GIL 开始Python 线程与协程的真实调度差异在 Python 里讨论线程和协程绕不开 GIL。很多人以为 GIL 意味着“Python 线程没用”这其实是个误解。GIL 锁的是 Python 字节码解释执行——同一时刻只有一个线程能跑 Python 字节码。但线程是否被操作系统调度、能否在阻塞时让出 CPU跟 GIL 完全是两回事。协程和线程的差异恰恰就体现在这里协程跑在一个线程里靠事件循环做协作式切换线程由操作系统做抢占式调度。用大白话说线程之间打架是有裁判内核的裁判说切谁就切谁协程之间互相谦让一个人不主动让出别人永远拿不到执行权。这个差异在“纯 IO 等待”场景下感受不出来因为 IO 等待天然是让出执行权的最佳时机协程在这里切换干净利落。但一进入 EngineCore 这种混合负载场景问题就暴露了。2.2 事件循环遇上前台大计算整个进程跟着“冻住”先看一个典型反例。假设你用 asyncio 手写一个调度器伪代码长这样async def scheduler_loop(): while True: batch assemble_batch() plan make_scheduling_plan(batch) # 这段可能涉及大量 Python 层计算 await executor_queue.put(plan)这里的make_scheduling_plan如果是一个纯 Python 实现的策略函数比如要给每个序列算 score、决定 prefill 还是 decode、遍历几千个 token 判断哪些可以抢占那它在执行期间不会 await事件循环就被这个函数整个占住。外围还挂着几十个流式请求的 IO 回调全都排不上队。更麻烦的是这个函数本身是 CPU 密集的但它不会像sleep或网络读取那样自动让出控制权。GIL 对它也无力——GIL 只能决定“哪个线程拿锁跑字节码”并不能在事件循环内部强制切换协程。结果就是一旦调度逻辑里有重计算整个 EngineCore 的 Python 侧协作调度全部停滞。这不是理论问题是在老版本 vLLM 里真实出现过的现象后来很多热点逻辑都被改写成向量化或者下沉到 C就是为了把它从 Python 敏感路径上挪开。2.3 C 扩展阻塞调用是压倒协程的最后一根稻草标题里提到“主要是 C 代码”这部分才是核心中的核心。vLLM 的 EngineCore 整个执行链路里Python 只是“传话人”真正干重活的是三块 C/C 代码PyTorch 的算子、vLLM 自研的 CUDA kernel、以及各种高性能 C 组件如 cache 管理、采样内核。当执行线程调用一个 C 扩展函数时如果这个函数是同步阻塞的——比如做一次显存拷贝或者等待某个 CUDA 算子执行完毕——Python 层面没有任何办法“中断”它。你把它包在async def里面也没用因为阻塞的是整个线程协程压根没有机会切换。这个场景下协程模型不是“效率低”而是“直接卡死”。想要破局你只能用asyncio.to_thread把这个阻塞 C 调用扔到线程池里去跑。但这个动作本身就是“用线程解决问题”等于绕了一圈又回去了。所以 EngineCore 干脆直接用线程本质上是把“绕一圈”变成“本来就该这样”。线程的优势在于阻塞的系统调用比如sleep、IO 等待、锁等待会让当前线程让出 CPU内核可以去调度别的线程而一个线程在 C 代码里执行耗时操作时如果 C 扩展主动释放 GIL其他线程还能真正并行跑 Python 逻辑。这给一条流水线提供了极大的重叠空间。2.4 协程在 vLLM 里的“合理地盘”其实很小看到这里你可能有另一个疑问那协程在 vLLM 里是不是完全没用了当然不是。API Server 的流式请求处理、SSE 数据推送、prompt 解析、异步数据加载这些场景依然是协程的主场因为它们的共同点是“等待成本远高于计算成本”。判断一个位置到底该用协程还是线程我自己的经验是看两件事第一这里会不会出现长时间不释放 CPU 的密集循环第二这里会不会同步调用某个 C 扩展且该扩展不释放 GIL。如果两个问题的答案都是“否”那协程没问题如果有一个是“是”你就得认真考虑线程或进程了。EngineCore 恰好两个答案都是“是”所以协程在这里只能做外围编排不能做核心承载。3. 线程真正并行起来的关键C 代码主动释放 GIL 的机制3.1 哪些底层操作会“放行”其他线程很多人有一个误解觉得 Python 线程在 C 扩展被调用时永远是“假并行”。实际情况是大量成熟的 C 扩展在耗时操作开始前会主动释放 GIL核心 C API 长这样Py_BEGIN_ALLOW_THREADS // 这里没有 GIL其他 Python 线程可以继续跑字节码 heavy_operation(); Py_END_ALLOW_THREADS释放 GIL 的典型场景包括CUDA kernel launch、显存拷贝、大文件读取、压缩、加密、矩阵运算等等。PyTorch 的前向计算、FlashAttention、vLLM 的采样 kernel在真正执行时基本都是释放 GIL 的。所以 EngineCore 的执行线程调度完一次 forwardGPU 在算CPU 线程处于等待状态而调度线程和输出线程完全可以趁这个时间继续跑 Python 逻辑。这就是“三个线程并行”的实际来源而不是大家以为的“三个线程都在抢同一把 GIL”。但这里有个非常关键的注意点GIL 的释放取决于 C 扩展是否做了这个动作。如果你自己写了一个 Python 层的 for 循环比如在 logits processor 里遍历几万个 token 逐个做条件判断那一整段 Python 字节码都在抢 GIL三个线程就会互相摩擦。所以 vLLM 这种重计算框架对“Python 层热循环”是零容忍的能下沉到 C 就下沉能用 numpy 向量化就向量化。3.2 三个线程之间靠什么排队、怎么互不打扰三个线程不是各干各的、互相之间没协调。它们之间的关系是生产者-消费者链调度线程生产 batch执行线程消费 batch执行线程再生产采样结果输出线程消费采样结果。中间靠的就是队列。# 简化的示意结构实际 vLLM 里用的是任务队列 事件信号 scheduler - execution_queue.put(plan) executor - plan execution_queue.get() executor - output_queue.put(sampling_result) output - result output_queue.get()这些队列操作在 Python 层面是有锁的但因为 GPU 单次 forward 的耗时通常是毫秒级甚至亚毫秒级队列本身的开销相对可以忽略。需要注意的是不要把队列设计成无界队列否则一旦某条线程变慢内存会被积压的请求吃掉我就在压测时见过 EngineCore 因为输出队列堆积导致内存暴涨的情况。三个线程之间除了队列还有事件通知机制。某个线程往队列里放东西后通过condition.notify()唤醒等待消费的线程避免忙轮询占满 CPU。这个设计也是线程模型相对协程的一个隐性优势线程可以用阻塞等待把 CPU 让给真正需要计算的线程而协程需要小心翼翼地避免在等待时阻塞整个事件循环。3.3 decode 阶段的高吞吐流水线一喂一算一出到了 Decode 阶段三个线程的流水线特性会表现得非常明显。每次 decode 迭代执行线程把当前 batch 喂给 GPUGPU 跑一遍 forward得到每个序列的 logits采样出下一个 token采样结果要送回调度线程更新序列状态同时也要送回 API 层推给用户。这里有个重叠窗口GPU 算 decode 的同时调度线程可以提前准备下一个 batch 的调度计划输出线程可以处理上一个 batch 的结果。三个线程在这个窗口里同时工作互不阻塞整条流水线天然把 GPU 算力和 CPU 调度能力重叠起来。如果你用协程来做同样的事因为所有切换都要经过同一个事件循环这个重叠窗口就很难打开。这个流水线设计是整个 vLLM 吞吐量能打的核心逻辑之一。当你看到 EngineCore 的 CPU 使用率并不是单核 100%而是两三个线程各自在 60%~80% 浮动的时候实际上就是这条流水线在健康运转的信号。4. 用 py-spy 和监控面板给线程模型做一次“体检”4.1 抓现场py-spy 看 EngineCore 线程栈理论讲再多不如亲自抓一次现场。我常用的三板斧是这样的# 找到 EngineCore 进程 ps -eLf | grep engine_core # 看线程数 ls /proc/PID/task | wc -l # 抓线程栈看每个线程卡在什么调用上 py-spy dump --pid PIDpy-spy dump的输出很有意思你会看到三条线程各自的栈有一条在queue.get()里挂着等活干有一条停在 PyTorch 的 C 算子栈里还有一条在 numpy 或者 Cython 的后处理代码里。如果你看到突然有四五条线程都停在同一段 Python 循环里那基本可以断定有逻辑写坏了比如把应该在 C 层做的逐 token 操作搬回 Python 层了。这里有个容器部署的坑要提醒一下py-spy需要 ptrace 权限在 docker 里跑 vLLM 时如果不给--cap-addSYS_PTRACE你会看到Permission denied。我在线上环境通常是在宿主机上直接找 PID 抓栈或者临时给容器加上权限用完再撤掉。4.2 实测 DeepSeek 部署时三条线程忙闲分布我自己压测 DeepSeek 模型时的观察数据可以给大家做个参考。用 16 路并发压一个 32B 量级的模型prompt 长度分布在 2K~8K观察三个线程的 CPU 占用长 prompt 请求大量进入时调度线程的 CPU 明显走高因为 prefill 调度需要做更精细的 KV cache 规划和 batch 决策进入稳定 decode 阶段后执行线程的占用开始稳定但它主要在等 GPU所以 CPU 不是满的反而 GPU 利用率冲到 90% 以上流式输出量大时输出线程会忙一些但如果输出线程出现持续高 CPU多半是 Python 层的后处理逻辑太重比如逐 token 做字符串拼接。这三条线程的忙闲分布是检验配置是否合理的一把尺子。如果调度线程长时间 100%说明调度策略或 Python 层逻辑有问题如果输出线程跑到 100%先查后处理代码再查队列如果执行线程完全不忙GPU 利用率又不高那说明进请求的速度跟不上瓶颈可能在 API 层。4.3 线程模型下最容易出现的三个坑及对策第一个坑是调度线程被 Python 层逻辑拖慢。我见过有人给 vLLM 加自定义 logits processor里面用纯 Python 遍历所有 token 做过滤结果是 decode 吞吐直接掉一截。对策很简单能向量化就向量化能下沉到 C 就下沉永远别在调度和执行路径上写逐元素的 Python 循环。第二个坑是输出线程被同步 IO 堵住。如果你在 EngineCore 线程路径上写日志而且是同步写盘一旦磁盘慢输出线程就会被 IO 阻塞拖死连带队列堆积。对策是把日志级别在压测时调低或者用异步日志库。第三个坑是队列长尾阻塞。max_num_seqs和max_num_batched_tokens设得太大调度线程一次性塞给执行线程的 batch 过大执行线程处理不过来后面的请求会排队很久。这不是线程模型的问题是参数和负载不匹配的问题。我一般从这两个参数的一半开始调压测时观察排队时延再逐渐往上加。5. 给我重选一次EngineCore 依然会用线程但会少踩这些坑5.1 线程、协程、多进程的选型对照表很多人在选择并发模型时容易“一招鲜”觉得协程万能或者线程万能。从 EngineCore 这个案例延伸出去我整理了一张选型对照表基本覆盖了三种模型的核心差异维度协程asyncio线程多进程调度方式协作式主动让出抢占式内核调度抢占式内核调度CPU 密集 Python 任务阻塞整个事件循环受 GIL 约束无法并行真并行阻塞 C 扩展且不释放 GIL整个线程卡死卡死当前线程但其他线程可调度只卡死本进程不影响其他进程高并发 IO 等待切换成本最低线程数多了内存压力大进程通信成本高数据共享同线程共享同进程共享锁成本可控需序列化/共享内存成本高EngineCore 中的位置API 入口、流式传输调度、执行、输出核心路径GPU worker 隔离、多卡并行这个表格能解释很多现象为什么 vLLM 的 GPU worker 用多进程而不是多线程因为不同进程可以绕开 GIL 做真正的并行而且一张卡一个进程更利于显存隔离和故障隔离为什么 API 层用协程因为高并发 IO 等待的场景协程切换成本最低为什么 EngineCore 的核心路径用线程因为它在“密集 IO 等待 密集 C 调用 少量 Python 计算”之间需要一个能被内核调度、能让出 CPU 的载体。5.2 架构上的三条工程经验第一条经验是“按层选型不要一刀切”。一个系统里可以同时存在协程、线程、多进程关键让它们出现在各自最合适的位置网络边界用协程计算核心用线程需要彻底隔离和并行的地方用进程。第二条经验是“协程只做编排不算力”。如果你写了大量 async 函数每个函数里又有重循环那协程大概率会变成性能瓶颈。正确的写法是 async 函数只做参数解析、IO 等待、调用子任务重活交给线程池或 C 扩展。这一点在构建自己的推理服务时同样适用。第三条经验是“线程数量按工种设不按核数设”。EngineCore 里三条线程对应的是三种职责而不是一口气开 64 个线程争抢 CPU。给每个工种一条清晰的工作线让它们通过队列衔接比盲目开更多线程更稳定。我在实际部署中还养成了一个习惯每次改完 vLLM 的并发参数或自定义逻辑先抓一次线程栈确认没有新的 Python 热循环出现在执行路径上再跑压测。这个动作成本很低但能省掉很多“吞吐量莫名下降”的排查时间。回到开头的问题EngineCore 为什么用三个线程而不是协程因为这里承载的是 CPU 密集型调度和 C/C 阻塞调用协程那套协作式切换在遇到不主动让出的重计算时毫无还手之力。线程虽然受制于 GIL但配合 C 代码释放 GIL 的机制已经能在工程上拿到真正的并行收益。这种选型不是技术洁癖也不是老代码没重构而是对一个混合负载系统最务实的回答。
返回列表