第29题:多线程同步如何做?如何提升推理吞吐?
1. 核心回答
这道题可以拆成两个层面:
- 多线程同步解决并发正确性问题:先识别共享状态,再根据访问模式选择
mutex、shared_mutex、condition_variable、atomic 等同步机制,同时缩短临界区并避免死锁。 - 推理吞吐优化解决资源利用率问题:重点通过动态 Batch、异步流水线、多个模型实例、推理模式、低精度计算,以及大模型场景中的 Continuous Batching 和 KV Cache 提高 GPU 利用率。
一个比较完整的推理服务可以组织成:
请求线程 ↓ 并发安全请求队列 ↓ Batch Scheduler ↓ 一个或多个 Inference Worker ↓ GPU ↓ 结果队列 ↓ 返回请求线程主要负责请求接收、预处理、调度和结果返回。GPU 推理请求则经过统一调度,避免大量线程各自提交很小的推理任务。
2. 多线程同步首先要确定共享状态
线程同步的第一步是明确:
哪些数据能够被多个线程同时访问,以及这些数据需要满足什么一致性条件。
例如一个推理服务可能存在:
- 请求队列;
- Batch Buffer;
- 模型实例池;
- KV Cache 管理器;
- 请求状态表;
- 统计计数器;
- GPU Stream 池;
- 内存 Buffer Pool。
如果一个变量只属于当前线程,就没有必要加锁。
真正需要同步的是共享可变状态。
例如:
Thread A ─┐ ├──> Shared Queue Thread B ─┤ │ Thread C ─┘如果多个线程同时修改队列,就需要保证操作的原子性和可见性。
3. 常用同步机制怎么选择
3.1mutex:共享数据需要互斥修改
最常见的情况是多个线程都会修改同一个数据结构。
例如:
std::mutex mtx;std::queue<Request>queue;voidpush(Request req){std::lock_guard<std::mutex>lock(mtx);queue.push(req);}std::lock_guard使用 RAII 管理锁。
进入作用域时获得锁,离开作用域时自动释放锁,因此可以降低异常路径或提前return导致漏解锁的风险。
适合:
- 请求队列;
- Map;
- Cache 元数据;
- 模型实例状态;
- 复杂共享对象。
3.2shared_mutex:读多写少
如果共享数据绝大多数时候只读取,可以使用读写锁。
例如:
Reader 1 ─┐ Reader 2 ─┼── shared lock Reader 3 ─┘ Writer ───── exclusive lock读取时:
std::shared_locklock(mtx);多个读线程可以同时进入。
修改时:
std::unique_locklock(mtx);写线程获得独占访问。
这种模式适合:
- 模型配置;
- 路由表;
- 只偶尔更新的 Cache Index;
- 服务配置。
shared_mutex的收益取决于实际读写比例和锁竞争。锁开销本身也需要通过 benchmark 判断。
3.3condition_variable:生产者—消费者
推理服务中更典型的情况是:
请求线程 = Producer 推理线程 = ConsumerProducer 把请求放入队列。
Inference Worker 在没有请求时应该阻塞等待,避免不断轮询:
while(queue.empty()){// busy waiting}更合适的方法是condition_variable。
典型逻辑:
std::mutex mtx;std::condition_variable cv;std::queue<Request>queue;voidproducer(Request req){{std::lock_guard<std::mutex>lock(mtx);queue.push(req);}cv.notify_one();}voidconsumer(){while(true){std::unique_lock<std::mutex>lock(mtx);cv.wait(lock,[]{return!queue.empty();});Request req=queue.front();queue.pop();lock.unlock();run_inference(req);}}这里有一个重要细节:
耗时的模型推理不要放在锁内部。
锁只保护:
取请求 修改队列状态完成后立即释放。
否则一个 GPU 推理如果需要 100 ms,其他线程可能连续 100 ms 无法访问队列。
3.4 Atomic:简单共享状态
如果共享状态只是简单计数器或 Flag,例如:
std::atomic<int>request_count;std::atomic<bool>running;可以使用 atomic。
典型场景包括:
- 请求计数;
- 简单状态位;
- 引用计数;
- 无锁数据结构中的基本状态。
Atomic 更适合简单状态转换。
如果一次操作涉及多个变量并要求它们满足联合不变量,通常仍需要 mutex 或其他更完整的同步机制。
4. 多线程同步最重要的工程原则
4.1 临界区尽可能小
例如:
lock();pop_request();unlock();preprocess();copy_to_gpu();inference();postprocess();锁只覆盖真正需要保护的共享状态。
避免:
lock();pop_request();preprocess();GPUinference();networksend();unlock();因为这会严重降低并发度。
4.2 锁内部避免阻塞操作
锁内部尽量避免:
- 文件 I/O;
- 网络 I/O;
- GPU Synchronize;
- 长时间 CPU 计算;
- RPC;
- 日志系统阻塞调用。
否则一个慢操作会扩大所有线程的等待时间。
4.3 多把锁需要固定顺序
假设存在:
Lock A Lock BThread 1:
Lock A ↓ Lock BThread 2:
Lock B ↓ Lock A就可能发生死锁:
Thread 1 持有 A,等待 B Thread 2 持有 B,等待 A因此需要统一锁顺序,例如:
永远先 A,再 BC++ 中也可以使用std::scoped_lock等机制管理多个锁。
5. 推理吞吐应该怎样定义
吞吐通常表示单位时间能够完成多少任务。
普通模型可以使用:
Throughput=Completed RequestsTime Throughput= \frac{\text{Completed Requests}} {\text{Time}}Throughput=TimeCompleted Requests
单位例如:
requests/s生成式大模型还经常关注:
tokens/s但吞吐不能脱离延迟单独优化。
例如:
| 配置 | Throughput | p95 Latency |
|---|---|---|
| A | 100 req/s | 30 ms |
| B | 180 req/s | 80 ms |
| C | 220 req/s | 500 ms |
如果服务要求:
p95 < 100 ms那么配置 C 即使吞吐最高,也无法满足服务目标。
因此更准确的优化目标是:
maxThroughput \max ThroughputmaxThroughput
约束:
p95 Latency≤Lmax p95\ Latency \leq L_{\max}p95Latency≤Lmax
或者同时约束 p99 latency。
6. 提高推理吞吐的第一优先级:Batching
6.1 Static Batching
最简单的方法是一次处理多个输入:
Request 1 ─┐ Request 2 ─┤ Request 3 ─┼── Batch ──> GPU Request 4 ─┘例如单请求:
X∈R1×d X\in \mathbb{R}^{1\times d}X∈R1×d
改成:
X∈RB×d X\in \mathbb{R}^{B\times d}X∈RB×d
GPU 通常能够通过更大的矩阵计算提高计算单元利用率。
6.2 Dynamic Batching
在线服务中请求并不会天然同时到达。
因此可以维护一个短暂的请求队列。
例如:
t0: Request A t1: Request B t2: Request C t3: Request DScheduler 等待一个很短的时间窗口,然后组成:
[A, B, C, D]一次送入 GPU。
NVIDIA Triton 将这一机制称为Dynamic Batching。
核心参数包括:
max_batch_size;- 最大排队时间;
- 请求并发量;
- 队列策略。
Batch 增大通常有利于吞吐,同时也可能增加请求等待时间。
所以需要通过实验搜索:
Batch Size × Queue Delay × Concurrency找到满足 latency budget 的最大吞吐配置。
7. 增加并发模型实例
如果单个模型实例没有充分占满 GPU,可以运行多个 model instance。
例如:
Request Queue │ ├── Model Instance 1 ──┐ ├── Model Instance 2 ──┼── GPU └── Model Instance 3 ──┘Triton 的instance_group就支持这种执行方式。
这种方法适用于:
- 单次推理 kernel 较小;
- 单模型 GPU 利用率较低;
- CPU/GPU pipeline 存在空洞;
- 多个请求具有足够并发量。
需要实际测量。
如果一个模型实例已经接近 GPU 计算或显存带宽上限,继续增加实例可能引起:
- 显存压力;
- kernel contention;
- context/scheduling overhead;
- latency 增加。
因此 instance 数量也是 benchmark 参数。
8. 使用异步 Pipeline 隐藏等待时间
推理流程通常包括:
CPU preprocessing ↓ Host → Device ↓ GPU inference ↓ Device → Host ↓ CPU postprocessing如果完全串行:
Request 1: CPU → H2D → GPU → D2H → CPU Request 2: CPU → H2D → GPU → ...GPU 和 CPU 都可能存在空闲阶段。
可以改成流水线:
时间 → Request A: CPU | H2D | GPU | D2H | Post Request B: CPU | H2D | GPU | D2H | Post Request C: CPU | H2D | GPU | D2H | Post常见优化包括:
- 异步预处理;
- pinned memory;
- asynchronous H2D/D2H;
- CUDA Streams;
- Buffer Pool;
- CPU preprocessing thread pool。
其目标是让:
CPU preprocessing 数据传输 GPU计算 后处理尽可能重叠。
9. 减少推理阶段本身的计算开销
9.1 正确使用推理模式
PyTorch 推理时通常应该使用:
model.eval()withtorch.inference_mode():output=model(x)model.eval()负责让 Dropout、BatchNorm 等模块进入正确的评估行为。
torch.inference_mode()会关闭 Autograd 相关工作,并进一步减少 view tracking、version counter 等开销。
两者承担不同职责,因此通常需要同时使用。
9.2 降低数值精度
根据硬件和模型精度要求,可以考虑:
- FP32;
- BF16;
- FP16;
- INT8;
- 更低比特量化。
例如:
FP32 ↓ FP16 / BF16 ↓ INT8通常可以减少:
- 显存占用;
- 内存带宽压力;
- 部分计算开销。
最终需要验证模型精度是否满足要求。
9.3 Kernel Fusion 和编译优化
多个小算子:
Op1 ↓ Op2 ↓ Op3可能产生多次 kernel launch 和中间内存读写。
如果能够融合成:
Fused Kernel就可以减少:
- kernel launch overhead;
- 中间 Tensor;
- 显存访问。
TensorRT、torch.compile等推理优化方案都会在不同程度上进行图优化或算子优化。
10. LLM 推理还可以使用 Continuous Batching
生成模型和普通分类模型存在一个重要差异:
不同请求的生成长度不同。
例如:
A: 生成 20 tokens B: 生成 500 tokens C: 生成 50 tokens传统 Static Batch 可能需要:
A 完成后等待 C 完成后等待 直到 B 完成这会浪费 Batch Slot。
Continuous Batching / Inflight Batching 会在每轮生成过程中动态管理请求:
Step 1: [A B C] Step 2: [A B C] ... A结束 下一轮: [D B C] ... C结束 下一轮: [D B E]已经结束的请求立即释放位置,新请求进入执行 Batch。
NVIDIA Triton 将这一机制用于 LLM inference,并明确说明这种持续重新组成 Batch 的方式能够提高吞吐和资源利用率。
11. LLM 推理还需要利用 KV Cache
自回归 Transformer 在第ttt步生成 token 时,历史 token 的 Key 和 Value 已经计算过。
如果每一步都重新计算:
token 1 token 1~2 token 1~3 ... token 1~t会产生大量重复计算。
KV Cache 保存历史的:
K1:t−1,V1:t−1 K_{1:t-1},V_{1:t-1}K1:t−1,V1:t−1
当前步骤只计算新 token 对应的:
Kt,Vt K_t,V_tKt,Vt
再与历史 Cache 一起执行 Attention。
这样可以显著减少 autoregressive decoding 中的重复计算。
如果大量请求共享相同 system prompt,还可以进一步使用 KV Cache Reuse,复用共同前缀对应的 Cache。
12. CPU 推理还需要注意线程过度订阅
如果模型运行在 CPU 上,还需要区分两层并发:
请求线程和模型算子内部的:
intra-op threads例如:
8 个请求线程 × 每个模型调用 16 个算子线程理论上可能产生大量竞争线程。
PyTorch 提供:
torch.set_num_threads(n)用于设置 CPU intra-op parallelism 的线程数量。
因此 CPU 场景需要联合调整:
请求线程数 × 模型实例数 × intra-op threads线程数量增加到一定程度后,CPU Core 已经饱和。继续增加线程会带来:
- context switch;
- cache miss;
- 调度开销;
- 内存带宽竞争。
最终吞吐可能下降。
13. 我会怎样设计一个实际推理服务
一个比较合理的架构是:
┌──────────────────┐ Request ────────>│ Request Threads │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ Thread-safe Queue│ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ Dynamic Batcher │ └────────┬─────────┘ │ ┌───────┴────────┐ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Worker / GPU│ │ Worker / GPU│ │ Instance 1 │ │ Instance 2 │ └──────┬──────┘ └──────┬──────┘ │ │ └───────┬─────────┘ ▼ ┌──────────────────┐ │ Response Queue │ └────────┬─────────┘ │ ▼ Client这里:
mutex / condition_variable保证 CPU 请求队列正确;- Scheduler 负责组成 Batch;
- Worker 数控制实际推理并行度;
- GPU 负责批量计算;
- LLM 场景增加 Continuous Batching 和 KV Cache;
- 整个系统通过 benchmark 确定最佳参数。
14. 怎样证明吞吐真的提高了
不能只看 GPU Utilization。
我会固定:
- 模型;
- 输入长度分布;
- 输出长度分布;
- 硬件;
- 精度;
- 请求数据;
然后逐步改变:
Concurrency Batch Size Queue Delay Model Instance Count CPU Thread Count Precision记录:
- Requests/s;
- Tokens/s;
- p50 latency;
- p95 latency;
- p99 latency;
- Queue Time;
- GPU Compute Time;
- GPU Utilization;
- CPU Utilization;
- 显存使用;
- 错误率。
例如:
| Batch | Throughput | p95 | GPU Util |
|---|---|---|---|
| 1 | 100 req/s | 20 ms | 35% |
| 4 | 260 req/s | 30 ms | 70% |
| 8 | 380 req/s | 55 ms | 90% |
| 16 | 410 req/s | 140 ms | 97% |
如果 SLA 是:
p95<100 ms p95 < 100\text{ ms}p95<100ms
那么 Batch 8 可能是更合理的配置。
最终优化目标应通过这种性能曲线确定。
15. 面试时可以压缩成下面这段
多线程同步我会先看共享状态和访问模式。如果多个线程修改同一个对象,就用 mutex;读多写少可以考虑 shared_mutex;生产者—消费者队列可以用 mutex 配合 condition_variable;简单计数器和状态位可以用 atomic。工程上重点是缩小临界区、避免持锁做 I/O 或 GPU 推理,并统一多把锁的获取顺序来防止死锁。
推理吞吐方面,我首先会做 profiling,判断瓶颈在 CPU、数据传输还是 GPU。GPU 利用率不足时,优先考虑 Dynamic Batching,把多个小请求合并成 Batch;然后根据资源情况测试多个 model instance 和异步 preprocessing/H2D/inference pipeline。推理阶段使用 eval 和 inference_mode,并根据精度要求使用 FP16、BF16、INT8 等优化。
如果是 LLM,还会重点使用 Continuous Batching 和 KV Cache。Continuous Batching 可以让已经完成的请求立即退出 Batch,新请求及时补入;KV Cache 可以避免每一步重新计算历史 token 的 Key 和 Value。
最后我会联合扫描 concurrency、batch size、queue delay 和 instance 数,在固定 p95/p99 latency SLA 下寻找最大 requests/s 或 tokens/s。线程数和 Batch 都属于需要实测确定的参数。
16. 来源
- cppreference —
std::lock_guard:RAII 方式管理 mutex。 - cppreference —
std::shared_mutex:支持共享读和独占写。 - cppreference —
std::condition_variable:用于 mutex 保护条件下的线程等待与通知。 - PyTorch Documentation —
torch.set_num_threads:控制 CPU intra-op parallelism。 - PyTorch Documentation —
torch.inference_mode:推理阶段关闭 Autograd 相关开销;文档同时说明仍需显式调用model.eval()。 - NVIDIA Triton Inference Server — Dynamic Batcher:将多个请求动态合并成 Batch,提高推理吞吐。
- NVIDIA Triton Inference Server — Instance Groups:允许同一模型配置多个并行执行实例。
- NVIDIA Triton Inference Server — Continuous / Inflight Batching:在 LLM 解码过程中持续重新组织 Batch。
- NVIDIA TensorRT / TensorRT-LLM — KV Cache 与 KV Cache Reuse:保存和复用历史 Key/Value,减少自回归生成中的重复计算。