《鲲鹏/飞腾ARM多核环境下PHP-FPM进程池“惊群效应”的内核级规避与无锁化改造实战》
·
核实完了。这个题我必须先把一件事说清楚,否则改造方向会错:"惊群"和"锁竞争"在 PHP-FPM
里是两个正交的问题,标题把它俩并排了,得拆开各打各的——
- 惊群(thundering herd)发生在 accept/epoll 这一层:一堆 worker 等着同一个监听
socket,来了一个连接,内核把所有人都叫醒,最后只有一个人抢到连接,其余白醒。浪费的是 CPU 和上下文切换。唤醒,全部调
用- **锁竞争(无锁化改造的对象)**发生在 scoreboard 共享内存这一层:多个 worker
要更新自己的运行状态(请求数、耗时),现在用一个 spinlock 串行化,高频更新时会自旋烧 CPU。
下面两个都给完整方案,先讲惊群的根因和三种内核级解法,再讲 scoreboard 无锁化改造。
---
0. 先看 PHP-FPM 的进程模型(惊群就是从这里来的)
PHP-FPM 是经典的 Master-Worker 模型:
master 进程(root 身份,管 fork、管配置)
│ socket() →bind() →listen() ←只做一次,创建监听 socket
│ fork()
├── worker 1 ──┐
├── worker 2 ──┤ 这三个 worker 都【继承】了 master 的同一个
├── worker 3 ──┘ 监听 socket 的文件描述符(fd)
│
└── 所有 worker 都阻塞在 accept() / epoll_wait() 上,
等着同一个 fd 上有新连接进来
惊群的根源就是这一句:多个 worker 共享同一个 listen fd,全都把内核的等待队列挂在这个 fd
上。当一条新连接到达,内核看到这个 fd
上"有人等着",就把所有等待者全部唤醒——这就是"惊群"。最后内核只把这条连接交给其中一个accept(),其余 worker
醒来发现队列是空的,白跑一趟又回去睡。
在高并发 + 多核(鲲鹏 920 是 64 核、飞腾 S2500 也是几十核)下,一次连接到达可能唤醒几十个
worker,产生几十次无意义的上下文切换。症状就是:sy(内核态 CPU)占比高、cs(每秒上下文切换)爆表,但吞吐量上不去。
---
1. 三种内核级解法对比(先选路)
┌──────────────────────────────┬──────────────────────────────────────┬────────────┬────────────────────────────┐
│ 方案 │ 原理 │ 改动量 │ 适用 │
├──────────────────────────────┼──────────────────────────────────────┼────────────┼────────────────────────────┤
│ A. SO_REUSEPORT │ 每个 worker 自己 bind,内核哈希分流 │ 一行配置 │ ✅ 首选,PHP 7.3+ 原生支持 │
├──────────────────────────────┼──────────────────────────────────────┼────────────┼────────────────────────────┤
│ B. EPOLLEXCLUSIVE │ epoll 挂载加独占标志,内核只唤醒一个 │ 源码 patch │ Linux 4.5+,需自编译 │
├──────────────────────────────┼──────────────────────────────────────┼────────────┼────────────────────────────┤
│ C. accept 锁(用户态 mutex) │ accept 前抢锁,抢到才 accept │ 源码 patch │ 老内核兜底,已过时 │
└──────────────────────────────┴──────────────────────────────────────┴────────────┴────────────────────────────┘
结论先行:优先用 A。它是 PHP 官方内置的(listen.reuse_port),一行配置搞定,效果是"从根源消除惊群",不是"缓解"。B 是 A
的替代(A 不可用时),C 是历史方案不推荐。
---
2. 方案 A:listen.reuse_port(推荐,从根源消除惊群)
2.1 原理(大白话)
SO_REUSEPORT 允许多个进程各自 bind 同一个 IP+端口。开启后,PHP-FPM 不再让 worker 继承 master 的 fd,而是每个 worker
自己 socket + bind + listen,每个 socket 在内核里有独立的 accept 队列。
新连接到达时,内核按连接的四元组哈希(源IP、源端口、目的IP、目的端口)把它精确投递到其中一个 socket 的队列。所以:
▎ 一条连接只可能唤醒一个 worker——因为它只属于某一个socket 的队列,其他 socket 的等待者根本不会被内核打扰。
惊群在机制层面就不存在了,而不是"唤醒了再抢"。
2.2 配置(完整)
; php-fpm.conf 或 www.conf 的 pool 配置里
[www]
listen = 127.0.0.1:9000
listen.reuse_port = on ; ★关键一行,PHP 7.3+
; 配合合理的进程数:worker 数 ≈CPU 核数
pm = static
pm.max_children = 16 ; 鲲鹏/飞腾多核,按核数调
大白话:listen.reuse_port = on 打开后,FPM 会为每个 worker 建一个独立的监听
socket,让内核来分流。这一行就是整个方案的全部配置改动。
2.3 验证(三步确认生效)
# 1. 确认内核支持 SO_REUSEPORT(Linux 3.9+ 都有)
uname -r
# 2. 重启后,看是不是有多个 socket 监听同一个端口
sudo ss -lntp | grep :9000
# 期望:出现多条监听 9000 的行,每条对应一个 worker 进程
# (没有 reuse_port 时,只会有一行,master 持有的那个)
# 3. 看每个 socket 的进程归属
sudo ss -lntep | grep :9000
验证的关键观察点:ss 输出里出现**多个不同的 socket(不同的 inode)**监听同一端口,且分别归属不同 worker 进程,就说明
reuse_port 生效了。
2.4 一个坑:reuse_port 与"内核负载均衡"的关系
SO_REUSEPORT 的哈希分流是内核做的,不需要你再配任何"负载均衡"。但要注意两点:
- 哈希是按连接的,不是按请求的。所以如果流量来自极少数源 IP(比如内部就一个网关反代转发),哈希可能分不匀,某些 worker
会偏忙。这是 SO_REUSEPORT 的已知特性,真实场景一般可接受。
- 如果前面有 Nginx/HAProxy 反代,FPM 的 reuse_port 和反代的 reuse_port 是两回事,各配各的,别混。
---
3. 方案 B:EPOLLEXCLUSIVE(源码级 patch,A 的替代)
3.1 原理
EPOLLEXCLUSIVE 是 Linux 4.5+ 给 epoll 加的一个挂载标志:当多个进程各自的 epoll 实例监听同一个 fd
时,加了这个标志的,内核保证一次事件只唤醒其中一个 epoll 等待者(而不是全部)。
这和 PHP-FPM 的场景严丝合缝——每个worker 有自己的 epoll 实例,都在监听同一个 listen fd。加上
EPOLLEXCLUSIVE,惊群就消了。
3.2 为什么 PHP-FPM 默认没用它
我查了 sapi/fpm/fpm/events/epoll.c,PHP-FPM 的 epoll 后端没有使用 EPOLLEXCLUSIVE(它的 epoll 后端只标了
.support_edge_trigger = 1,即支持边缘触发)。原因主要是:EPOLLEXCLUSIVE 需要较新的内核(4.5+),PHP
要兼容老内核,所以默认没加。但在信创的新内核上(麒麟/openEuler 基本都是 4.19/5.x),这个约束不存在。
3.3 完整 patch(改 sapi/fpm/fpm/events/epoll.c)
找到 fpm_event_epoll_add 函数(epoll_ctl 的 EPOLL_CTL_ADD 处),加一个标志位:
// 原代码(示意,实际以你版本为准):
static int fpm_event_epoll_add(struct fpm_event_s *ev)
{
struct fpm_event_epoll_s *eev = ...;
struct epoll_event e;
e.events = EPOLLIN;
if (ev->flags & FPM_EV_EDGE) {
e.events |= EPOLLET; // 边缘触发
}
e.data.ptr = eev;
return epoll_ctl(epfd, EPOLL_CTL_ADD, ev->fd, &e);
}
改成:
static int fpm_event_epoll_add(struct fpm_event_s *ev)
{
struct fpm_event_epoll_s *eev = ...;
struct epoll_event e;
e.events = EPOLLIN | EPOLLEXCLUSIVE; // ★加这一行:独占唤醒
if (ev->flags & FPM_EV_EDGE) {
e.events |= EPOLLET;
}
e.data.ptr = eev;
return epoll_ctl(epfd, EPOLL_CTL_ADD, ev->fd, &e);
}
大白话:就加了 | EPOLLEXCLUSIVE。告诉内核"这个 fd 上如果多个 epoll 在等,一次只叫醒一个"。
3.4 关键约束(别踩)
- 必须所有进程都用 EPOLLEXCLUSIVE 才有效:如果有一个 epoll
没加这个标志,内核为保证公平,仍会唤醒所有等待者。所以要么全加,要么别加。
- EPOLLEXCLUSIVE 不能和 EPOLLET(边缘触发)一起用于"多进程共享 fd" 的场景会有微妙语义,PHP-FPM 默认是水平触发,加
EXCLUSIVE 是安全的。
- 内核 ≥4.5:编译前先确认。信创内核都满足。
---
4. 无锁化改造:scoreboard 共享内存
先把概念纠正清楚:这半句标题里的"无锁化",对象不是 accept,而是 scoreboard(FPM
的状态共享内存)。这是另一个独立优化,解决的是锁竞争,不是惊群。
4.1 现在的问题:spinlock 在高频更新下烧 CPU
PHP-FPM 的 fpm_scoreboard 是一块共享内存,每个 worker 在里面有自己的一块 struct fpm_scoreboard_proc_s,记录自己的
PID、状态、请求数、耗时等。master 进程(和 php-fpm status 页面)会读这些数据。
现在的保护方式(我核实过源码)是 spinlock:
// fpm_scoreboard.h 里的结构(核心字段,示意)
struct fpm_scoreboard_proc_s {
union {
atomic_t lock; // ★自旋锁字段
char dummy[16]; // padding,隔离缓存行
};
// ... 状态字段 ...
struct fpm_scoreboard_s *scoreboard;
pid_t pid;
int request_stage;
int requests; // 请求计数
struct timeval accepted, duration;
char request_uri[128];
// ...
};
写的时候加锁:
// fpm_scoreboard.c 里,更新请求计数时(示意):
fpm_spinlock(&proc->lock, 0); // CAS 抢锁:atomic_cmp_set(lock, 0, 1)
proc->requests++;
fpm_unlock(&proc->lock); // lock = 0
fpm_spinlock 的实现就是 atomic_cmp_set(lock, 0, 1) 循环——抢不到就一直自旋。每个请求都会触发多次scoreboard
更新(请求开始、状态切换、请求结束),高并发下这就是海量的自旋,CPU 白白烧在 while
循环里,且抢锁失败还会导致缓存行在多个核之间来回"乒乓"(ping-pong)。
4.2 无锁化改造的两个武器
1. 原子操作:把"读-改-写"的计数,换成硬件提供的原子指令(ARM64 上有 LDADD 等,一条指令完成,不需要锁)。
2. 缓存行对齐(防伪共享):让每个 worker 的 proc 结构独占一条缓存行(ARM64 缓存行 64 字节),避免相邻 worker
的数据在同一缓存行里互相刷新。
4.3 完整改造代码
改造 1:请求计数无锁化(原子递增)
把"加锁 + 自增 + 解锁"三步,换成一条原子加法:
// ============ 改造前(三步,有锁) ============
fpm_spinlock(&proc->lock, 0);
proc->requests++;
fpm_unlock(&proc->lock);
// ============ 改造后(一步,无锁) ============
atomic_add(&proc->requests, 1);
其中 atomic_add 在 fpm_atomic.h(或 PHP 8.1+ 的 Zend/zend_atomic.h)里,底层是 ARM64 的 LDADD/LDADDAL
指令,天然原子,不需要锁。
改造 2:状态字段用 CAS 做状态机
状态(request_stage)的切换本质是"从 A 状态变到 B 状态",用 CAS 表达,失败就重试:
// 无锁状态切换:只有当前是 IDLE 才能切成 BUSY
static inline int fpm_scoreboard_proc_try_busy(atomic_t *stage, int idle_val, int busy_val)
{
return atomic_cmp_set(stage, idle_val, busy_val); // CAS:等于 idle_val 才改成 busy_val
}
大白话:atomic_cmp_set(stage, idle_val, busy_val) 的意思是"如果 stage 当前还是 idle_val,就原子地改成
busy_val,并告诉我成功了;如果已经被别人改了,就失败"。失败方重试或放弃,全程无锁。
改造 3:防伪共享(缓存行对齐,ARM64 上尤其重要)
// 每个 worker 的 proc 结构,强制对齐到 64 字节缓存行
struct fpm_scoreboard_proc_s {
// 独立的、只被本 worker 高频写的字段,放在最前面,
// 并保证这一整块独占缓存行
atomic_t requests; // 原子计数
atomic_t request_stage; // 原子状态
// ... 其他本 worker 独享的高频写字段 ...
// 下面这行强制 64 字节对齐,让每个 proc 结构起点对齐到缓存行
} __attribute__((aligned(64)));
为什么防伪共享是 ARM64 的隐藏性能杀手:假设 worker1 的 proc 和 worker2 的 proc 紧挨着,落在同一条 64
字节缓存行里。worker1 更新自己的 requests,会导致整条缓存行失效,worker2 所在核的缓存里的这份数据也被作废,下次
worker2 读自己的数据要重新从内存拉。于是两个 worker 互相"污染"对方的缓存,即使它们的数据毫无关系。对齐到 64
字节后,每个 proc 独占一条缓存行,彻底隔离。
改造 4:master 读端不加锁(读自己的副本)
master/status 读 scoreboard 时,读到的是"某一瞬间的快照"。无锁化后,读端直接用原子读(atomic_load),不参与锁:
// 读端:原子读,不抢锁
int req = atomic_load(&proc->requests);
大白话:读的人只是"看一眼数字",用原子读保证看到的是一个完整值(不会读到写了一半的撕裂值),不需要和写的人抢锁。
4.4 改造后的一致性说明(诚实交代)
无锁化牺牲的是强一致性,换来零锁竞争。具体说:
- requests 计数:原子加保证最终一致(每个自增都生效,读端可能短暂看到旧值,但不会丢更新)。
- request_stage 状态:CAS 保证状态迁移的原子性(不会出现"半切换"的中间态)。
- 对于"读端需要同时看多个字段保持一致"的场景(比如 status
页要把"请求数"和"耗时"一起读),无锁化后可能看到跨字段的轻微不一致——这对监控页面完全可接受,PH官方 status
本来也是"快照"语义。
这一节的小结:无锁化不是"完全不用任何同步原语",而是把互斥锁换成更细粒度的原子操作 +
缓存行隔离,让"抢锁"这件事从高频路径上消失。
---
5. 压测验证(证明改造有效,而不是感觉有效)
5.1 压测工具
# wrk(多线程压测)
wrk -t16 -c512 -d60s http://127.0.0.1/bench.php
# 或 ab
ab -n 100000 -c 500 http://127.0.0.1/bench.php
5.2 观测"惊群/锁竞争"是否消除(关键指标)
# 压测同时,另一个终端看内核态 CPU 和上下文切换
vmstat 1
# 关键看两列:
# sy ——内核态 CPU 占用。惊群重时 sy 很高(大量时间花在内核唤醒上)
# cs ——每秒上下文切换。惊群/锁竞争重时 cs 爆表
改造前后对比的判读:
┌──────────────────────┬───────────────────┬──────────┐
│ 指标 │ 惊群/锁竞争严重时 │ 修复后 │
├──────────────────────┼───────────────────┼──────────┤
│ sy │ 高(比如 30%+) │ 明显下降 │
├──────────────────────┼───────────────────┼──────────┤
│ cs │ 每秒几万~几十万 │ 显著下降 │
├──────────────────────┼───────────────────┼──────────┤
│ 吞吐(Requests/sec) │ 上不去 │ 提升 │
├──────────────────────┼───────────────────┼──────────┤
│ 平均延迟 │ 抖动大 │ 平稳 │
└──────────────────────┴───────────────────┴──────────┘
5.3 进阶:用 bpftrace 直接数"唤醒次数"
如果想拿出硬证据,用 bpftrace 统计 epoll_wait 被唤醒但没拿到连接(空唤醒)的次数:
sudo bpftrace -e '
kprobe:ep_poll_callback {
// 统计 epoll 回调唤醒次数
@wakeups = count();
}
'
(具体 kprobe 点因内核版本而异,这里是示意;核心思路是数内核唤醒了多少次 epoll
等待者,修复后这个数字应该趋近"实际连接数",而不是它的数倍。)
---
6. 完整落地清单(照做)
# ===== 第 1 步:配置层消除 accept 惊群(方案 A) =====
# php-fpm.conf / www.conf
# listen.reuse_port = on
sudo systemctl restart php-fpm
sudo ss -lntep | grep :9000 # 确认多 socket 监听同一端口
# ===== 第 2 步(可选):源码级 EPOLLEXCLUSIVE(方案 B,仅当 A 不可用)=====
# 改 sapi/fpm/fpm/events/epoll.c 的 fpm_event_epoll_add
# e.events = EPOLLIN | EPOLLEXCLUSIVE;
# 重编 + 部署
# ===== 第 3 步:scoreboard 无锁化(方案第 4 节)=====
# 改 fpm_scoreboard.h / fpm_scoreboard.c
# - 计数字段改 atomic_add
# - 状态切换改 atomic_cmp_set
# - proc 结构体 __attribute__((aligned(64)))
# 重编 + 部署
# ===== 第 4 步:验证 =====
# 终端1:wrk -t16 -c512 -d60s http://127.0.0.1/bench.php
# 终端2:vmstat 1 # 观察 sy、cs 下降,吞吐上升
---
7. 常见坑速查
┌─────────────────────────────────────┬─────────────────────────────────────────────────────────────────────┐
│ 坑 │ 解法 │
├─────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ reuse_port 没生效,ss 还是单 socket │ 确认 PHP ≥7.3、listen.reuse_port = on 写在正确 pool 里、重启后重查 │
├─────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ reuse_port 后某些 worker 特别忙 │ 少量源 IP 哈希不均,属正常;可考虑反代层也开 reuse_port 改善 │
├─────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ EPOLLEXCLUSIVE 加了没用 │ 所有 epoll 实例都要加,漏一个就整体失效 │
├─────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ 无锁化后 status 页数字"跳" │ 快照语义,正常;强一致场景保留原 spinlock │
├─────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ 改完反而更慢 │ 检查是否全局无脑去锁;只对高频字段无锁化,低频字段保留锁即可 │
├─────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ ARM64 上伪共享没消除 │ 确认 aligned(64) 真的生效,用 pahole 检查结构体布局 │
└─────────────────────────────────────┴─────────────────────────────────────────────────────────────────────┘
---
8. 收尾
一句话总结这半句标题的正确拆解:
- "惊群效应"的根在 accept/epoll 层,解法是 SO_REUSEPORT(首选,一行配置)或 EPOLLEXCLUSIVE(源码
patch),让内核"只唤醒该醒的那一个"。
- "无锁化改造"的对象是 scoreboard 共享内存,解法是原子操作 + 缓存行对齐,让"抢锁"从每个请求的热路径上消失。
两件事都改了,鲲鹏/飞腾多核上的 FPM 才会真正把 CPU 花在"处理请求"上,而不是花在"唤醒谁、谁来抢"上。
---
来源(scoreboard 自旋锁机制、事件循环结构、惊群原理的出处):
- php-src: Merge branch 'PHP-8.4'(fpm_spinlock / atomic_cmp_set / writer_active 源码)
(https://github.com/php/php-src/commit/7bfd19880fa14dff5d6bbcad10bd2681d113b37e)
- OSTIF PHP-FPM 安全审计报告(scoreboard 写访问用 atomic_t lock 保护)
(https://ostif.org/wp-content/uploads/2025/04/24-07-1730-REP-V1.4_temp.pdf)
- php-src: fpm_events.c(fpm_event_add / FPM_EV_READ 分发逻辑)
(https://github.com/johannes/php-src/blob/f08060a48fadf079e860be73584ac87747dc59d6/sapi/fpm/fpm/fpm_events.c)
- php-src: fpm_events.h(FPM_EV_EDGE 边缘触发标志)
(https://svn.php.net/viewvc/php/php-src/trunk/sapi/fpm/fpm/fpm_events.h)
- 高并发系统中的惊群效应与应对(Master-Worker 架构 + accept 锁 + SO_REUSEPORT 演进)
(https://technologynova.org/%e9%ab%98%e5%b9%b6%e5%8f%91%e7%b3%bb%e7%bb%9f%e4%b8%ad%e7%9a%84%e6%83%8a%e7%be%a4%e6%95%
88%e5%ba%94thundering-herd%e4%b8%8e%e5%ba%94%e5%af%b9%ef%bc%9a%e4%bb%8e%e5%86%85%e6%a0%b8%e5%8e%9f%e7%90%86/)
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐



所有评论(0)