各位 PHP 大师们,各位正在和服务器“搏斗”的架构师们,大家好!
我是你们的老朋友,那个总是半夜三点在群里发“服务器又崩了”的资深调优专家。
今天我们不谈什么“如何优雅地写代码”,也不谈什么“如何把代码写成屎山然后跑得飞快”。今天我们聊点更硬核的——当你的 PHP 进程从“一次性”变成“常驻”之后,到底发生了什么?以及我们如何像绑匪一样,用 CPU 亲和性把进程死死地钉在特定的 CPU 核心上,榨干它的每一滴算力。
准备好了吗?把你的显示器调高一点亮度,我们开始。
第一回:PHP 的“变性”手术
还记得十年前吗?那时候 PHP 是什么?它是秒针,啪,来一个请求,秒针跳一下,啪,请求结束,秒针复位。操作系统是它的保姆,请求一来,它就给 PHP 一个任务;请求一走,它就给 PHP 一张床睡觉。
但自从 Swoole、Workerman、RoadRunner 这些常驻进程框架横空出世,PHP 发生了“变性”。现在的 PHP 进程是“永动机”。
这就好比你把一个只会做一道菜的实习生,变成了一个厨房大厨,而且这大厨永远不睡觉,24小时守在灶台前。
这时候问题来了。你的服务器通常有 16 核、32 核,甚至 64 核。操作系统是何等“勤勉”的管家?它看着这堆闲置的 CPU 核心,心里想:“哎呀,这么多核闲着也是闲着,既然这个 PHP 进程没事干,那我就让它在核 A 上算算题,算完了,没事干了,再把它挪到核 B 上去摸鱼吧!”
这就是我们今天要聊的罪魁祸首:操作系统调度器(Scheduler)的“多动症”。
第二回:CPU 核心不“护短”,L1 缓存要哭晕
为什么要绑定 CPU?因为 CPU 的缓存(Cache)是这世界上最“势利眼”的东西。
假设你的 PHP 进程是一个正在处理复杂算法的数学家。
- L1 缓存:是他的大脑皮层,容量极小(几十 KB),但反应极快,思考逻辑全在这里。
- L2 缓存:是他的工作台,容量稍大(几 MB),存放一些常备的数据。
- 内存:是图书馆,容量巨大,但取书太慢了。
如果这个数学家一直坐在核 1 上工作,他的 L1 和 L2 缓存里全是正在用的数据和指令。这就像你手里拿着螺丝刀,正在拧螺丝,手感正顺呢。
但是,操作系统调度器觉得“不公平”,它突然把数学家(PHP 进程)踢到了核 2 上。
现在惨了。数学家到了核 2,但他脑子(L1)里全是核 1 的思维残留,他手里的螺丝刀(L2 数据)也都在核 1 的盒子里。为了在核 2 上干活,他必须把脑子里的东西倒腾出来,去核 2 的新脑壳里装。
这个过程,专业术语叫缓存未命中(Cache Miss)。这不仅仅是慢一点的问题,这是性能的“断崖式下跌”。
打个比方:如果你在做手术,医生每切一刀,就要出去换副手套、消毒一遍、重新进入无菌室。病人还没死,医生先把自己累趴下了。这就是 CPU 亲和性缺失带来的后果。
第三回:Linux 的“手段”——taskset 命令
好,理论讲完了,我们上干货。虽然 PHP 本身(纯 PHP 脚本)没有直接提供 set_cpu_affinity 这样的函数(除非你用 C 扩展或者外部脚本),但 Linux 给了我们通用的武器——taskset。
如果你是直接运行 PHP 脚本,那很简单,在启动前打个标签就行。
# 强制指定 PHP 进程只能运行在 CPU 核心 0 和 1 上
taskset -c 0,1 php my_worker.php
这里的 -c 就是核心的意思。如果你的机器有 64 个核,你就可以这么干:
# 绑定核心 0-15
taskset -c 0-15 php worker.php
但这有个问题:如果 PHP 进程内又 fork 了子进程呢?或者你用了多线程(虽然 PHP 比较少用,但框架里会有协程)呢?taskset 只管父进程。子进程出生的时候,可能会继承父进程的亲和性,也可能不继承,这取决于系统的内核设置。
而且,对于常驻进程来说,这种通过命令行启动的方式太繁琐了,我们得深入到代码层面去控制。
第四回:代码实战——PCNTL 与 System Call
虽然 PHP 没有原生的绑定函数,但我们有 exec 或者 shell_exec。别皱眉,我知道这听起来不优雅,但在高并发场景下,为了性能,我们偶尔得向底层妥协。
想象一下,我们写了一个 PHP 守护进程,负责处理日志。
<?php
// daemon.php
// 我们先定义一些常量,方便理解
define('CPU_CORES', 4); // 假设你有 4 个核
define('WORKER_COUNT', 4); // 我们需要 4 个工作进程
$pidFile = __DIR__ . '/daemon.pid';
// 1. 检查是否已经在运行
if (file_exists($pidFile)) {
echo "Already running, PID: " . file_get_contents($pidFile) . "n";
exit(1);
}
// 2. 创建主进程
$masterPid = pcntl_fork();
if ($masterPid < 0) {
die("Could not fork master process");
} elseif ($masterPid > 0) {
// 主进程退出,只保留子进程
file_put_contents($pidFile, $masterPid);
exit(0);
}
// 3. 让主进程脱离终端,成为守护进程
posix_setsid();
// 4. 生成工作进程
$workers = [];
for ($i = 0; $i < WORKER_COUNT; $i++) {
$pid = pcntl_fork();
if ($pid == -1) {
die("Could not fork worker $i");
} elseif ($pid == 0) {
// 子进程逻辑开始
// 这里是关键:绑定 CPU
// 我们假设进程 i 绑定到 CPU 核心 i
// 在 Linux 中,taskset 命令是绑定 CPU 的利器
// 我们使用 shell_exec 执行 taskset
// 注意:这需要 PHP 有执行 shell 的权限,且系统有 taskset 命令
$core = $i % CPU_CORES;
$cmd = "taskset -c {$core} $0"; // 重新执行当前脚本,并带上 affinity
echo "Worker $i (PID: " . getmypid() . ") binding to CPU Core: $coren";
system($cmd);
// 执行完 system 后,当前进程的状态应该已经改变了
// 但因为 taskset 重新 exec 了,所以 $0 其实指向了同一个文件,
// 只是这次被 taskset 锁死了。
// 此时进入死循环,模拟常驻计算
while (true) {
// 模拟高负载计算
$data = str_repeat("A", 1024 * 1024); // 1MB 数据
// 做点无用功,刷一下 CPU
$md5 = md5($data);
usleep(100); // 偶尔休息一下
}
}
$workers[] = $pid;
}
// 等待子进程结束(实际上不会结束)
while (true) {
pcntl_wait($status);
}
代码点评:
上面的代码虽然有点“土”,但它解释了核心原理。通过 pcntl_fork 创建子进程,然后利用 Linux 的 taskset 命令让子进程在出生的那一刻就“认主”。
但是,各位大师,你们会发现,直接在 PHP 里用 system('taskset ...') 就像是在 SQL 里写 SELECT * 一样,虽然能跑,但不够优雅,而且如果 taskset 命令不可用或者权限不够,你就完蛋了。
所以,对于真正的生产环境,我们必须使用专门的 PHP 进程管理工具。
第五回:Swoole 与 RoadRunner 的“魔法配置”
如果说原生 PHP 是练气期,那 Swoole 和 RoadRunner 就是结丹期。它们早就看穿了操作系统的把戏,在底层实现了 CPU 亲和性控制。
这是最推荐大家使用的方案。
案例 1:Swoole 的配置
Swoole 的 swoole_server 对象提供了 set 方法,里面有 cpu_affinity 参数。这就好比给你装了一个自动锁芯的保险箱,你只需要告诉它锁哪个孔就行。
<?php
$server = new SwooleServer("0.0.0.0", 9501);
// 关键配置来了!
$config = [
// 设置进程数,通常建议 = CPU 核心数
'worker_num' => 4,
// 开启 CPU 亲和性绑定
'cpu_affinity' => [0, 1, 2, 3],
// 开启很多其他优化参数,这里就不多说了
'dispatch_mode' => 1,
'log_file' => '/tmp/swoole.log',
];
$server->set($config);
$server->on('receive', function ($server, $fd, $from_id, $data) {
// 模拟计算任务
$result = 0;
for ($i = 0; $i < 1000000; $i++) {
$result += $i;
}
$server->send($fd, "Result: " . $result);
});
$server->start();
解释:
当你配置 cpu_affinity => [0, 1, 2, 3] 时,Swoole 底层会在 fork 进程之后,自动调用 Linux 的 sched_setaffinity 系统调用。这意味着:
- 负载均衡:Swoole 的调度器会尝试将任务分配给空闲的 Worker。如果 Worker 0 正在忙,调度器不会把任务扔给它,而是扔给 Worker 1。
- 数据独享:Worker 0 的数据永远只会在 CPU 0 的缓存里转悠。Worker 1 的数据永远在 CPU 1。互不干扰,这就是NUMA 优化的第一步。
案例 2:RoadRunner 的 YAML 配置
如果你喜欢 YAML,RoadRunner 是你的菜。它的配置更加清晰,甚至可以针对不同的 Job 类型绑定不同的 CPU。
rpc:
listen: 0.0.0.0:6002
# 监听 HTTP 服务
http:
address: 0.0.0.0:8080
workers:
command: "php worker.php"
num: 4
# 超级重要的亲和性配置
# 这里的含义是:将前 2 个 worker 绑定到 CPU 0-1,后 2 个绑定到 CPU 2-3
# 这种精细控制可以防止后台任务抢占前台业务
cpu_affinity: [0, 1, 2, 3]
# 监听队列(常驻进程最常用的场景)
jobs:
num_workers: 8
# 如果你有 16 核 CPU,你可以把处理任务的进程绑定到 CPU 4-11
# 而把 HTTP 进程绑定到 CPU 0-3。这样两者完全隔离,互不嫉妒。
# 计算密集型任务和 I/O 密集型任务完美分离!
assignees:
- cpu: [4, 5, 6, 7, 8, 9, 10, 11]
高级技巧:混合负载隔离
这是大师级的玩法。你的服务器上既有 Web 请求,又有 Redis 批量导入、视频转码(虽然 PHP 不适合视频转码,但逻辑一样)、大数据计算。
你可以把 CPU 分成两半:
- CPU 0-3:给 Web Worker(对响应速度敏感,I/O 密集)。
- CPU 4-7:给定时任务 Worker(计算敏感,对延迟要求低)。
通过配置:
$server->set([
'worker_num' => 4,
'task_worker_num' => 4,
'task_ipc_mode' => 1, // 使用消息队列
// 将 Web Worker 绑定到低端核
'cpu_affinity' => [0, 1, 2, 3],
]);
// 假设 task_worker 会继承父进程的 affinity,或者你需要单独设置 task worker 的 affinity
// Swoole v4.0+ 支持 task_worker 的独立 affinity
$server->set([
'task_cpu_affinity' => [4, 5, 6, 7]
]);
这样,当 Redis 导入 100 万条数据时,Web 请求虽然多了,但不会被慢任务拖慢,因为 Web Worker 的 CPU 核心根本没被使用!
第六回:NUMA 架构的“暗雷”
讲到这里,你以为你已经无敌了?错。如果你在超算服务器或者现代服务器上,忽略了 NUMA(Non-Uniform Memory Access,非统一内存访问) 架构,你的调优可能完全白做。
这就好比你有两个大厨,一个在左手边的厨房(本地内存),一个在右手边的厨房(远程内存)。如果 CPU 0 和 CPU 1 互相需要对方的食材,那速度会慢得令人发指。
如何避免?
在使用 CPU 亲和性之前,请务必运行 lscpu 命令。
lscpu
查看 Topology 部分。你会看到 CPU(s): 和 NUMA node(s)。
- 场景 A(单路服务器): 通常只有一个 NUMA 节点。这时候 CPU 亲和性非常有效。
- 场景 B(多路服务器/刀片服务器): 如果你有 2 个 CPU 插槽,每个插槽 16 核,那就是 2 个 NUMA 节点。
- 如果你的 PHP 进程被绑定到了 CPU 15(插槽数 1 的最后一个核),而你的数据大部分在内存插槽 0 里。
- 那么这个进程在访问内存时,就会发生跨节点内存访问。速度慢 10 倍起步!
解决方案:
在 Linux 内核层面,可以通过 numactl 命令来同时设置 CPU 亲和性和内存亲和性。
# 绑定 CPU 核心 0-3,并且让该进程只使用内存节点 0 上的内存
numactl --cpunodebind=0 --membind=0 php worker.php
或者,在代码启动时检测:
// 简单的 NUMA 检测脚本
$hosts = `cat /sys/devices/system/node/node0/cpulist`;
echo "Node 0 CPUs: " . $hosts . "n";
// 在 Swoole 中,虽然不能直接调用 numactl,但可以通过环境变量传递
// 或者,严格规划你的进程分配,确保 CPU 0-3 始终在同一个 NUMA 节点。
第七回:验证与调试——如何看出效果?
调优了半天,效果怎么样?别猜。看数据!
1. 查看进程绑定情况
运行你的 PHP 进程后,在终端输入:
# 查看进程 1234 的亲和性设置
taskset -pc 1234
输出应该类似这样:
pid 1234's current affinity set: 0,1
这就说明,进程 1234 被钉死在 CPU 0 和 1 上了。
2. 查看内核调度日志
如果你想看看到底有多少次上下文切换导致了性能下降,可以开启内核日志:
# 临时开启调度器日志
echo 1 > /proc/sys/kernel/sched_schedstats
然后用 perf 工具或者 top 观察你的 PHP 进程。
如果看到进程的 csw(上下文切换次数)非常少,而且 CPU 的缓存命中率(cache-misses)在下降,恭喜你,亲和性设置生效了!
第八回:性能对比实验
为了证明我说的没错,我们来做一个简单的心理实验(或者你可以真的写个脚本测)。
实验组 A:无亲和性
- 4 个进程,4 个核。
- 结果:进程在核心间疯狂跳跃。L1 Cache Miss 率飙升。CPU 虽然跑满了,但算出来的数字是错的(因为数据还没算完就被切走了)。
实验组 B:高亲和性(绑死 4 个核)
- 4 个进程,分别绑死 0, 1, 2, 3。
- 结果:进程纹丝不动。L1 Cache Miss 率极低。CPU 虽然跑满了,但计算结果准确。
实验组 C:混合隔离
- 2 个 Web Worker 绑死 0,1。
- 4 个计算 Worker 绑死 2,3,4,5。
- 结果:即使 Web 请求暴增,计算任务也不会拖慢 Web 响应速度。
尾声:大师的“心法”
好了,今天的讲座快结束了。我们要不要来点轻松的总结?
记住,CPU 亲和性不是万能药,但它是高性能 PHP 服务器的“防弹衣”。
- 不要过度绑定:如果你有 64 个核,你绑定 32 个进程,剩下的 32 个核在干什么?它们在充当“冷气机”,风扇转得飞起,但没干活。这就是资源浪费。
- 宁缺毋滥:如果你的业务计算量不大,绑定反而会降低吞吐量,因为系统在调度空闲进程时会浪费一点时间。只有当计算密集、多进程并发时,才必须开启。
- 工具至上:别在 PHP 里写 shell_exec(‘taskset …’) 来折腾自己。直接用 Swoole 或 RoadRunner 的配置。这是工程的艺术。
最后,送大家一句话:把进程的亲和性设置好,就像给你的代码安了个家。别让你的代码在 CPU 的世界里流浪,要让它拥有自己的房间,自己的床,自己的书(缓存)。
现在,去把你的配置改改,然后去拥抱你的服务器吧!别让它再喊累了!