核实完了。这个题我必须先把一件事说清楚,否则改造方向会错:"惊群""锁竞争"在 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
  醒来发现队列是空的,白跑一趟又回去睡。

  在高并发 + 多核(鲲鹏 92064 核、飞腾 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/)
Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐