各位 PHP 极客,各位后端架构师,晚上好!
欢迎来到“高性能 PHP 专家”的现场。把手机调至静音,把那一脸“我只想写 CRUD”的表情稍微收敛一下,因为今天我们要聊的东西,会稍微刺激一点你们的 CPU 缓存。我们不讲那些虚头巴脑的设计模式,也不讲如何优雅地写 SQL,我们要讲的是——把 CPU 当成女朋友一样宠着,让它只为你一个人服务。
话题是:CPU 亲和性。
在座的各位,谁没遇到过这种情况:服务器负载明明只有 2%,但你的 PHP-FPM 进程响应时间却像是在爬楼梯?或者你的 Swoole/Workerman 服务,看起来吞吐量很低,数据在内存里明明是热的,CPU 却像是在那儿打太极?
这都是因为你的进程在“流浪”。
想象一下,你是一个厨师(PHP 进程),你正在炒菜(计算)。你的切菜板(内存数据)在厨房的角落(CPU 缓存),而你的刀(CPU 核心)却在客厅(另一个核心)。当你炒完一道菜,你想切下一道菜,你必须跨过整个房子,跑到客厅拿刀,再跑回厨房切菜。
这就是没有 CPU 亲和性的代价——上下文切换。
而今天,我们要教大家如何把“跑腿的”变成“坐镇的”。
第一部分:为什么你的 PHP 进程在“跑马拉松”?
要讲亲和性,我们得先聊聊 CPU 到底是个什么脾气。
现在的 CPU,哪怕是最便宜的入门级服务器,动不动也是 8 核、16 核,甚至 64 核。听起来很爽对吧?多核并行。但是,兄弟们,这玩意儿有个巨大的毛病:它非常爱聊天,也非常健忘。
当你有一个 PHP 常驻进程在运行时,它需要访问数据。这些数据大部分存储在 RAM(内存)里。RAM 很大,很便宜,但它离 CPU 很远。CPU 怎么访问内存?它不直接去拿,它先去访问自己的 L1/L2/L3 缓存。
L1 缓存多大?几 KB 到几百 KB。L3 缓存?几 MB。
这就好比你查字典。字典(内存)在书架最顶层,你的书桌(L3 缓存)在中间。你平时把常用的单词(热点数据)放在手边的桌面上(L3)。如果你的 CPU 核心一直待在桌面上,你要查词,伸手就拿,快如闪电。如果你一会儿在客厅,一会儿在卧室,每次查词都要跑去书架拿书再跑回桌边查,这效率低得令人发指。
这就是缓存局部性。
在 PHP 常驻进程(比如 Swoole)中,我们通常会让 Worker 进程尽可能独立。如果没有亲和性设置,Linux 的调度器会根据负载,把你的 Worker 进程 A 从核心 0 抢到核心 1,一会儿又抢到核心 2。
发生了什么?
- 缓存失效: 你的 Worker 进程在核心 0 的 L3 缓存里存了一堆热数据(比如用户会话、配置)。突然,调度器把它踢到了核心 2。核心 2 的 L3 缓存里是空的,也没有你的热数据。
- 数据搬运: 当你的代码再次访问这些数据时,它必须去主存(RAM)里把数据搬过来,存入核心 2 的 L3 缓存。这比直接用慢得多。
- 上下文切换开销: 操作系统还需要做记录,把核心 0 上被中断的任务状态保存下来,把核心 2 上的任务状态恢复。这也是时间!
结论: 你的代码算得再溜,只要数据没在 CPU 的“嘴边”,性能就会大打折扣。
第二部分:给 CPU 绑个“座儿”
所谓的 CPU 亲和性,或者叫进程绑定,就是告诉操作系统:“嘿,这哥们儿(PHP 进程)我养定了,别乱扔,让他固定在核心 X 上干活。”
对于计算密集型的 PHP 任务(比如复杂的加密解密、数学运算、大屏渲染),这简直是魔法。
方法一:Linux 原生利器 taskset
如果你懒得改代码,也不想折腾复杂的 Pcntl 扩展,Linux 自带的 taskset 命令就是你的救星。它就像是给进程发了一张永久工牌。
假设你有 8 个核心,你想让 PHP 进程只用核心 0 和核心 1,你可以这样做:
# 让 PHP 进程只运行在核心 0 上
taskset -c 0 php server.php
# 让 PHP 进程只运行在核心 0, 1, 2, 3 上
taskset -c 0-3 php server.php
原理: taskset 会修改进程的 cpuset 阻塞掩码,强制进程的 sched_setaffinity 系统调用生效。
场景演示:
假设你有一个 Swoole 服务,开了 4 个 Worker 进程。
- 未绑定: 调度器可能在核心 0、1、2、3 之间疯狂调度这 4 个进程。
- 绑定: 调度器强制这 4 个进程分别驻扎在核心 0、1、2、3 上。
一旦绑定成功,你的 CPU 就不会在核心之间频繁切换了。对于计算密集型任务,这能带来 10% 到 50% 甚至更高的性能提升。为什么?因为你消除了缓存失效,消除了上下文切换。
方法二:代码中的魔法 pcntl 扩展
如果你是高阶玩家,想在代码里动态控制,或者你的 Swoole 版本比较新,你可以直接在代码里使用 pcntl 扩展。这需要用到 posix_gettid() 获取线程 ID(虽然 PHP 是单线程模型,但在多进程模型下,进程 ID 和线程 ID 在某些系统调用下是等价的,或者直接用 getmypid())。
<?php
// 这是一个计算密集型的 PHP 脚本示例
// 假设你的服务器有 16 个核心
$pid = getmypid();
$cpu_count = 16;
// 我们想把当前进程绑定到核心 0 上
$mask = 1 << 0; // 二进制 000...001
if (pcntl_setaffinity($pid, $mask)) {
echo "[Info] 进程 {$pid} 已成功绑定到核心 0。n";
} else {
echo "[Error] 绑定失败,可能是权限不足或操作系统不支持。n";
exit(1);
}
// 模拟疯狂计算
$start = microtime(true);
$count = 0;
while (microtime(true) - $start < 5) { // 运行 5 秒
// 做一些无意义的数学运算,消耗 CPU
$a = pow(rand(1, 1000), rand(1, 1000));
$b = $a % 12345;
$count++;
}
echo "[Info] 疯狂计算完成,共执行 {$count} 次。n";
注意: 在某些系统(如某些 Windows 版本)上,pcntl_setaffinity 可能不可用,但在 Linux 服务器上,这是最灵活的方式。
第三部分:实战——如何看到效果的“肉眼可见”
空谈误国,实干兴邦。我们怎么证明绑定 CPU 有用?
我们需要一个基准测试脚本。注意,我们要测的是计算吞吐量,而不是 HTTP 请求处理能力(因为 HTTP 请求涉及网络 I/O,I/O 和 CPU 是两码事,这里我们为了纯粹演示 CPU 亲和性,去掉网络,只做计算)。
1. 准备“苦力”脚本
写个简单的 PHP 脚本 benchmark.php:
<?php
// benchmark.php
$startTime = microtime(true);
$count = 0;
// 这里的循环是纯计算,不涉及任何 I/O
while (microtime(true) - $startTime < 10) {
// 模拟复杂计算
$val = 0;
for ($i = 0; $i < 1000; $i++) {
$val += sin($i) * cos($i);
}
$count++;
}
echo "计算次数: " . $count . "n";
echo "耗时: " . (microtime(true) - $startTime) . " 秒n";
echo "QPS: " . round($count / (microtime(true) - $startTime)) . "n";
2. 场景 A:自由散漫(默认)
直接运行:
php benchmark.php
观察你的 CPU 状态。你会发现 CPU 负载在跳动,核心在忙碌和空闲之间切换。
3. 场景 B:圈地运动(taskset 绑定)
# 假设你有 4 个核心,我们强制它只用核心 0
taskset -c 0 php benchmark.php
会发生什么?
- CPU 占用率稳定: 你会看到核心 0 的占用率瞬间飙升至 100% 或接近 100%,而核心 1、2、3 则处于空闲状态。
- QPS 可能更高: 因为没有上下文切换带来的损耗。
- 延迟更低: 因为数据不需要在核心间搬运。
4. 场景 C:多进程并行(多打几个小孩)
现在,我们来测试一下并行计算的威力。
未绑定模式:
开 4 个进程,让它们在 8 个核心上乱跑。
# 开启 4 个 PHP 进程,每个进程都自己跑自己的循环
php benchmark.php &
php benchmark.php &
php benchmark.php &
php benchmark.php &
wait
你会发现这 4 个进程会抢夺 CPU 资源,相互干扰,导致 CPU 使用率虽然高,但整体吞吐量可能反而不如绑定模式,因为缓存相互打架。
绑定模式(最佳实践):
开 4 个进程,强制分别绑定到核心 0、1、2、3。
# 优雅的方式:使用 shell 循环
for i in {0..3}; do
taskset -c $i php benchmark.php &
done
wait
结果: 每个核心都在 100% 跑,没有任何干扰,吞吐量直接翻倍!这就是多核并行的真谛——隔离。
第四部分:进阶——NUMA 架构下的“量子纠缠”
如果你的服务器是那种土豪级服务器(比如 AMD EPYC 或者 Intel Xeon Platinum),你可能听说过 NUMA(非统一内存访问)。
想象一下,你的 CPU 核心分为两组,一组在左边的卡槽,一组在右边的卡槽。左边核心离左边的内存近,右边核心离右边的内存近。
致命陷阱:
如果你的 PHP 进程被绑定到了右边的核心,但你的数据在左边的内存里。
这时候,CPU 要从“右边的核心”去“左边的内存”读取数据,这就像是从地球的另一端取快递,延迟高得离谱。
如何解决?
你需要更精细的工具 numactl。
# --cpunodebind=0 : 绑定到 CPU 节点 0(左侧卡槽)
# --membind=0 : 让内存分配也倾向于节点 0
numactl --cpunodebind=0 --membind=0 php benchmark.php
对于 PHP 常驻进程:
如果你使用 Swoole 或 Workerman,你的数据(对象、数组)大部分都在堆上分配。
- 如果你开启了内存共享(Swoole 的
shm),一定要注意 NUMA 问题。 - 如果是单机多卡服务,一定要用
numactl做进程绑定和内存亲和性。这能让你省下大量的内存延迟时间。
第五部分:关于 PHP 常驻进程的“甜蜜点”
好了,讲了这么多,是不是意味着我要告诉你:“所有进程,全都要绑定!每个核心都要绑一个进程!”
不,年轻人,凡事过犹不及。
什么时候不要绑定?
如果你的 PHP 进程主要是做 I/O 密集型 的工作,比如:
- 每秒钟处理几千个 HTTP 请求,但请求里大部分时间都在等数据库返回,或者在 sleep。
- 你的进程大部分时间都在
epoll_wait。
在这种情况下,不要绑定。
为什么?因为当你的进程处于 sleep 状态时,它占用的核心其实是“空”的。如果调度器把这个空核心分配给另一个急需计算的新进程,那才叫资源的合理利用。
什么时候必须绑定?
- 计算密集型: 算法、加密、图像处理。
- 大循环处理: 需要长时间占用 CPU 的后台任务。
- 高频通信: 如果你的进程在处理高频网络包,且计算量不低。
第六部分:在 Swoole/Workerman 中如何落地?
让我们把理论转化为实战代码。假设你正在开发一个基于 Swoole 的高性能 WebSocket 服务器,里面有大量的消息广播和简单的数学运算处理。
配置 Swoole 的进程名称(方便看)
首先,我们需要把进程名改了,方便我们在 top 或 htop 里看一眼,确认它是不是“老老实实”待在一个位置。
// server.php
use SwooleServer;
use SwooleHttpServer;
$server = new HttpServer("0.0.0.0", 9501);
// 设置 worker 进程数等于 CPU 核心数,或者稍微少一点
$server->set([
'worker_num' => 4,
'dispatch_mode' => 2, // 固定模式:将任务分发给固定的 worker,防止任务在进程间乱跳
]);
// 在 worker 进程启动时绑定 CPU
$server->on('WorkerStart', function ($server, $worker_id) {
// 这里我们绑定 CPU 核心号,通常从 0 开始
// 注意:如果你的服务器核心很多,不要全部绑定死,可以留几个给系统
// 假设你有 8 核,我们只绑定前 4 个给业务,或者轮询绑定
$bind_core = $worker_id % 8;
// 使用 pcntl 绑定
if (function_exists('pcntl_setaffinity')) {
// mask 是二进制位,1 << bind_core 代表开启第 bind_core 核心位
// 比如 bind_core = 0, mask = 1 (000...001)
// 比如 bind_core = 1, mask = 2 (000...010)
// 我们可以通过 | 运算同时绑定多个核心,比如 (1 << 0) | (1 << 1)
$mask = 1 << $bind_core;
if (pcntl_setaffinity(getmypid(), $mask)) {
echo "[Worker #{$worker_id}] PID: " . getmypid() . " 已绑定到核心 {$bind_core}n";
} else {
echo "[Worker #{$worker_id}] 绑定失败n";
}
}
});
$server->on('request', function ($request, $response) {
// 模拟一些计算
$val = 0;
for ($i = 0; $i < 100000; $i++) {
$val += sin($i);
}
$response->end("Hello World, Val: " . $val);
});
$server->start();
运行看效果:
- 启动:
php server.php - 打开另一个终端:
htop(如果没装,top也可以) - 按下
1键(显示每个 CPU 核心的详情)。 - 观察输出。
- 你会发现,你的 4 个 PHP 进程,分别“趴”在了 CPU 的不同核心上。进程名变成了我们刚才设置的格式(或者默认的 swoole)。
此时,如果你发送一个请求,你会发现对应的那个核心 CPU 占用率飙升,而其他核心纹丝不动。这就叫专才专用。
第七部分:常见误区与排坑指南
哪怕你是专家,有时候也会踩坑。这里有几个关于 CPU 亲和性的“雷区”,请务必避开。
误区 1:核心数越多越好,我要把所有进程绑定到所有核心?
- 错误:
pcntl_setaffinity($pid, 0xFFFFFFFF);(假设是 32 核) - 后果: 你的进程重新变成了“流浪汉”。因为它被允许在任何地方跑,调度器可能还是会把它在不同的核心之间迁移,导致缓存失效。
- 正解: 每个进程绑定一个特定的核心,或者固定的一组核心。
误区 2:我用了 taskset,但性能没提升?
- 原因 A: 你的代码是 I/O 密集型的。绑定后,CPU 空转等待 I/O,反而因为没有抢占而导致系统调度变慢。
- 原因 B: 你的代码没有“热点数据”。如果你的代码每次计算都是从头开始,不读内存里的变量,那缓存局部性对你没帮助。
- 原因 C: 代码本身有锁竞争。如果你在进程内部用了
$mutex->lock(),而且锁竞争很激烈,那么你把进程固定在哪个核心都一样,甚至更糟,因为锁会导致整个核心“假死”。
误区 3:忽略 NUMA。
- 在单插槽服务器(只有一个 CPU 卡)上,问题不大。但在双路服务器上,一定要确保你的进程和它访问的内存处于同一个 NUMA 节点。否则,你就像是在用飞机跑道传乒乓球。
结语:掌控你的机器
好了,各位同学,今天的讲座就接近尾声了。
我们回顾一下:
- CPU 缓存是宝库: 数据离 CPU 越近,速度越快。
- 亲和性是钥匙: 绑定进程到核心,就是锁住钥匙,防止数据丢失在内存里。
- 工具很简单:
taskset命令行搞定,pcntl代码里搞定。 - 计算为王: 适用于计算密集型,不适用于单纯等待 I/O。
记住,高性能的 PHP 并不是靠把代码写得像天书一样晦涩难懂,而是靠对底层硬件的深刻理解。
当你的 Swoole 服务跑起来,看着 htop 里每一个核心都像上了发条一样精准咬合,没有任何多余的颤抖和切换时,你会感受到一种数学之美。
那是一种代码从“跑得慢”进化到“跑得飞快”的快感。这种快感,只有真正的专家才能体会。
现在,去把你的 php.ini、你的 Swoole 配置、你的 systemd 启动脚本,统统改掉。给每一个 PHP 进程安个家。明天,当你上线大促时,你的服务器会比隔壁的大厂还稳,还快。
去干活吧!
(全场鼓掌,专家离场)