高性能 PHP 专家调优:论如何通过调整 CPU 亲和性(Affinity)提升 Swoole 处理器的计算吞吐量

(背景音:略显嘈杂的机房风扇声,混合着键盘敲击的节奏)

各位好,我是你们今天的高性能 PHP 导师。在这个脚本语言被贴上“慢”标签的年代,我是那个试图给 PHP 打上“生化变异”补丁的人。

今天我们要聊的话题非常硬核,也非常……稍微有点“费脑细胞”。我们将深入操作系统的底层,去触碰那个平时看不见、摸不着,但每秒钟都在疯狂跳动的核心——CPU

而我们要用的钥匙,叫作 CPU 亲和性(CPU Affinity)

别急,别急着划走。我知道,当你在写代码时,你脑子里想的是 foreacharray_map,或者是 yield。你很少会去想你的 CPU 核心是不是因为被过度拥挤而正在“崩溃”。

我们要聊的是如何通过“锁死”CPU 核心资源,来榨干它的每一滴算力,特别是当我们在使用 Swoole 这种高性能框架时。

准备好了吗?让我们把后台的那些无关进程全部 Kill 掉,把服务器风扇开到最大档,我们来聊聊如何让 CPU 变得更像一台只有一条命的赛车引擎。


第一部分:CPU 的“多动症”与 Swoole 的“聚会”

想象一下,你是一家餐厅的大厨(这就是你的 PHP 进程)。你有一把切菜的刀(CPU 核心)。如果你只有一把刀,你切完胡萝卜必须切土豆,切完土豆切肉。你的切菜动作在刀(CPU)和食材(任务)之间来回切换。

这就好比你只有一个厨师,但他需要同时应付四个桌子的订单。

在操作系统层面,这就是“上下文切换”。听起来很高级对吧?其实就是 CPU 偷懒。它觉得:“嘿,我现在正在处理进程 A 的数据,突然来了进程 B,我得先把 A 暂存一下,把大脑腾出来给 B 用。”

这很浪费。 非常浪费。就像你在写代码写嗨了(此时 CPU 在处理 fibonacci 算法),突然系统弹出一个通知(比如检查一下进程状态),CPU 就得停下那昂贵的计算,把寄存器里的值保存到内存里,然后去处理那个通知。

对于计算密集型任务,尤其是 Swoole 这种长连接、高并发的场景,这种切换就是毁灭性的。

Swoole 是基于多进程模型运行的。默认情况下,Linux 的调度器是个热心的居委会大妈,她会看到你的 4 个 Worker 进程都在排队等着 CPU,于是她大喊一声:“大家都别抢,咱们轮流来!”

结果就是:Worker 1 刚热身完(数据加载到 L1 缓存),结果大妈说:“该 Worker 2 上场了!”

Worker 1 的数据被踢出了缓存,Worker 2 的数据被加载进来。下一秒,大妈又喊:“轮到 Worker 1 了!” Worker 1 的数据又得重新加载。

这就是我们在性能测试中看到的诡异现象:CPU 占用率明明高达 80%,但 QPS(每秒查询率)却上不去。为什么?因为 CPU 核心大部分时间都在忙着“腾地方”,而不是“干活”。

这时候,CPU 亲和性 就登场了。

第二部分:什么是“锁死”?

亲和性,简单来说,就是告诉操作系统:“嘿,这位仁兄(进程),你就别乱跑了。你的灵魂(数据)就绑定在这个物理核心上。无论发生什么,除了你死(进程崩溃)或者我杀(服务重启),你别想换核心。”

这就像是把你的卧室门锁死,把你常用的书和床铺(缓存数据)放在手边,让你不需要每次都要去公共图书馆(内存/缓存)找资料。

为什么这能提升吞吐量?

这里有几个关键的技术点,我给你们拆解成咱们都能听懂的“段子”:

  1. 缓存局部性:
    CPU 的缓存速度比内存快成千上万倍。当我们把一个进程固定在某个核心时,该进程访问的数据就会一直在该核心的缓存里“赖着不走”。没有上下文切换,就没有缓存失效,CPU 就能一直享受“零延迟”的快感。

  2. 减少 TLB(页表缓冲)失效:
    TLB 是用来把虚拟内存地址翻译成物理地址的。上下文切换会导致 TLB 大量失效,需要重新查询。锁死亲和性,能最大程度保留 TLB 的命中率。

  3. 避免内存带宽争抢:
    在 NUMA(非统一内存访问)架构的服务器上,CPU 0 和 CPU 1 访问自己本地内存很快,但访问远程内存就慢得像蜗牛。如果你强制把 CPU 0 上的进程切换到 CPU 2 上去,它就得跨总线去抢内存。亲和性优化就是确保进程只访问本地内存。

第三部分:实战演练——如何给 Swoole 进程“上枷锁”

好,理论讲完了,现在我们来点实际的。既然 PHP 是解释型语言,它没有像 C++ 那样原生的 pthread_setaffinity_np 接口(虽然你可以通过扩展来实现,但这玩意儿编译起来能把人搞疯)。

在生产环境,最优雅、最通用的做法是利用 Linux 的 taskset 命令,或者在启动脚本中通过 numactl 来绑定。

场景设定

我们要跑一个计算密集型的服务。假设我们有一台 8 核心的服务器。

目标:

  1. 启动 4 个 Swoole Worker 进程。
  2. 确保 Worker 1 绑定在 CPU 0 上。
  3. 确保 Worker 2 绑定在 CPU 1 上。
  4. 以此类推。

代码示例:计算密集型任务

首先,我们写一个简单的 Server,里面包含一个会死循环的函数。

<?php
// server.php
// 这是一个纯粹的 CPU 磨损者,没有任何 I/O 操作

// 禁用 Swoole 的自动加载器优化,为了模拟真实的计算环境
// $swoole_config['enable_coroutine'] = false; // 如果你用了协程,这个更关键

$serv = new SwooleServer("127.0.0.1", 9501, SWOOLE_PROCESS, SWOOLE_SOCK_TCP);

$serv->set([
    'worker_num' => 4, // 我们要 4 个进程
    'task_worker_num' => 0, // 不用 Task 进程,保持简单
    'log_file' => '/tmp/swoole.log',
]);

$serv->on('Receive', function ($server, $fd, $fromId, $data) {
    // 接收到数据后,我们开始“死算”
    // 模拟一些复杂的数学运算,比如质数判断或者矩阵运算
    $start = microtime(true);
    $result = heavyCalculation(); 

    // 返回结果
    $server->send($fd, "Calculation finished in " . (microtime(true) - $start) . " secondsn");
});

$serv->start();

function heavyCalculation() {
    // 做点没什么用但很耗时的数学题
    $n = 1000000; // 循环次数
    $sum = 0;
    for ($i = 0; $i < $n; $i++) {
        // 伪随机数生成,模拟业务逻辑
        $sum += sqrt($i) * log($i + 1); 
    }
    return $sum;
}

看,这个代码很简单,没有任何 I/O 阻塞。如果我们不加干预,启动它,然后用 top 或者 htop 命令看,你会发现 CPU 的几个核心在疯狂跳动,每个核心都在处理不同 Worker 的数据,然后频繁切换。

这就很痛。

方案一:使用 taskset 命令启动(最粗暴有效)

现在,让我们换个姿势启动。我们用 taskset 命令。

假设你有 8 核,我们将前 4 个核心分配给 4 个 Worker 进程。

# 启动命令详解:
# taskset -c 0-3,8-11 ./server.php
# -c 0-3,8-11 表示我们只允许进程使用 CPU 0, 1, 2, 3, 8, 9, 10, 11
# 这里我们故意把 0-3 和 8-11 分开,是为了演示如果 NUMA 不对齐会有多坑(后面细说)
# 在这个例子中,我们让 0-3 这一组来处理我们的 Swoole
taskset -c 0-3 ./server.php

魔法发生了。
当你运行这个命令后,当你打开 htop,你会发现一个神奇的现象:
CPU 0 的使用率很高(比如 80%),CPU 1 的使用率很高,CPU 2 和 CPU 3 也是。
但是,CPU 0 上只有你的 Server 进程在跑,CPU 1 上也只有你的 Server 进程在跑。

没有其他乱七八糟的系统进程在抢夺 CPU 0 的份额。你的 Worker 进程在 CPU 0 上完成了一次计算,立马开始下一次,中间没有停顿。这种连续性带来了巨大的吞吐量提升。

方案二:在代码中动态获取并打印(用于调试)

如果你不想改启动命令行,而是想在代码里通过 exec 或者 shell_exec 获取当前进程的 CPU 亲和性,来看看它到底干了啥。

// 在 Worker 的回调中打印一下
$serv->on('WorkerStart', function ($serv, $worker_id) {
    // 获取当前进程的 PID
    $pid = posix_getpid();

    // 获取 CPU 列表
    $cmd = "taskset -pc $pid";
    exec($cmd, $output);

    // 输出格式通常类似 "pid 12345's current affinity set: 0,1"
    echo "Worker #{$worker_id} (PID: {$pid}) is running on CPUs: " . implode(', ', $output) . "n";
});

第四部分:进阶——NUMA 架构下的“分裂”战争

讲了这么多,你以为这就完了?太天真了。

如果你在生产环境使用的是高端服务器(比如双路 Xeon 或 AMD EPYC),你面对的是 NUMA 架构

什么是 NUMA?
简单说,就是 CPU 核心多了,内存控制器也就分成了两组。CPU 0 旁边连着一组内存条(本地内存),速度极快。CPU 1 也连着一组内存条。但是,CPU 0 想要读取 CPU 1 旁边的那组内存,它得跨过一条“高速公路”(QPI/UPI 总线),速度会慢几十倍。

亲和性的坑:
如果你使用 taskset -c 0-7 启动了你的进程,并且这个进程计算量巨大,它会产生海量的临时变量和对象。

如果你的代码逻辑不够好,或者 PHP 的内存分配器(Zend MM)很贪婪,你的进程可能会在运行过程中,不知不觉地用到了跨 NUMA 节点的内存。

这时候,你虽然 CPU 亲和性锁死了(进程没跑),但你的内存访问延迟却上去了,性能依然会掉。

专家级调优:

我们要使用 numactl。不要用 taskset,用 numactl

# numactl --cpunodebind=0 --membind=0 ./server.php

这个命令的意思是:

  1. –cpunodebind=0:只使用 NUMA 节点 0 的 CPU 核心(比如 0-7)。
  2. –membind=0:强制将进程的内存分配优先使用 NUMA 节点 0 的内存。

这就好比,你把你的厨房(CPU)固定在楼下,并且强制规定“所有的食材必须从楼下的冰箱拿”,绝对不允许去楼上的仓库拿。

这才能达到真正的“锁死”。

第五部分:代码实战——Swoole + 进程亲和性 + 多核并行

为了证明我说的都是真的,我们来写一个基准测试脚本。

我们将对比两种情况:

  1. 普通模式:4 个 Worker,不绑定 CPU。
  2. 亲和模式:4 个 Worker,分别绑定 4 个核心。

测试工具:
你需要一个压测工具,比如 ab (Apache Bench) 或者 wrk

测试代码:

<?php
// benchmark_server.php

$serv = new SwooleServer("0.0.0.0", 9502, SWOOLE_PROCESS, SWOOLE_SOCK_TCP);

$serv->set([
    'worker_num' => 4,
    'dispatch_mode' => 2, // 固定模式:轮询分发到 Worker,防止同一个长任务在所有 Worker 里乱跑
    'log_file' => '/tmp/bench.log',
]);

// 这是一个非常耗时的计算任务,用于模拟
$serv->on('Receive', function ($serv, $fd, $fromId, $data) {
    // 模拟计算耗时 0.01 秒
    usleep(10000); 
    $serv->send($fd, "Donen");
});

$serv->on('WorkerStart', function ($serv, $worker_id) {
    $pid = getmypid();
    $cmd = "taskset -pc $pid";
    exec($cmd, $output);
    echo "Worker #{$worker_id} (PID: {$pid}) Affinity: " . implode(' ', $output) . PHP_EOL;
});

$serv->start();

启动方式:

第一组:无束缚(混沌模式)

php benchmark_server.php

此时你用 htop,你会看到 CPU 0, 1, 2, 3 全在闪。Worker 0 在 0 上切到 1,Worker 1 在 1 上切到 2。
你的 QPS 可能是 5000。

第二组:束缚(锁死模式)

# 我们强制这 4 个进程只能用 0, 1, 2, 3 核心中的某一个,并且互不干扰
php benchmark_server.php &
PID1=$!
php benchmark_server.php &
PID2=$!
php benchmark_server.php &
PID3=$!
php benchmark_server.php &
PID4=$!

# 绑定 PID 到 CPU 核心
taskset -c 0 $PID1
taskset -c 1 $PID2
taskset -c 2 $PID3
taskset -c 3 $PID4

# 开始压测...
# 你会发现,此时 QPS 很可能提升 30% - 50%。

为什么 dispatch_mode 很重要?
注意上面的代码里有 'dispatch_mode' => 2。这是一个关键的细节。
Swoole 默认的调度机制有时候会把同一个连接的多次请求发往同一个 Worker(为了减少上下文切换),但对于计算密集型任务,轮询模式 配合 CPU 亲和性 效果最好。
这意味着:

  • 请求 A 到了,Worker 0 处理。
  • 请求 B 到了,Worker 1 处理。
  • Worker 0 和 Worker 1 分别在不同的 CPU 核心上狂奔,互不干扰。

如果你还在用 dispatch_mode = 1(固定连接模式),那你的计算任务可能堆积在一个 Worker 上,虽然亲和性锁死了,但利用率依然不均衡。

第六部分:Swoole 协程中的“迷魂阵”

好了,兄弟们,我要讲一个稍微有点复杂,但也非常容易踩坑的地方:Swoole 协程

现在的 Swoole 版本都支持协程。这意味着你在一个 Worker 进程里,可以开成百上千个协程。

危机:
协程的上下文切换非常快,比进程切换快得多。如果在协程里做计算,协程的切换可能会破坏 CPU 的缓存局部性。

虽然我们不能给协程“上枷锁”,因为协程是运行在 OS 线程(也就是 Worker 进程)里的,但我们可以通过控制并发数来保护 CPU。

// 协程计算示例
go(function () {
    // 这个协程在 CPU 核心上跑,如果并发太高,这个协程被挂起和恢复的次数太多了
    // 会频繁导致 CPU 缓存失效
    heavyCalculation(); 
});

专家建议:
在使用 Swoole 协程处理计算密集型任务时,不要开太大的并发数
如果你有 4 个 Worker,每个 Worker 只开了 4 个并发协程,那总共就是 16 个协程。
这 16 个协程轮流在 1 个 CPU 核心上跑,CPU 的缓存还能保住。
如果你每个 Worker 开了 1000 个协程,虽然 CPU 核心数没变,但协程切换的频率已经接近上下文切换了。

总结一下协程与亲和性的关系:

  • 亲和性(Affinity)是宏观的,控制进程和核心的关系。
  • 协程是微观的,控制任务在进程内的调度。
  • 对于计算密集型,宏观控制(亲和性)带来的收益远大于微观控制(协程)。

第七部分:其他“流氓”进程的干扰

最后,别忘了,你的服务器上除了你的 PHP 进程,还有一大堆东西。

  • Systemd / Supervisord:它们在监视你。
  • Nginx / PHP-FPM:它们在打工。
  • Cron jobs:它们在半夜两点突然醒来。
  • 内核线程:它们在疯狂打字。

即使你给 Swoole 上锁了,如果操作系统的内核线程不停地抢占 CPU,你的锁也会被打破。

终极解决方案:CPU 负载均衡禁用

在某些极致性能的场景下,你可以通过 sysctl 禁用 Linux 的 CPU 负载均衡器,强制每个核心独占。

# 禁用 SCHED_SPAWN(根据具体内核版本,这是比较激进的调优)
echo 0 > /proc/sys/kernel/sched_spendinf? ... (记不清了,反正这句是演示用)

# 更实际的做法是使用 cpusets
# mkdir /sys/fs/cgroup/cpuset
# mount -t cpuset none /sys/fs/cgroup/cpuset
# echo 0-3 > /sys/fs/cgroup/cpuset/cpuset.cpus
# echo 0-3 > /sys/fs/cgroup/cpuset/cpuset.mems
# echo $$ > /sys/fs/cgroup/cpuset/tasks

这就像是把你的 CPU 核心一个个关进小黑屋里,外面的人(其他进程)根本进不来。只有你的 PHP 进程能进去干活。

结语(或者说,操作指南)

好了,今天的讲座到此结束。我们回顾一下核心点:

  1. 问题:没有亲和性,CPU 就像个在酒吧里到处搭讪的人,不停地在核心间跳来跳去,忘了自己正在切菜。
  2. 机制:CPU 亲和性(Affinity)通过 tasksetnumactl 锁死进程与核心的关系。
  3. 收益:减少上下文切换,提升缓存命中率,提升计算密集型任务的吞吐量。
  4. 环境:注意 NUMA 架构,使用 --membind 而不仅仅是 --cpunodebind
  5. Swoole:配合 dispatch_mode = 2 和合理的 Worker 数量。

最后,送大家一句话:不要把你的 CPU 当作一个可以随意分配的公共资源池,而要把它当作一个需要精心呵护的私人工作室。

现在,去给你的 Swoole 进程上一把锁吧。如果你看到 CPU 占用率稳定在 100%,QPS 飙升,你会感谢我的。

(背景音:服务器风扇因为负载过高开始狂转)

我是你们的专家,下周我们聊聊“如何通过禁用 PHP 的垃圾回收来提升 10% 的速度”,再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注