
核实完了。这个题我必须先把一件事说清楚否则改造方向会错惊群和锁竞争在 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()├── worker1──┐ ├── worker2──┤ 这三个 worker 都【继承】了 master 的同一个 ├── worker3──┘ 监听 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内核哈希分流 │ 一行配置 │ ✅ 首选PHP7.3原生支持 │ ├──────────────────────────────┼──────────────────────────────────────┼────────────┼────────────────────────────┤ │ B.EPOLLEXCLUSIVE │ epoll 挂载加独占标志内核只唤醒一个 │ 源码 patch │ Linux4.5需自编译 │ ├──────────────────────────────┼──────────────────────────────────────┼────────────┼────────────────────────────┤ │ C.accept 锁用户态 mutex │ accept 前抢锁抢到才 accept │ 源码 patch │ 老内核兜底已过时 │ └──────────────────────────────┴──────────────────────────────────────┴────────────┴────────────────────────────┘ 结论先行优先用 A。它是 PHP 官方内置的listen.reuse_port一行配置搞定效果是从根源消除惊群不是缓解。B 是 A 的替代A 不可用时C 是历史方案不推荐。---2.方案 Alisten.reuse_port推荐从根源消除惊群2.1原理大白话 SO_REUSEPORT 允许多个进程各自 bind 同一个 IP端口。开启后PHP-FPM 不再让 worker 继承 master 的 fd而是每个 worker 自己 socketbindlisten每个 socket 在内核里有独立的 accept 队列。 新连接到达时内核按连接的四元组哈希源IP、源端口、目的IP、目的端口把它精确投递到其中一个 socket 的队列。所以 ▎ 一条连接只可能唤醒一个 worker——因为它只属于某一个socket 的队列其他 socket 的等待者根本不会被内核打扰。 惊群在机制层面就不存在了而不是唤醒了再抢。2.2配置完整;php-fpm.conf 或 www.conf 的 pool 配置里[www]listen127.0.0.1:9000listen.reuse_porton;★关键一行PHP7.3;配合合理的进程数worker 数 ≈CPU 核数 pmstaticpm.max_children16;鲲鹏/飞腾多核按核数调 大白话listen.reuse_porton 打开后FPM 会为每个 worker 建一个独立的监听 socket让内核来分流。这一行就是整个方案的全部配置改动。2.3验证三步确认生效 #1.确认内核支持 SO_REUSEPORTLinux3.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.方案 BEPOLLEXCLUSIVE源码级 patchA 的替代3.1原理 EPOLLEXCLUSIVE 是 Linux4.5给 epoll 加的一个挂载标志当多个进程各自的 epoll 实例监听同一个 fd 时加了这个标志的内核保证一次事件只唤醒其中一个 epoll 等待者而不是全部。 这和 PHP-FPM 的场景严丝合缝——每个worker 有自己的 epoll 实例都在监听同一个 listen fd。加上 EPOLLEXCLUSIVE惊群就消了。3.2为什么 PHP-FPM 默认没用它 我查了 sapi/fpm/fpm/events/epoll.cPHP-FPM 的 epoll 后端没有使用 EPOLLEXCLUSIVE它的 epoll 后端只标了.support_edge_trigger1即支持边缘触发。原因主要是EPOLLEXCLUSIVE 需要较新的内核4.5PHP 要兼容老内核所以默认没加。但在信创的新内核上麒麟/openEuler 基本都是4.19/5.x这个约束不存在。3.3完整 patch改 sapi/fpm/fpm/events/epoll.c 找到 fpm_event_epoll_add 函数epoll_ctl 的 EPOLL_CTL_ADD 处加一个标志位// 原代码示意实际以你版本为准staticintfpm_event_epoll_add(structfpm_event_s*ev){structfpm_event_epoll_s*eev...;structepoll_evente;e.eventsEPOLLIN;if(ev-flagsFPM_EV_EDGE){e.events|EPOLLET;// 边缘触发}e.data.ptreev;returnepoll_ctl(epfd,EPOLL_CTL_ADD,ev-fd,e);}改成staticintfpm_event_epoll_add(structfpm_event_s*ev){structfpm_event_epoll_s*eev...;structepoll_evente;e.eventsEPOLLIN|EPOLLEXCLUSIVE;// ★加这一行独占唤醒if(ev-flagsFPM_EV_EDGE){e.events|EPOLLET;}e.data.ptreev;returnepoll_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而是 scoreboardFPM 的状态共享内存。这是另一个独立优化解决的是锁竞争不是惊群。4.1现在的问题spinlock 在高频更新下烧 CPU PHP-FPM 的 fpm_scoreboard 是一块共享内存每个 worker 在里面有自己的一块structfpm_scoreboard_proc_s记录自己的 PID、状态、请求数、耗时等。master 进程和 php-fpm status 页面会读这些数据。 现在的保护方式我核实过源码是 spinlock// fpm_scoreboard.h 里的结构核心字段示意structfpm_scoreboard_proc_s{union{atomic_t lock;// ★自旋锁字段chardummy[16];// padding隔离缓存行};// ... 状态字段 ...structfpm_scoreboard_s*scoreboard;pid_t pid;intrequest_stage;intrequests;// 请求计数structtimevalaccepted,duration;charrequest_uri[128];// ...};写的时候加锁// fpm_scoreboard.c 里更新请求计数时示意fpm_spinlock(proc-lock,0);// CAS 抢锁atomic_cmp_set(lock, 0, 1)proc-requests;fpm_unlock(proc-lock);// lock 0fpm_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或 PHP8.1的 Zend/zend_atomic.h里底层是 ARM64 的 LDADD/LDADDAL 指令天然原子不需要锁。 改造2状态字段用 CAS 做状态机 状态request_stage的切换本质是从 A 状态变到 B 状态用 CAS 表达失败就重试// 无锁状态切换只有当前是 IDLE 才能切成 BUSYstaticinlineintfpm_scoreboard_proc_try_busy(atomic_t*stage,intidle_val,intbusy_val){returnatomic_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 字节缓存行structfpm_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 独占一条缓存行彻底隔离。 改造4master 读端不加锁读自己的副本 master/status 读 scoreboard 时读到的是某一瞬间的快照。无锁化后读端直接用原子读atomic_load不参与锁// 读端原子读不抢锁intreqatomic_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-n100000-c500http://127.0.0.1/bench.php5.2观测惊群/锁竞争是否消除关键指标 # 压测同时另一个终端看内核态 CPU 和上下文切换 vmstat1# 关键看两列#sy——内核态 CPU 占用。惊群重时 sy 很高大量时间花在内核唤醒上#cs——每秒上下文切换。惊群/锁竞争重时 cs 爆表改造前后对比的判读 ┌──────────────────────┬───────────────────┬──────────┐ │ 指标 │ 惊群/锁竞争严重时 │ 修复后 │ ├──────────────────────┼───────────────────┼──────────┤ │ sy │ 高比如30% │ 明显下降 │ ├──────────────────────┼───────────────────┼──────────┤ │ cs │ 每秒几万~几十万 │ 显著下降 │ ├──────────────────────┼───────────────────┼──────────┤ │ 吞吐Requests/sec │ 上不去 │ 提升 │ ├──────────────────────┼───────────────────┼──────────┤ │ 平均延迟 │ 抖动大 │ 平稳 │ └──────────────────────┴───────────────────┴──────────┘5.3进阶用 bpftrace 直接数唤醒次数如果想拿出硬证据用 bpftrace 统计 epoll_wait 被唤醒但没拿到连接空唤醒的次数 sudo bpftrace-e kprobe:ep_poll_callback{// 统计 epoll 回调唤醒次数wakeupscount();} 具体 kprobe 点因内核版本而异这里是示意核心思路是数内核唤醒了多少次 epoll 等待者修复后这个数字应该趋近实际连接数而不是它的数倍。---6.完整落地清单照做 #第1步配置层消除 accept 惊群方案 A#php-fpm.conf/www.conf#listen.reuse_portonsudo 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.eventsEPOLLIN|EPOLLEXCLUSIVE;# 重编部署 #第3步scoreboard 无锁化方案第4节# 改 fpm_scoreboard.h/fpm_scoreboard.c #-计数字段改 atomic_add #-状态切换改 atomic_cmp_set #-proc 结构体__attribute__((aligned(64)))# 重编部署 #第4步验证# 终端1wrk-t16-c512-d60s http://127.0.0.1/bench.php# 终端2vmstat1# 观察 sy、cs 下降吞吐上升---7.常见坑速查 ┌─────────────────────────────────────┬─────────────────────────────────────────────────────────────────────┐ │ 坑 │ 解法 │ ├─────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤ │ reuse_port 没生效ss 还是单 socket │ 确认 PHP ≥7.3、listen.reuse_porton 写在正确 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 branchPHP-8.4fpm_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.cfpm_event_add/FPM_EV_READ 分发逻辑(https://github.com/johannes/php-src/blob/f08060a48fadf079e860be73584ac87747dc59d6/sapi/fpm/fpm/fpm_events.c)-php-src:fpm_events.hFPM_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/)