news 2026/9/29 3:26:08

PHP内存分配剖析:从emalloc与pemalloc看FPM进程内存泄漏

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP内存分配剖析:从emalloc与pemalloc看FPM进程内存泄漏

1. 从一次线上事故说起:为什么你需要重新认识 PHP 的内存分配

大概半年前,我接手了一个基于 PHP-FPM 的老项目。业务逻辑本身不复杂,但上线的第一个月,运维同学就找上门了:每天早上八点半,高峰流量一上来,服务器内存就被打满,Swap 疯狂写入,紧接着 FPM 进程集体卡死,502 一片。排查了一圈,MySQL 慢查询没有、Redis 连接数正常、Nginx 日志干净,唯独free -m里的 available 数字像坐了滑梯一样往下掉。

最后用pm.status和strace定位才发现,问题出在某个第三方 SDK 的缓存类上——它把用户会话数据塞进了一个静态属性数组里,而这个数组的生命周期竟然跨了多个请求。这直接导致了 PHP 内存无法按预期回收。更让我意外的是,不少同事在讨论这个问题时,嘴里蹦出来的词都是“垃圾回收”“引用计数”,但真正问到底层的内存分配策略、emalloc和pemalloc的区别时,能讲清楚的几乎没有几个。

这就是我写这篇东西的初衷。emalloc和pemalloc不是两个冷门的底层术语,它们直接决定了你的 PHP 进程“吃”内存的方式,决定了在 FPM 长生命周期进程下内存会不会被悄悄耗尽。这篇文章我会结合自己的踩坑经历,把这套分配策略彻底拆开,讲清楚它们各自的行为逻辑、适用范围、以及在实战中怎么规避内存泄漏。无论你是写业务代码的,还是偶尔碰一碰扩展开发的,这篇文章都值得读完。

2. 先把几个关键概念掰开揉碎:Zend MM、emalloc 与 pemalloc

2.1 Zend Memory Manager:PHP 内存世界的“总管家”

要弄懂emalloc和pemalloc,第一步是理解它们背后的管理者——Zend Memory Manager(Zend MM)。

你可以把 Zend MM 想象成一个小区物业公司。每一次 PHP 脚本运行(比如一次 HTTP 请求),就是一位住户入住。物业公司为这位住户分配房间(内存块),并且在住户退房时(请求结束)统一清空房间。这个过程中的所有内存申请,几乎都走的是emalloc这一套接口。

Zend MM 并不是直接向操作系统索要内存的。它更像一个“批发商”:它一次性从操作系统(通过malloc或mmap)批发一大块内存(称为 chunk,默认大小 2MB),然后切成小块,按需“零售”给 PHP 内部的各类数据结构。这样做的好处非常明显:

  • 减少系统调用:进程每次向内核申请内存都要发生上下文切换,成本高。Zend MM 通过批量批发,大幅降低了系统调用频率。
  • 统一释放:请求处理完毕后,Zend MM 可以直接释放整个 chunk,而不用逐个跟踪每个变量的内存,这在 C 语言层面是一种非常高效且省心的设计。
  • 内存统计:memory_get_usage()能精确报告当前请求占用的内存,这个数据就是 Zend MM 统计出来的。

2.2 emalloc:跟着请求走,活在当下

emalloc的全称是 “engine malloc”,也就是引擎级内存分配。它走的是 Zend MM 的统一管理。我们平时写的 PHP 代码里,无论是创建一个数组、实例化一个对象、还是拼接一个字符串,底层在需要分配内存时,绝大部分调用的都是emalloc。

emalloc最核心的行为特征是:它的生命周期与当前请求绑定,请求结束即整体回收。这是 PHP 在 FPM 模式下能够稳定运行的关键机制之一。

但这里有个很容易被忽略的细节:如果内存是被 PHP 内部(比如一个数组)持有的,请求结束会释放;但如果你写了一个扩展,在扩展里用了malloc而不是emalloc来分配内存,PHP 是感知不到的。这块内存就成了“黑户”,请求结束也不归 Zend MM 管,必须靠扩展自己显式释放。这也是扩展开发中常常踩坑的地方。

2.3 pemalloc:给持久化数据留一条“VIP通道”

pemalloc的全称是 “persistent malloc”,持久化内存分配。它的核心逻辑非常有意思:它先判断当前 PHP 是否运行在“持久化”模式下。

怎么判断呢?关键看一个全局变量persistent,它定义在Zend/zend_alloc.c中,初始值是 0。只有在sapi_startup()阶段,如果当前 SAPI(比如 FastCGI、CLI)支持持久化且配置允许,这个变量才会被置为 1。

  • 如果persistent == 1:那么pemalloc做的事情和malloc几乎一样——直接向系统申请内存,不走 Zend MM。
  • 如果persistent == 0:那么pemalloc和emalloc的行为完全一致——交给 Zend MM 管理。

所以,pemalloc并不是一个独立的“持久化分配器”,它本质上是一个策略函数:在可以持久化的时候用系统分配,在不能持久化的时候退化为普通请求分配。这个设计就是为了解决一个核心矛盾——有些数据需要在多个请求之间共享(比如缓存的编译结果、连接池),它们不能随请求结束就消失。

2.4 同样的请求,在一句话里记住它们的关系

为了让你记得更牢,我用一个生活化的场景来总结:

一家餐厅(PHP 进程)每天接待很多桌客人(HTTP 请求)。每位客人入座后,服务员(Zend MM)会按照这桌客人的需求,从后厨的大冰箱(内存池)里拿出各种食材(内存块)。客人吃完离开时,服务员会把这一桌没吃完的东西全部清走,保证下一桌客人用的是干净整洁的桌面。这就是emalloc的行为。

但如果餐厅老板发现,某一种调味料(比如特制酱料)每天都会被所有桌客人用到,自己花钱买了很多次。这时候老板决定:我直接在后厨建一个储存柜,专门放这种保质期长的酱料,不管哪桌客人来了都能直接拿,不用每次重新制作,客人走后这些酱料也依然存在。这就是pemalloc的行为。

emalloc负责“当前这顿饭”的循环,pemalloc负责“跨饭局共享”的库存。理解了这个场景,后面的所有代码和排查逻辑就都顺了。


3. 核心调度逻辑:var 与 zend_mm_heap 的秘密

为了讲清楚真正的分配流程,我们不能停留在一个“类比”的层面,还得往代码里看一眼。不过你不需要害怕,我不会把整段源码贴出来,只会把最关键的骨架逻辑拆给你看。

3.1_emalloc与_pemalloc的真实面孔

在 PHP 源码(以 PHP 8.x 为例)中,emalloc和pemalloc其实都是宏定义,它们的底层函数名都带下划线,位于Zend/zend_alloc.h:

#define emalloc(size) _emalloc(size ZEND_FILE_LINE_RELAY_CC) #define safe_emalloc(nmemb, size, offset) _safe_emalloc(nmemb, size, offset ZEND_FILE_LINE_RELAY_CC) #define pemalloc(size, persistent) _pemalloc((size), (persistent) ZEND_FILE_LINE_RELAY_CC)

注意看pemalloc宏,它多了一个persistent参数。这就是关键调用方传来的“意愿”:我在这个场景下是否需要持久化。

继续追到_pemalloc的定义(Zend/zend_alloc.c):

static zend_always_inline void *_pemalloc(size_t size, zend_bool persistent) { if (persistent) { return malloc(size); } else { return _emalloc(size); } }

逻辑简洁到令人发指。所谓pemalloc,就是判断了一下persistent标志位,为真就调用系统malloc,为假就老老实实走_emalloc绕一圈 Zend MM。

那persistent标志是从哪里来的?还是在zend_alloc.c里:

static zend_bool persistent = 0; void zend_alloc_init(void) { ... }

而在sapi_startup()(main/SAPI.c)里,会做一次关键的初始化:

void sapi_startup(void) { ... zend_alloc_init(); ... }

更进一步,zend_alloc_init()会调用zend_mm_init(),并在其中根据当前 SAPI 的能力设置persistent标志。比如,FastCGI 和 CLI 这类常驻进程的 SAPI,是可以开启持久化的;而像php -f这种一次性执行的模式,或者 Apache 的mod_php(apache2handler)在某些配置下,默认往往不会启用。

3.2 Zend MM 的两大内存池:non-freeable 与 freeable

Zend MM 内部其实维护了两个不同的内存池,这就是很多人在排查内存问题时觉得“为什么内存总是不归还操作系统”的根源之一。

  • freeable 内存池:存放普通请求中用emalloc分配的内存。请求结束后,Zend MM 可以整体把它归还(释放)。
  • non-freeable 内存池:存放用pemalloc分配的内存,或者通过emalloc分配但被标记为不可释放的内存(比如某些内部缓存)。这部分内存不随请求结束而消失。

在zend_mm_heap结构体中,这两个池子是分开的。Zend MM 在分配内存时,会先尝试从 freeable 池中复用空闲块,如果不够再从系统批量采购。这也解释了为什么一个 “内存占用 200MB” 的 PHP 进程,在处理完同一个请求后,memory_get_usage()可能只报告 20MB,但进程的 RSS 却依然居高不下——因为 Zend MM 只是回收了小块内存,并没有真正把整块 chunk 还给操作系统。

3.3 chunk 与页面的组织方式

我再补充一个底层细节:Zend MM 的 chunk 默认大小是 2MB,它内部又按 4KB 页(page)划分。内存分配时,Zend MM 会按「三级分级」的思路处理:

  • 小于 3KB 的内存:从已经分配好的 page 内的 segment 中切分,速度极快。
  • 小于 2MB 的内存:从 chunk 内部寻找合适的 page 组合,形成 segment 分配。
  • 大于 2MB 的内存:直接绕过 chunk,单独向系统mmap申请。

这套机制让 PHP 在短生命周期请求场景下表现得非常出色,因为大部分内存分配集中在“小块”区间,完全可以在用户态完成管理,不需要反复打扰内核。


4. 实战复盘:一次 OOM 故障的完整排查与修复

理论讲得再多,不如看一次真实案例。下面我把开头提到的那场内存事故,具体还原一遍整个排查和修复过程。这一节你可以把它当成一个“剧本”来读,里面涉及的命令和代码都是可以直接复用的。

4.1 故障现场

项目环境是这样的:

  • PHP 8.1,PHP-FPM,pm.max_children = 50
  • 单台 8GB 内存的云主机
  • 流量高峰集中在早上 8:30-9:30

某天早上 9 点左右,监控告警触发:服务器内存使用率超过 95%。我登录服务器后第一件事就是看进程状态:

free -m ps aux --sort=-rss | head -20

结果很吓人,30 个 php-fpm 进程,每个 RSS 都接近 150MB。正常情况下,这个项目的每个进程内存应该在 60MB 以内。这意味着平均每个进程多出接近 90MB 的内存。

接着看 FPM 状态:

curl http://127.0.0.1/pm_status

输出显示max_children_reached比较高,而且last request memory差异巨大,从 50MB 到 160MB 不等,说明部分进程已经处于“内存畸形”状态。

4.2 用 strace 定位内存分配行为

为了搞清楚内存到底去哪儿了,我使用了strace来跟踪内存相关的系统调用:

strace -f -p <php-fpm-pid> -e trace=mmap,munmap,brk -o /tmp/strace_php.log

在一段短时间的采集后,我分析日志,发现mmap调用异常频繁,而且brk调用不断把进程的堆顶往上推。这说明 PHP 内部在大量向操作系统申请内存,而 Zend MM 本应通过复用空闲块来减少这种系统调用,但现在“复用”并没有生效。

更进一步,我用gdb附加到一个 PHP-FPM 进程,调用内部内存管理接口打印堆信息:

gdb -p <pid> call zend_mm_info(&zend_mm_heap)

输出里有一项非常显眼:

large_free_buckets: 0 free_buckets: 0 used: 154828800

结合业务代码,问题终于暴露了。

4.3 罪魁祸首:跨请求持久化的静态数组

这个项目有一个 UserSession 类,代码如下:

class UserSession { private static array $cache = []; public static function get(int $userId): ?UserSession { if (isset(self::$cache[$userId])) { return self::$cache[$userId]; } // 模拟从数据库加载 $session = self::loadFromDb($userId); self::$cache[$userId] = $session; return $session; } }

表面上看没什么问题,但注意:static::$cache是类级别的静态属性,它存储在 PHP 进程的静态变量区。在 PHP-FPM 的常驻进程模型下,这个属性一旦被赋了值,就会一直存在直到进程结束。同一个 FPM worker 每处理完一个请求后,UserSession::$cache并不会被清空,它就会像一个“缓存仓库”一样不断膨胀。

更糟糕的是,第三方 SDK 内部也有类似的实现,它把用户订单信息缓存到了静态属性里,每个进程每天要处理上千个请求,静态数组越积越大,最终直接把内存拖垮。

4.4 修复思路:从根上理解你的数据生命周期

问题清楚了,接下来就是选择方案。这里我不只是给你答案,而是把这个选择的过程讲透。

  • 方案 A(最简单):去掉静态缓存,每次直接从数据库读取。这样做虽然行,但会导致数据库压力剧增,原本缓存实现的性能优化就没了。
  • 方案 B(引入外部缓存):把静态缓存替换为 Redis 或 APCu。这是最稳妥的方案。APCu 是进程内共享存储,但它是采用共享内存实现的,不占用 PHP 进程自己的内存空间,生命周期与进程一致。Redis 则是跨进程共享。
  • 方案 C(限制静态缓存条数):在静态属性里加一个最大条数限制,超出就清理。这能缓解膨胀速度,但治标不治本,如果并发用户量大依然会出问题。

实际上我最终选择了方案 B,用 APCu 替换了静态数组。修改后的代码大致长这样:

class UserSession { private const CACHE_PREFIX = 'user_session:'; private const CACHE_TTL = 300; public static function get(int $userId): ?UserSession { $cacheKey = self::CACHE_PREFIX . $userId; $cached = apcu_fetch($cacheKey, $success); if ($success) { return $cached; } $session = self::loadFromDb($userId); apcu_store($cacheKey, $session, self::CACHE_TTL); return $session; } }

这里有一个关键点:为什么 APCu 不会拖垮 PHP 进程内存?因为 APCu 的存储用的是系统共享内存(shm),并不走emalloc,也不走pemalloc。它在 PHP 进程之外独立存在。这样一来,无论 PHP 进程处理了多少个请求,它的$cache不会无限增长。

提示:不要小看静态属性在 FPM 下的持久化能力。只要你用了static::$variable,并且这个变量在请求结束后没有被显式清理,它就会一直存活到当前 worker 进程结束。很多人写“常驻内存”代码时,第一反应是开 Swoole 或 Workerman,但很少意识到 FPM 本身也有类似的“隐式常驻”场景。

4.5 修复后的效果验证

上线后,我继续观察了三天。free -m显示内存稳定在 25% 左右。FPM 每个进程的 RSS 也从 150MB 降回到 60MB 上下。pm.status里的last request memory最大值几乎不再超过 70MB。

更重要的是,整个排查过程让我意识到:内存分配策略不是纯理论,它是生产事故的第一现场。


5. 深度实践:什么时候该用 pemalloc,什么时候该避开

如果你不写 PHP 扩展,那你大概率不会直接在代码里调用emalloc和pemalloc——这两个函数是给 C 扩展开发者用的。但“什么时候用持久化分配”这个决策逻辑,对你设计和评估一个 PHP 系统的内存模型同样重要。这一节我把场景展开讲清楚。

5.1 扩展开发视角:pemalloc 的正确姿势

假如你要写一个 PHP 扩展,需要在请求之间缓存一些数据结构,比如一个全局配置对象。你可能会写出这样的代码:

PHP_FUNCTION(my_ext_cache_get) { zval *cache; // 假设 cache 是一个全局变量 if (cache == NULL) { // 在这里创建对象 MAKE_STD_ZVAL(cache); array_init_size(cache, 10); // 注意:这里用的是 pemalloc,且 persistent = 1 cache = pemalloc(sizeof(zval), 1); } RETURN_ZVAL(cache, 1, 0); }

这样做合理吗?表面上看,pemalloc(..., 1)会直接调用系统malloc,分配的内存在请求结束后依然存在。但如果这个缓存是一个zval,事情就没这么简单。

zval是 PHP 引用计数体系的核心。当一个zval被pemalloc分配后,它的声明周期脱离了 Zend MM。这意味着你必须在它“退休”时手动调用pefree来释放,否则就会内存泄漏。但更麻烦的是,如果这个zval又指向了其他用emalloc分配的内部结构(比如一个 string),那么当这个zval被销毁时,析构逻辑会尝试efree内部的 string,这就出现了混用释放,大概率直接导致崩溃。

所以,在扩展开发里,不要轻易在zval结构上使用pemalloc(..., 1)。正确的做法是:

  • 全局的简单数据类型(如char *、int *),且不需要被 PHP 引用计数管理,可以用pemalloc。
  • 任何会被 PHP 变量体系持有的结构,请放在pemalloc之外,而是使用emalloc分配,再通过zend_register_persistent_*之类的方式挂载到持久化资源列表里。
  • 如果不确定,优先用emalloc,然后在RINIT(请求初始化)阶段创建、在RSHUTDOWN(请求关闭)阶段释放。这个模式虽然每次请求都有开销,但安全得多。

5.2 业务开发视角:如何模拟“无意的 pemalloc”

业务 PHP 代码里不会出现pemalloc这个关键词,但很多行为在效果上等同于pemalloc——只要让某些数据跨请求存活,你就在无意中“模拟”了 pemalloc 的行为。下面这些场景,是我这几年排查下来最容易中招的:

场景行为表现是否持久化风险等级
静态属性缓存static::$data[$key] = $value是(跨请求存活)高
全局变量注册$GLOBALS['config']在常驻进程中反复填充是中
单例对象中的集合属性单例类的private array $items是高
APCu / Redis 缓存外部存储取决于存储介质低(可控)
SwooleTable/Array常驻内存结构是(但生命周期明确)中(看是否清理)

上面表格里第一行和第二行,如果你在 FPM 下不主动清理,它们的行为就等价于“用pemalloc分配了不随请求释放的内存”。这也是为什么很多 FPM 项目跑久了内存就上去——如果每天处理 10 万个请求,每个请求往静态数组里塞一条 1KB 的数据,那这个 worker 进程一天就会多占近 100MB 内存,永不释放。

5.3 如何查看当前 PHP 的内存分配模式

如果你想知道当前 SAPI 下pemalloc是否“真正持久化了”,有一个小技巧:写一个 PHP 脚本,调用一个会触发持久化分配的扩展函数,比如pdo_mysql的连接池、mysqli的persistent连接,然后对比进程内存的变化。

更直接的方式是使用 PHP 内置函数memory_get_usage()和memory_get_usage(true):

$usageReal = memory_get_usage(true); // 由 Zend MM 实际向系统申请的内存 $usageCurrent = memory_get_usage(); // 脚本当前使用的内存

注意这两个值之间的差值就是 Zend MM 从系统批量申请但尚未使用的空闲内存。这个数值越大,说明内存池“囤货”越严重。在 CLS 模式下跑一个脚本,然后对比php-fpm模式下的差值,你能直观感受到不同 SAPI 下 Zend MM 的行为差异。


6. 手把手实操:从零分析一个 PHP 进程的内存画像

理论讲完了,接下来直接上手。我带你从零分析一个 PHP-FPM 进程的内存分配画像,包括用到的工具、命令和判断逻辑。这一部分你将收获一套可以反复套用的排查流程。

6.1 环境准备

你需要准备一个测试环境,推荐用 Docker 或 Homestead 都行,关键是 PHP 版本建议 7.4 及以上,下面所有操作都以 PHP 8.1 为例。

先确认环境:

php -v # PHP 8.1.20 (cli) (built: Jul 5 2023 10:00:00) ( NTS )

6.2 写一个测试脚本观察内存变化

创建memory_test.php:

<?php echo '初始 current: ' . memory_get_usage() . PHP_EOL; echo '初始 real: ' . memory_get_usage(true) . PHP_EOL; $data = []; for ($i = 0; $i < 100000; $i++) { $data[] = str_repeat('x', 128); } echo '填充后 current: ' . memory_get_usage() . PHP_EOL; echo '填充后 real: ' . memory_get_usage(true) . PHP_EOL; unset($data); echo '释放后 current: ' . memory_get_usage() . PHP_EOL; echo '释放后 real: ' . memory_get_usage(true) . PHP_EOL;

用 CLI 模式运行:

php memory_test.php

输出大致如下:

初始 current: 352720 初始 real: 2097152 填充后 current: 14455256 填充后 real: 16777216 释放后 current: 353344 释放后 current: 2097152

注意看“释放后 real”还是 2097152——进程向系统申请的内存并没有因为unset而下降,因为 Zend MM 保留了这个 chunk 以备复用。这解释了为什么很多程序员的直觉(“我的数组释放了,内存应该还回去”)在 PHP 里是错的。

6.3 用 Valgrind 检查扩展层面的内存泄漏

如果你在写扩展,并且怀疑emalloc和pemalloc用错了,最有效的工具是 Valgrind。假设你的扩展编译好了,可以这样跑:

USE_ZEND_ALLOC=0 valgrind --leak-check=full --error-exitcode=1 php -d extension=myext.so leak_test.php

这里要特别提一下USE_ZEND_ALLOC=0这个环境变量。它的作用是把 PHP 的 Zend MM 关闭,让所有内存分配直接走系统的malloc,这样 Valgrind 才能准确报告每一块未释放的内存的来源。如果不设置这个变量,大部分内存会被 Zend MM 缓存,Valgrind 会报告“仍被占用”而误判为泄漏。

经验:用 Valgrind 检查 PHP 扩展时,务必先设置USE_ZEND_ALLOC=0。否则你看到的报告里全是 Zend MM 的缓存,根本定位不到自己的代码问题。

6.4 生产环境的轻量排查技巧

生产环境不一定有 Valgrind,但你可以用 Linux 自带的工具快速判断内存是不是被 PHP 内部缓存“扣住”了:

cat /proc/<php-fpm-pid>/smaps | grep -E '^Size|^Rss|^Pss' | head -50

重点看Rss和Pss,如果Rss很大但Pss相对较小,说明这块内存是多个进程共享的(比如 OPcache 的共享内存),不需要担心。如果Rss和Pss都大,并且地址段集中在heap或anonymous,那就是 PHP 进程自身占用的内存。

还有一个很实用的工具是pm.status里的max_children和listen queue,它们能告诉你 FPM 是否已经因为内存问题开始拒绝了新请求。


7. 常见问题与排查技巧实录:这些坑我替你先踩了

这一节我整理了这几年里围绕内存分配策略最常被问到的问题,以及我在实战中总结的经验教训,希望你能少走一些弯路。

7.1 为什么我的 PHP 进程内存一直涨,但看不到明显的代码问题?

这是最高频的问题。通常有三个原因:

  • 静态属性或全局变量在不断累积数据。用$GLOBALS或static属性存储了请求级数据,但是忘了在请求结束时清理。FPM 的 worker 不会自动回收这些数据。
  • OPcache 的opcache.memory_consumption设置过大。这只影响共享内存,不占进程 RSS。
  • 某些扩展存在内存碎片化。Zend MM 虽然按块管理,但长期运行后,大块内存被拆散成碎片,无法有效复用,导致进程 RSS 缓慢上升。

我的实操建议是:先判断是不是静态数据的问题,在php-fpm.conf里把pm.max_requests设置成一个值(比如 1000),让 worker 处理一定请求数后自动退出重启,这样能兜底。

pm.max_requests = 1000

7.2memory_get_usage(true)和memory_get_usage()的差值巨大,正常吗?

正常。因为 Zend MM 会一次性向系统申请比较大的内存块(默认 2MB 起步),memory_get_usage(true)返回的是 Zend MM 实际持有的内存大小,memory_get_usage()是当前请求实际分配的数据大小。两者差值大,代表 Zend MM 池子里有大量空闲块。如果你的业务运行稳定,这种“预支”内存不是问题。

但是,如果你在 CLI 长驻脚本(比如php worker.php)里跑了一晚上,memory_get_usage(true)不断上升,说明你的代码可能在往某个全局容器里塞数据,或者有循环引用导致无法回收。

7.3 为什么unset()释放了变量但内存没降?

这在前面已经解释过了。unset只是告诉 Zend MM “这一块可以回收”,Zend MM 把它标记为空闲块,并不会立刻还给操作系统。如果你确实需要降低进程内存,可以考虑:

  • 在循环处理完一批数据后,调用gc_collect_cycles()强制回收循环引用。
  • 对于超大数组(比如几百 MB),可以考虑使用yield配合迭代器,避免一次性载入内存。

7.4pemalloc能用在所有 PHP 扩展里吗?

不能。绝大多数的业务型扩展(比如 gd、mysqli 的非持久化部分)在请求结束后都需要清资源,如果你强行调用pemalloc分配数据但不注册到持久化资源列表里,你会发现请求结束后内存泄漏,且无法通过memory_get_usage()看到。最麻烦的是,这种泄漏只有在进程退出时才被系统回收,高并发下非常致命。

所以,扩展开发中凡是不确定的地方,我都推荐遵循一条铁律:优先用emalloc处理请求级数据;必须跨请求的简单资源(如文件句柄、连接句柄),用pemalloc并配合rsrc注册机制管理;对于复杂结构(尤其是zval),用持久化资源列表或者干脆用外部存储。

7.5 怎么避免被第三方库坑了内存?

第三方库如果使用了静态缓存且不清理,你很难直接改它的源码。我的做法是:

  • 在php-fpm.conf中设置pm.max_requests,确保 worker 定期重启。
  • 如果第三方库提供了清理方法(比如clearCache()),在register_shutdown_function或请求结束的钩子里调用它。
  • 使用preload特性时,特别注意被预加载的类中的静态属性——它们会在所有请求间共享,而且不会被重置。

7.6 关于persistent标志位的冷知识

很多人以为pemalloc的persistent标志是扩展自己控制的,其实最终决定权在 SAPI 启动阶段。即便你传了persistent = 1,如果当前 SAPI 的startup没有把全局变量persistent置为 1,pemalloc依然退化为emalloc。因此,相同的扩展代码在不同 SAPI 下可能表现不同。这也是为什么你在 CLI 下测试扩展一切正常,到了 FPM 下却发现内存泄漏——请先检查你的 SAPI 是否真的开启了持久化支持。


8. 更深一层:Zend MM 的调试手段与扩展开发注意事项

如果你已经走完了前面所有步骤,仍然需要深入内存分配的底层调试,那么这一章就是为你准备的。虽然偏进阶,但这部分能力是普通 PHP 工程师和资深架构师之间的分水岭。

8.1 编译一个带 Debug 的 PHP

要调试 Zend MM 的行为,最好编译一个 Debug 版本的 PHP。

./configure --enable-debug --enable-maintainer-zts make -j4 make install

--enable-debug会启用 Zend MM 的内部断言(assertion),这意味着每次内存操作都会做更严格的安全性检查。一旦你误用了emalloc和pemalloc(比如混用释放),Debug 版本会直接报出内存错误并定位到具体行,而不是在生产环境里悄无声息地崩溃。

8.2 用 gdb 打印 zend_mm_heap

在 Debug 版本上,你可以更直接地检查一个 PHP 进程的堆状态:

gdb -p <pid> (gdb) call zend_mm_info(zend_mm_heap)

这个命令会打印 chunk 总数、已用内存、空闲内存等关键信息。如果segments的数量一直在长,说明进程请求的内存块非常多,而且没有合并。

8.3 混用 emalloc 和 free 的致命后果

最后强调一个最常见的崩溃原因:在自定义扩展中,分配和释放函数不匹配。

// 错误示范 pemalloc(size, 1); // 使用 malloc 分配 efree(ptr); // 尝试使用 Zend MM 释放

这类代码轻则导致堆损坏,重则使进程直接崩溃(Segmentation Fault)。原因很简单:malloc分配的内存来自系统堆,而efree期望的是一块由 Zend MM 管理的 chunk 内的内存。两者的元数据格式完全不同,efree会尝试读取一个并不存在的 chunk 头,瞬间触发非法内存访问。

正确的方式是严格配对:

  • emalloc↔efree
  • pemalloc(ptr, 1)↔pefree(ptr, 1)
  • pemalloc(ptr, 0)↔efree(ptr)

无论是一般工程实践还是内存安全层面,这个铁律都要记住。


9. 写在最后的一个建议

考虑了半天,还是想在这个位置说两句个人体会。

内存分配策略这话题,表面上看是 C 语言层面的底层机制,讨论的都是zend_alloc.c里的实现细节。但真正让我把它当成一个“必修课”的,还是那些线上事故。每次看到监控面板上内存曲线跟心跳一样冲到顶,然后 FPM 开始批量杀进程,那种感觉非常难受。而事后复盘时,绝大多数问题都不是 PHP 语言本身的内存管理缺陷,而是我们对内存生命周期的理解有偏差,对emalloc和pemalloc的边界模糊不清。

如果你有时间,我建议你专门花一个下午做一个实验:用pm.max_requests = 0跑一个包含静态缓存的脚本,然后用前面讲到的smaps和pm.status观察一个 worker 从请求 1 到请求 1000 的内存变化曲线。这个实验做完,你对“为什么需要理解内存分配策略”会有一次非常直观的感知,比看任何文章都管用。

我个人在实际操作中的体会是:拥抱常驻进程模型的 PHP 项目(比如 FPM 长生命周期、Swoole、Workerman),本质上已经把内存管理的部分责任从语言运行时转移到了开发者自己身上。理解emalloc和pemalloc的职责划分,不是在研究一个冷知识,而是在为自己的每一行代码的安全边界负责。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 3:25:20

Altium Designer信号完整性仿真实战:从反射串扰到IBIS模型

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 3:25:20

用buzz搭建实时热点监控系统:从数据采集到爆发预警的完整实践

凌晨一点半&#xff0c;我正准备关电脑&#xff0c;手机弹出一条推送&#xff1a;某款老牌汽水因为包装文案突然冲上热搜尾部&#xff0c;不到两小时就蹿到了前十。只要当晚跟进&#xff0c;至少能吃下两波流量。可团队里没有任何人知道这条线索&#xff0c;等大家第二天醒来才…

作者头像 李华
网站建设 2026/9/29 3:23:07

Sqoop实战:MySQL到HDFS数据导入原理、配置与调优

搞大数据的人&#xff0c;基本都绕不开这么件事&#xff1a;业务数据在MySQL里躺着&#xff0c;数仓在HDFS上等着分析&#xff0c;中间这一公里怎么打通&#xff1f;我早年最早用的是自己写Java程序起多线程跑JDBC&#xff0c;后来换成Sqoop才发现&#xff0c;这玩意把并行导入…

作者头像 李华