上一篇给出了请求路径与线程共享两条坐标系。本篇只钉一条轴:多线程下谁跑事件循环、什么状态在进程内共享、什么必须跨进程复制——以及在 poller 线程里阻塞意味着什么。
常见误区有两个:把 HAProxy 想成「像 Nginx 一样每个 worker
一份 stick-table」;或把 nbthread 想成「像
Envoy Main
那样有一条专职控制面线程不碰连接」。两者都会把排障方向带偏——前者去进程间找「粘性丢失」却忘了自己开了多进程;后者去
Main 上找延迟,却发现 HAProxy 默认根本没有那条对称的
Main。
本文说明:nbthread / thread-group 如何组织
CPU;每线程事件循环与连接亲和;共享内存边界相对
nbproc;以及与 Nginx / Envoy 的对照句。
本文是「HAProxy / 数据面代理内核」系列第 2 篇(共 14 篇)。→ 系列目录
篇目 核心内容 第 1 篇 · 数据面全景 生态位与五条坐标系 第 2 篇 · 线程与调度 事件循环、共享边界、阻塞警告 第 3 篇 · Frontend Accept bind / accept / FD 第 8 篇 · Stick-table 容量、锁、peers 复制
版本锚定:HAProxy 3.4.3 Management Guide / Starter Guide / Configuration Manual(docs.haproxy.org/3.4/);源码树
haproxy/haproxytag v3.4.3。不粘贴伪造的show info/ 线程 dump。
一、单进程多线程:官方怎么钉死
Management Guide(3.4)开篇把 HAProxy 写成:
multi-threaded, event-driven, non-blocking daemon
要点不是口号,而是三条可检验不变量:
| 不变量 | 含义 | 排障后果 |
|---|---|---|
| 事件多路复用调度 | 用 poller 调度活动,而不是「每连接一 OS 线程」 | 单回调里阻塞 = 该线程上所有连接一起饿 |
| 默认单进程、每允许 CPU 一 worker 线程 | 除非显式改配置,流量摊到各线程,同一套事件循环 | ps 里常只有一个
haproxy(reload 时可能短暂并存旧进程) |
| 连接由单线程服务 | 限制跨线程依赖,追求近线性扩展 | 单连接尖刺看该线程,不要先平均所有 CPU |
Starter Guide 补充:多线程主要用于 SSL 或极高转发吞吐;并警告——在跨物理 CPU / 跨 NUMA 通信时,减少 HAProxy 使用的 CPU 数有时反而更好。这与「能绑多少核就绑多少」的直觉相反,却与缓存/中断局部性一致。
flowchart LR
subgraph proc ["Single process address space"]
T0["Thread 0<br/>event loop"]
T1["Thread 1<br/>event loop"]
T2["Thread 2<br/>event loop"]
ST["Shared stick-tables / stats"]
end
C0["Conn A"] --> T0
C1["Conn B"] --> T1
C2["Conn C"] --> T2
T0 --> ST
T1 --> ST
T2 --> ST
二、nbthread 与
thread-group
2.1 nbthread
全局 nbthread
指定工作线程数。不显式写时,常见行为是按进程被允许运行的 CPU
集合自动起步——具体自动策略随 3.2+
拓扑感知增强;强制写死 nbthread
会关掉「按本机拓扑自动选优」的那条路径(配置教程与
3.4 公告均强调:大机上优先理解自动策略,再手工覆盖)。
线程数必须与连接并发匹配:官方写明,要用满处理能力,连接数至少要到线程量级——长连接稀少时会出现「核很多、却喂不饱」的假空闲。
2.2 Thread-group(线程组)
Thread-group 把线程按局部性收成组,降低「远距离线程」之间的通信与争用面。版本脉络(以官方/发布说明为准,不编造本机数字):
- 2.7 引入线程组概念。
- 3.2 起加强 CPU 拓扑绑定与自动组织。
- 3.4:例如默认
max-threads-per-group从偏大值收到 16(经维护者实验权衡);过大单组会在 FD 层等路径上放大争用;cpu-affinity/cpu-policy等提供更细绑定;thread-group 硬上限抬高以服务超大机,但日常配置仍应围绕缓存域思考,而不是「一组塞满 64」。
对排障:stats 上部分 per-request 计数在 3.4 可按 thread-group 内部分片再聚合上报——报表数字仍是总和,但内部减少热点。这解释了「升级 3.4 后大机 HTTP/1 更稳」的一类机制叙述,却不能在没有本机对照实验时写成固定百分比结论。
2.3 cpu-map 与中断
Management Guide 性能段:网络密集时,把软中断与
HAProxy 钉在共享同一 L3
的不同核上,往往优于让它们抢同一核;cpu-map(配置)或
taskset(外部)是绑定入口。irq_balance
在这类负载上常被点名为「最差默认」。这些是部署层工具,不改变「连接钉在一线程」的语义。
三、每线程事件循环与调度器
每个工作线程跑自己的 poller 循环:等 I/O /
定时器,再跑到期的 task /
tasklet。内部时钟用于超时:空闲时也会周期性醒来校正漂移(Management
Guide 用 poll/epoll_wait 与
gettimeofday
叙述说明「空闲也有短周期唤醒」是正常现象)。
与站内 libevent 系列 的关系:Reactor 通识在那边;本篇只保留 HAProxy 特有的 多线程同一进程 + 共享表。调度器是优先级感知的(Starter Guide:priority-based, multi-threaded scheduler)——含义是不同类任务可排序,而不是「业务请求有 POSIX 实时优先级」。
源码阅读入口(v3.4.3,目录级):src/ 下与
task / polling / thread 相关的实现,以及
include/ 中线程上下文结构。具体函数名以该 tag
为准;本篇不伪造调用栈。
四、共享内存边界:相对
nbproc
4.1 线程之间(同进程)
同进程内,stick-table、大量配置对象、聚合用的 stats 等位于共享地址空间。正确性靠锁与局部数据结构;吞吐靠减少跨 thread-group 碰触与缩短持锁临界区(3.4 对 stick-table 释放路径等仍有争用相关改动)。共享 ≠ 无锁,也 ≠ 「读配置完全免费」。
4.2
进程之间(nbproc)
Management Guide 明文列出多进程限制,其中与本系列直接相关的是:
| 限制 | 含义 |
|---|---|
| Stick-tables are per process and are not shared between processes | 粘性/限流计数在进程间天然分裂 |
| Health checks per process | 上游收到的检查数随进程数倍增 |
maxconn / 队列 per-process |
必须按进程重算,否则打爆后端 |
| Peers section 同时只在单进程跑 | 复制拓扑不能「每进程各玩各的」却期望同一 peers 语义 |
| CLI 一次作用一个进程 | Runtime 操作要意识到目标进程 |
因此:现代默认叙事是
nbthread,不是靠 nbproc 横向扩
stick-table。 nbproc 仍可能出现在「CPU
极重的 SSL/压缩拆层」等实验拓扑(官方甚至描述过用 UNIX
socket + PROXY protocol
串两层),但那是刻意的进程边界,不是「多开几个就更快」。
Peers / reload
时的表复制,是「跨进程或跨节点对齐状态」的正路——第 8、12
篇展开。不要用「再开一个 nbproc」假装共享。
五、对照:Nginx 多进程与 Envoy Main/Worker
| 维度 | HAProxy(默认线程模型) | Nginx | Envoy |
|---|---|---|---|
| 并行单元 | 同进程多线程 | 多 worker 进程 | Main + 多 Worker 线程 |
| 连接亲和 | 单线程服务连接终生 | 通常钉在 accept 到的 worker | 钉在单个 Worker |
| 配置更新 | 新进程 reload + Runtime 热改子集 | reload / 共享内存区视模块 | xDS → TLS 快照 |
| 共享粘性状态 | 进程内 stick-table | 默认不跨 worker 共享同构表 | 集群状态经主线程编排进 TLS |
| 控制面线程 | 无对称「只跑 xDS 的 Main」 | master 管进程 | Main 跑 xDS/Admin |
对照句:
- 相对 Nginx:HAProxy 用地址空间共享换 stick-table/stats 一致性;代价是锁与「一线程阻塞伤一片」。
- 相对 Envoy:两者都「连接不迁移」;Envoy 把动态配置做成 Main→Worker 快照协议,HAProxy 把动态性拆成 Runtime 子集 + reload 全集。
开放争论(与第 1
篇对齐):进程隔离换简单推理,还是线程共享换粘性正确性?没有免费午餐——选
nbproc 就必须接受 Management Guide
那张限制表。
六、软警告:不要在 poller 线程里阻塞
HAProxy 的正确性假设是热路径非阻塞。在线程回调里做同步 DNS、大文件读、长临界区持锁、或调用可能堵死的外部库,会导致:
- 该线程上所有连接的定时器与 I/O 一起推迟;
- 表现为「随机超时 / 延迟尖刺」,火焰图却不像业务锁竞争;
- 多线程下问题被局部化到一核,平均 CPU 利用率看起来「并不高」。
扩展点(Lua、某些 SPOE 用法、自定义 check 等)若把阻塞工作做进 poller,等于放弃事件驱动前提。官方与社区排障直觉一致:先假设线程被堵,再假设上游慢——区分手段是会话年龄、线程 CPU、与上游 RTT 是否同量级(具体命令语义见第 13 篇;本篇不伪造输出)。
Watchdog 式「摸时间戳」在 Envoy 里是显式机制;HAProxy 侧则更依赖「超时全靠内部时钟 + 你别堵 poller」。工程间隙:论文式 lock-free/event-driven 叙述常省略扩展与日志 I/O 对尾延迟的贡献。
可检验的开放问题:
- 超大 thread-group 与「多组小群」在容器 cpuset 收缩时,谁的尾延迟更可预测?
- Stick-table 极高更新率下,锁外释放等 3.4 改动是否足以支撑目标 QPS——必须按表类型与 key 基数实测(第 8 篇)。
- 与 Envoy TLS 快照相比,Runtime 改权重的「生效可见性」如何在多线程下定义(第 11、13 篇)。
七、调度器与「任务」:热路径上还有谁
事件循环不只处理套接字可读可写。HAProxy 还把大量工作建模成 task / tasklet:超时检查、会话收尾、某些内部维护。优先级调度的工程含义是——在同一线程里,紧急的短任务可以插在大块工作之前,但没有任何魔法把阻塞的系统调用变成非阻塞。
因此排障时要建立第二直觉:CPU 不高却延迟高,可能是线程卡在锁或偶发阻塞 syscall;CPU 某一核打满,可能是该核上连接叠加了 SSL/压缩/重规则,而不是「全局 nbthread 不够」。加线程只在「有足够并发连接、且工作可并行」时有效;官方已提醒连接数要到线程量级,否则多核是空转。
与健康检查引擎的关系(第 9 篇预告):检查任务同样要进入调度器。检查过密或脚本型检查过重时,争用的不是「另一个进程的 worker」,而是同进程线程的时间片——这与 Nginx 把某些工作隔离在独立进程里的取舍不同。
Master-worker 模式下,master 负责拉起/回收 worker、协助 reload,仍然不是 Envoy 那种持续解析 xDS 的 Main 数据面旁路。把 master 的 CPU 毛刺当成「数据面锁竞争」会走错方向;把 worker 上的 Lua 阻塞当成「上游 RTT」同样会走错方向。
八、参考资料
规范 / 官方文档(A)
- HAProxy 3.4.3, Management
Guide:事件驱动与多线程开篇;连接单线程服务;
cpu-map/ 中断;nbproc限制表(含 stick-tables 不跨进程共享);reload 相关-x背景。 - HAProxy 3.4.3, Starter Guide:多线程适用场景与「先减核」的反直觉建议;调度器定位。
- HAProxy 3.4.x, Configuration
Manual:
nbthread、thread-group /cpu-map/cpu-affinity等全局关键字(以 3.4 文档树为准)。
源码(A)
haproxy/haproxytag v3.4.3:线程与调度相关src/、include/(目录级入口;具体符号随 tag 核对)。
维护者 / 发布说明(B)
- HAProxy 3.4 公告与
[ANNOUNCE] haproxy-3.4.0:max-threads-per-group默认、stats 分片、stick-table 争用相关叙述。
站内对照
实验台账
- 本篇无命令输出、无自测 QPS。引用发布说明中的性能叙述时保持为上游实验声明,不冒充本机结果。
九、小结
- 默认模型是单进程、每 CPU 工作线程、每线程事件循环;连接不跨线程迁移。
- Stick-table 在线程间共享、在
nbproc进程间不共享——这是 Management Guide 级硬边界。 - Thread-group / cpu-map 服务的是局部性与争用,不是换一套代理语义。
- Poller 上阻塞会把整线程连接拖进超时;扩展作者要把重活挪出热路径。
- Task 调度解释了「同线程上超时与 I/O 如何交错」;它不能拯救阻塞 syscall,也不能替代正确的进程/线程边界选择。
→ 上一篇:数据面全景 · 系列目录 · 下一篇:Frontend / bind / accept
读完这篇,下一步读什么
优先读同系列或同问题的下一篇,把单篇消费变成主题集群。
【HAProxy 数据面】Stick-table:键类型、容量、跨线程可见性与 peers/reload
说明 stick-table 的键类型、size/expire/store、nbthread 共享内存与 nbproc 不共享的边界,以及 peers 复制与 reload 继承;覆盖表满等容量失败模式。
【HAProxy 数据面】数据面全景:从 Frontend 到 Seamless Reload 的代理内核
定位 HAProxy 相对 network/56 配置运维、Nginx 多进程与 Envoy Filter/xDS 的生态位;钉住请求路径、线程共享、协议表示、动态运维、失败归因五条坐标系,并给出 14 篇阅读路线。
【HAProxy 数据面】Frontend / bind / accept:监听、FD 与接入路径
说明 HAProxy frontend 与 bind 如何落到监听套接字;accept 之后 FD 与线程的关系;SO_REUSEPORT / cpu-map 与多线程接入;以及 expose-fd listeners 作为 seamless reload 的前置,并点到 content switching 入口而不做 ACL 百科。
【HAProxy 数据面】Stream 与 Mux:连接之上的协议分层
厘清 connection → session → stream 与 mux 的分层;对比 TCP pass-through 与 HTTP mux;说明超时挂在哪一层对象上,以及失败归因如何区分连接级与流级;源码目录级指向 stream 与 mux_*。