在线上高并发场景里摸爬滚打多年后,你会发现PHP最容易被忽略但又最致命的一环,往往不是业务代码写得好不好,而是底层内存分配策略到底合不合理。今天聊的主角是PHP内核里的两员大将:emalloc和pemalloc。这两个函数是Zend Memory Manager(ZendMM)对外提供的主要接口,负责管理PHP进程内所有动态内存的申请与释放。如果你写过PHP扩展、或者遇到过内存泄漏、内存暴涨、把PHP常驻内存跑崩过,那这篇文章就是给你准备的。我会从底层设计思路讲起,再到实际使用中的选择标准,最后把排查内存问题的实战经验一并倒出来。
1. 为什么PHP需要一套独立的内存分配策略
1.1 从系统malloc说起
几乎所有C程序都在直接调用系统提供的malloc/free来管理堆内存,这套接口成熟、高效,而且经过几十年的优化,普通场景下完全够用。但PHP是个例外,它运行的方式和普通的C程序差别很大。
普通C程序往往是单次执行,要么是命令行工具跑几秒就退出,要么是daemon进程里的某个模块在稳定状态下反复执行同一逻辑。PHP则不同,一个PHP-FPM worker进程会在一秒内处理几十个甚至上百个请求,每个请求都会申请大量小内存,比如字符串、数组、对象结构体等。请求结束之后,这些内存又必须尽快释放干净,为下一个请求腾出完全隔离的干净环境。
问题在于,如果每次都直接调用系统malloc/free,会有两个痛点。第一,系统malloc为了平衡性能与碎片化,通常有比较复杂的数据结构和分配算法,高频次小内存分配的开销会非常可观。第二,也是更要命的,malloc并不理解PHP的请求生命周期,它只负责给一块内存,然后等着free来回收,但PHP要求的是“请求结束后,该请求申请的所有内存一个不剩地全部清空”。如果靠开发者在代码里手动释放每一个指针,那PHP扩展开发将成为一场噩梦,因为任何一个忘记释放的情况都会让内存无限增长。
1.2 ZendMM的诞生目标
正是为了应对这种高频、短生命周期、大量小内存分配的需求,Zend引擎实现了自己的内存管理层,也就是ZendMM。它相当于在PHP和系统malloc之间加了一个定制化的中转站。
我个人的理解是,ZendMM更像是一个私有化的内存池。这个池子会一次性向系统malloc申请比较大的内存块(比如2MB),然后再按照PHP自己的规则切割成一个个小内存槽位,供脚本里的变量、数组元素等对象使用。当一次请求结束时,ZendMM并不需要逐一判断每个指针是否还在使用,它只需要做一次集体回收:直接重置当前请求分配用的整个内存段,丢回池子里复用。这样做的结果就是,分配内存的开销被大量摊薄,释放内存也从O(n)级别摊成了O(1)级别的重置动作。
这个设计非常聪明,它把“内存管理策略”从“精确追踪每个块”转变成了“批量管理整个池”。对应到我们的主角,emalloc正是在这个池子上实现的标准分配函数,pemalloc则是嵌入了“是否跨请求存活”判断的更智能版本。理解了这层背景,后面所有细节就都能串起来了。
2. emalloc和pemalloc:核心差异与设计原理
2.1 emalloc:请求生命周期内的临时内存
emalloc是PHP扩展开发中最常用的内存分配函数,可以把它理解为PHP版的malloc。它的原型很简单:
void *emalloc(size_t size);调用这个函数,会在ZendMM当前请求对应的内存池中分配一块size字节的内存。关键点是,这块内存的生命周期默认绑定在当前请求上,请求一结束,ZendMM会直接释放或重置整个请求内存池,你分配的这块内存也跟着“灰飞烟灭”,不需要,也不能手动free。
实际写扩展时,你几乎不需要担心这个内存什么时候释放,因为你没法主动释放。ZendMM的机制就是这样,它在每个请求结束时自动清理所有通过emalloc申请的内存。这种策略导致一个很有意思的习惯:PHP扩展开发者在分配内存时胆子都很大,因为系统帮你兜底回收,不过这也带来了一个副作用,就是一旦内存被错误地存到全局变量里,跨请求保留,麻烦就来了。这种内存不会在请求结束时被释放,因为它挂在全局状态上,但ZendMM并不认识它,长期下来必然导致泄漏。
2.2 pemalloc:跨请求的持久内存
pemalloc的全称是persistent emalloc,它比emalloc多了一个参数:
void *pemalloc(size_t size, int persistent);第二个参数persistent是一个布尔开关。当它为0时,pemalloc的行为和emalloc完全一致:使用请求内存池,请求结束自动释放。当它为1时,pemalloc会绕过请求内存池,直接调用系统malloc分配内存,并且这块内存可以跨请求存活,直到你在合适的时候用pefree手动释放。
这里有个非常巧妙的抽象:你在写扩展时,可以用同一个函数名,通过persistent参数来决定内存的生命周期。如果你是给某个数据结构分配内存,而这个数据结构可能需要在多个请求之间共享(比如缓存、连接池),那persistent = 1就特别合适。如果你只是处理临时数据,那persistent = 0又让你无需操心释放。
但注意,pemalloc设计得“聪明”的前提是你必须告诉ZendMM是否持久。如果选错了,后果很隐蔽。最常见的就是:一个临时对象的内存被标记成persistent = 1,然后每个请求都分配一次,又没在合适的地方释放,轻则几百个请求后worker内存飙到几百MB,重则直接OOM崩溃。
2.3 两者的实现细节对比
从源码实现来看,emalloc和pemalloc在ZendMM内部走的是完全不同的两条路径。我们要想真正理解差异,就得深入看一层。
当persistent为0时,pemalloc内部直接调用emalloc逻辑。emalloc会从当前请求的堆(heap)上分配内存,这个heap是ZendMM为每个线程维护的一个对象池。分配成功后,ZendMM会在这个堆的内存块链表里登记一条记录,方便请求结束时的统一清理。由于ZendMM使用了自己的一套内存槽位复用机制,相同大小的内存分配会非常快,基本上是常数时间操作。
当persistent为1时,pemalloc直接调用系统malloc。系统malloc返回的是一个独立的、不挂在ZendMM heap上的内存块。也就是说,ZendMM对这块内存没有控制权,它不会在请求结束或者线程结束时自动清理。你需要手工用pefree释放,或者注册一个在进程退出前执行的清理函数。
还有一个容易忽略的细节:emalloc分配的内存,内存对齐是由ZendMM的堆配置决定的,ZendMM默认会做CPU cache line对齐(一般是64字节),以优化缓存命中率。系统malloc虽然也做对齐,但它的对齐规则和ZendMM内部选择的chunk大小可能并不一致,这也会导致在高频分配场景下性能差异。
我们可以用一张表快速对比两者的差异:
| 对比项 | emalloc | pemalloc(persistent=1) |
|---|---|---|
| 底层实现 | ZendMM内存池 | 系统malloc |
| 生命周期 | 当前请求结束时自动释放 | 跨请求存活,需手动pefree |
| 分配速度 | 极快,常数级复用槽位 | 受系统影响,有锁和系统调用开销 |
| 适用场景 | 请求内临时数据、变量 | 缓存、连接池、跨请求共享数据 |
| 释放方式 | 请求结束统一回收 | 手动pefree或注册清理钩子 |
| 内存碎片控制 | ZendMM内部通过块内存复用控制 | 依赖系统malloc碎片策略 |
3. 实操中如何选择与验证内存分配策略
3.1 在扩展开发中使用emalloc/pemalloc
写PHP扩展的时候,很多人上来就无脑用emalloc,这是基于安全习惯的默认操作。但实际编码中,我们经常需要做选择。我总结了一套可以“抄作业”的判断流程:
- 如果你的内存在当前请求内创建、使用、结束,不需要跨请求保存,毫不犹豫选emalloc。
- 如果内存需要跨请求保存,并且保存时间是确定的(比如某个全局配置结构体在PHP_MINIT阶段创建,在进程整个生命周期内使用),那就用pemalloc(ptr, 1),并在PHP_MSHUTDOWN阶段调用pefree。
- 如果内存是全局的,但大小会随请求变化、内容又依赖每次请求的状态,那就不要用pemalloc持久化,而是考虑用php的全局变量结合请求周期内存池,每次请求重新初始化,避免内存被不干净地复用。
给你一个典型的扩展伪代码片段,这样看更直观:
PHP_MINIT_FUNCTION(myext) { // 全局缓存:跨请求存活 myext_global_cache = pemalloc(sizeof(cache_entry_t), 1); myext_global_cache->data = NULL; return SUCCESS; } PHP_MSHUTDOWN_FUNCTION(myext) { // 进程结束时释放 if (myext_global_cache) { pefree(myext_global_cache, 1); } return SUCCESS; } PHP_FUNCTION(myext_do_something) { // 临时缓冲区:请求结束时自动释放 char *tmp = emalloc(4096); snprintf(tmp, 4096, "hello %ld", Z_LVAL_P(z_arg)); RETURN_STRING(tmp); // 注意,tmp不用手动释放,emalloc会在请求结束时被ZendMM回收 // 而且RETURN_STRING会拷贝一份,所以tmp丢了也不泄漏 }有人会问,RETURN_STRING拷贝了一份,那emalloc的tmp是不是有点浪费?这确实是个坑。PHP的核心API会自动管理返回的zval内存,如果你自己emalloc一个字符串然后用RETURN_STRING,等于多复制了一次。正确做法是用RETURN_STRINGL拷贝生成新的持久内存,然后自己再用efree释放临时块。很多人写扩展时内存翻倍增长,就是因为这类不必要的emalloc没释放。
3.2 通过配置和工具观察内存行为
如果你不写扩展,只是在用PHP开发Web或CLI应用,那你怎么感受到emalloc和pemalloc的存在呢?最直接的入口是memory_limit配置项。当PHP进程运行到某个请求时,ZendMM会统计当前请求已分配的内存总量,一旦超过memory_limit,就会抛出Allowed memory size of xxx bytes exhausted致命错误。
注意,这个统计的实际上是ZendMM通过emalloc分配的请求内存总量,不包含pemalloc持久内存。所以如果某个扩展大量用pemalloc分配了跨请求内存,你在memory_limit里是看不到的,看到的只是系统进程RSS不断上涨。
想要观察底层内存分配行为,我用的最多的工具是valgrind的massif和dhclient(抱歉,是heaptrack)分析。对PHP扩展开发来说,跑脚本时用valgrind做泄漏检测是非常有效的:
USE_ZEND_ALLOC=0 valgrind --leak-check=full php -c /path/php.ini -r 'echo "test\n";'这里有个关键技巧:USE_ZEND_ALLOC=0环境变量,会让PHP在启动时直接禁用ZendMM,所有emalloc调用会退化成系统malloc。这样才能让valgrind精确跟踪到每一块内存的分配与释放。如果不用这个变量,ZendMM会把大量内存池整体视为隐藏的,valgrind根本看不出哪些是真正的泄漏。
3.3 内存池的参数与优化
ZendMM并不是一个黑盒,它有几个关键参数可以被外部调整,典型的如error_reporting里的E_ERROR级别,还有一个非常隐蔽但实用的配置项:zend_mm_heap的大小策略。
从PHP源码的zend_alloc.c里可以看到,ZendMM为每个线程维护一个heap,这个heap会使用ZEND_MM_CHUNK_SIZE(默认2MB)作为一块连续内存的申请单位,再通过ZEND_MM_MAX_SCRATCH等参数控制内存碎片和缓存槽位数。这些数值无法通过php.ini修改,但我们可以在编译PHP时通过宏调整。不过说实话,默认值已经经过大量真实负载的调优,普通项目完全不需要动。
比较有意义的是pcre.backtrack_limit这类扩展级配置,它和内存分配关系密切。PCRE库在做正则匹配时,本身会申请大量临时内存,如果它在匹配过程中走到了请求内存池的边缘,ZendMM的分配算法会因为块耗尽而触发额外向系统malloc请求新块,导致性能下降。实测中,把backtrack_limit调高到1000000以上,对于复杂正则的内存分配性能有可感知的提升。
再就是opcache的interned_string_buffer配置。它实际上是一个独立的共享内存缓冲区,不是ZendMM管理的字符串池,但对PHP内存压力影响很大。当你把interned_string_buffer调大,很多重复字符串会被去重并常驻在共享内存中,减少了每个请求通过emalloc重复分配相同字符串的负担,这是我在高并发业务里用得最多的“无痛优化”手段。
4. 常见问题排查与避坑经验
4.1 内存泄漏与越界诊断
在PHP场景下,内存泄漏通常不是你想象的那种“忘记free”导致的,因为ZendMM会自动回收请求结束时的内存。真正的泄漏,基本都发生在以下三种模式里:
第一种:全局变量持有emalloc指针。比如你在扩展里用ZEND_TSRM的全局宏,把一个emalloc分配的内存块存到全局结构体中,然后在每个请求里反复覆盖这个指针,却不释放旧指针。由于指针不在请求内存池的“追踪范围”之外(ZendMM觉得它还在当前请求内),请求结束时它会被尝试清理,但因为全局变量还在指向它,逻辑上就造成了悬空引用,再往后分配就会用到一块已释放的内存,导致崩溃或者神秘报错。这种问题极难排查。
第二种:pemalloc的持久内存没有配套释放。比如在扩展里使用了pemalloc(1),但只在MINIT阶段分配,忘了在MSHUTDOWN阶段释放。FPM worker常驻,内存只涨不降,最后稳定在几GB。这种泄漏非常好定位,直接看RSS随时间线爬升即可。
第三种:C扩展中越界写。ZendMM分配的内存块会记录实际大小,如果你写越界,比如emalloc(10)却写入了memcpy(buf, data, 20),那么紧接着的另外一块内存的数据就被覆盖掉,最典型的表现是数组元素的值随机变化。你可以开启USE_ZEND_ALLOC=0配合valgrind跑一遍,越界问题会被valgrind在free时检测出来。
我提供一套排查泄漏的流程:
- 用
ps aux观察PHP-FPM各worker的RSS内存,如果某个worker的RSS持续上涨,就可能需要检查该worker处理的请求是否有横跨的全局缓存。 - 启用PHP-FPM的
pm.max_requests=500,每个worker处理500次请求后自动重启,能暂时缓解泄漏,但治标不治本。 - 用
pm.status以?full模式检查脚本状态,配合memory_get_usage(true)监测当前请求峰值内存。 - 在本地用
USE_ZEND_ALLOC=0 valgrind --leak-check=full运行同样业务脚本,对比系统级泄漏。
4.2 pemalloc持久连接带来的坑
很多面向数据库的PHP扩展,都会用pemalloc来保存持久化连接。比如mysqli的mysqli::pconnect(),底层在创建新的数据库连接时,就会把连接对象放入一个持久内存池里。这种设计本身是好的,能省去每次请求重建连接的开销。
但持久连接和pemalloc结合时有一个非常经典的坑:在连接的生命周期里,它会持有若干通过emalloc分配的临时资源,例如预处理语句对象、字符缓冲等。请求结束时,emalloc分配的资源被ZendMM自动释放,但持久连接对象还活在下一次请求里,这就导致连接对象的内部状态变成了“引用已释放内存”的野指针。下次请求再使用这个持久连接,最常见的结果就是segfault或者“MySQL server has gone away”。
解决办法有两个方向。第一个是确保扩展内部的所有连接关联资源都使用pemalloc持久分配,而不是emalloc,这需要扩展作者严格遵守规范。第二个是从业务侧规避:避免使用过长的持久连接生命周期,定期清理连接池,或者在FPM配置里设置pm.max_requests较小值来强制复用新worker。
我工作的一个项目里,把pm.max_requests从默认的无穷大改成了300,耗时2分钟,解决了线上偶发呆锁崩溃的问题,这正是pemalloc持久状态和请求期资源生命周期冲突导致的。
4.3 常见错误速查表
我在踩坑无数后,整理了一张速查表,方便大家快速定位问题。
| 问题现象 | 可能的根因 | 处理建议 |
|---|---|---|
| 请求内内存涨到memory_limit后立即崩溃 | 扩展中使用emalloc分配了大块临时内存且没有及时efree,导致请求峰值超限 | 检查扩展里循环内的emalloc,及时efree;提升memory_limit临时止损 |
| worker RSS持续上升,不随请求回落 | 扩展使用了pemalloc持久内存且不释放;或FPM未开启max_requests | 开启max_requests;用valgrind检测持久泄漏点 |
| valgrind报“still reachable” | ZendMM内存池本身保留未释放的chunk | 不用太担心,属于正常的内存池设计 |
| 启用USE_ZEND_ALLOC=0后性能骤降 | 绕过了ZendMM,所有分配走系统malloc,开销增加 | 仅用于内存调试,不要在生产环境开启 |
| 使用持久连接后连接状态错乱 | 扩展内部把持久连接关联资源误用emalloc | 升级扩展版本,或避免pconnect方式;用短连接 |
| 内存碎片化严重导致大块分配失败 | 频繁emalloc/efree不同大小的内存块 | 尽量避免请求内创建大量大小不一致的临时对象;开启ZendMM块复用特性 |
4.4 分享一个我自己写过的ZendMM监控工具
说完了理论,每个人都会都喜欢实操硬货。如果你整天被PHP内存问题折磨,有个很原始的土办法,就是用PHP扩展监控当前请求内存使用。先在你的扩展里注册一个预执行或请求结束时段的钩子,在这个钩子里读取zend_memory_usage(true):
PHP_FUNCTION(show_memory_stats) { zend_ulong real_size = zend_memory_usage(1); zend_ulong internal_size = zend_memory_usage(0); php_printf("real: %lu, internal: %lu", real_size, internal_size); }注意,zend_memory_usage(1)返回的是ZendMM通过系统malloc申请的总内存大小,包括了内存池扩大的部分,比memory_get_usage(true)更精确。这个数值如果在单请求内经多次数组操作后始终超过你预期,那你八成是生成了大量中间数组,可以用zend_memory_peak_usage查看峰值。
对于纯PHP应用,不需要写扩展,直接调用内置函数就能两者对比:
$start = memory_get_usage(true); // 业务逻辑... $peak = memory_get_peak_usage(true); $delta = memory_get_usage(true) - $start;但记住,内置函数的统计粒度是请求内存池,无法暴露pemalloc持久部分。如果想要完整进程级内存视图,还是得用/proc/<pid>/status里的VmRSS+VmSwap来做粗粒度监控。
从个人经历来看,真正吃透emalloc和pemalloc的价值,不在于你能背出源码里的结构体,而在于你面对“内存只涨不清”这类问题时,能快速思考:这是不是emalloc被错误挂到全局了?这是不是pemalloc忘了释放了?这套思维模型让我少熬了无数个定位崩溃的深夜。这个内容如果对你的项目也有帮助,接下来你可以尝试用systemtap或bpftrace去跟踪PHP用户态内存分配调用,看看emalloc和pemalloc在真实负载下的分配频次比值,那会是一个非常有说服力的性能分水岭。