PHP Swoole的内存分配逻辑,Swoole 与 ZendMM 协同机制
- PHP笔记
- 12天前
- 92热度
- 0评论
1. 全景:Worker 进程里的“两套内存体系”(ZendMM vs Swoole)
Swoole 的 Worker 进程是“常驻进程”,既跑 PHP 用户代码,也跑大量 C/C++ 网络与协程逻辑。因此你在排查内存时,必须先建立一个基本认知:Worker 里同时存在 PHP/Zend 的内存体系(ZendMM)和 Swoole/C 层的内存体系(系统分配器/内存池/共享内存)。很多“PHP 内存正常但 RSS 仍增长”的问题,本质就是这两套体系的统计口径不同。
1.1 一句话理解:PHP 变量归 ZendMM,Swoole 结构归系统分配器
在 Swoole 的 Worker 进程里执行 PHP 用户代码时,绝大多数 PHP 变量/数组/字符串/对象都通过 Zend 引擎的 API 分配:
- emalloc / efree / erealloc → ZendMM(zend_alloc)
而 Swoole 自己的网络、协程、缓冲区、内存池等,大多使用 sw_malloc/sw_free 这一层封装,默认最终落到系统 malloc/free(或你编译时链接的 jemalloc/tcmalloc)。
1.2 sw_malloc 的真身:std_allocator(默认就是 malloc/free)
Swoole 4.8 的 sw_malloc/sw_free 只是把请求转交给全局结构 SwooleG 上的 std_allocator:
- 文件:src/core/base.cc
- 链接:https://github.com/swoole/swoole-src/blob/v4.8.13/src/core/base.cc
对应的代码片段(注意缩进与换行):
void *sw_malloc(size_t size) {
return SwooleG.std_allocator.malloc(size);
}
void sw_free(void *ptr) {
return SwooleG.std_allocator.free(ptr);
}
并且 swoole_init() 默认把它绑定到系统 malloc/calloc/realloc/free:
void swoole_init(void) {
/* ... */
SwooleG.std_allocator = {malloc, calloc, realloc, free};
/* ... */
}
2. Swoole 4.8 的三类核心内存:池化内存、共享内存、协程栈
如果把 Worker 进程当成一个“长期运行的服务”,那么 Swoole 为了性能会尽量减少频繁的 malloc/free,并把一些内存做成池化/缓存结构;同时在涉及进程间通信或高性能数据结构时会使用 mmap 共享内存;在协程模式下又会为每个协程分配独立栈空间。这三类内存是造成 RSS 波动的主要来源。
2.1 池化内存(MemoryPool):为什么看起来“留着不释放”?
Swoole 有一套 MemoryPool(内存池)体系,很多结构的目标是“少 malloc/free、多复用”。其中最典型的是 GlobalMemory:它按页(默认 2MB)不断扩容分配,但不会逐块 free。
- 文件:src/memory/global_memory.cc
- 链接:https://github.com/swoole/swoole-src/blob/v4.8.13/src/memory/global_memory.cc
源码注释直接写明(生命周期型内存):
/**
* After the memory is allocated,
* it will not be released until it is recycled by OS when the process exits
*/
并且 GlobalMemory::free 是空实现:
void GlobalMemory::free(void *ptr) {}
2.2 共享内存:sw_shm_malloc/sw_shm_free = mmap/munmap
共享内存这条路径相对“干净”:分配用 mmap,释放用 munmap。也就是说,只要对象最终走到了 sw_shm_free(),它更接近你直觉里的“释放给操作系统”(解除映射)。
- 文件:src/memory/shared_memory.cc
- 链接:https://github.com/swoole/swoole-src/blob/v4.8.13/src/memory/shared_memory.cc
mem = mmap(nullptr, size, PROT_READ | PROT_WRITE, flags, tmpfd, 0);
/* ... */
if (munmap(object, size) < 0) {
swoole_sys_alert("munmap(%p, %lu) failed", object, size);
}
2.3 协程栈:可能是 mmap,也可能是 sw_malloc(决定了 RSS 是否容易回落)
每创建一个协程,都会创建一个 Context,并分配一段“协程栈”。Swoole 4.8 支持两条路径:
- 开启 SW_CONTEXT_PROTECT_STACK_PAGE:用 mmap 分配/用 munmap 回收
- 否则:用 sw_malloc 分配/用 sw_free 回收
- 文件:src/coroutine/context.cc
- 链接:https://github.com/swoole/swoole-src/blob/v4.8.13/src/coroutine/context.cc
#ifdef SW_CONTEXT_PROTECT_STACK_PAGE
stack_ = (char *) ::mmap(0, stack_size_, PROT_READ | PROT_WRITE, mapflags, -1, 0);
#else
stack_ = (char *) sw_malloc(stack_size_);
#endif
/* ... */
#ifdef SW_CONTEXT_PROTECT_STACK_PAGE
::munmap(stack_, stack_size_);
#else
sw_free(stack_);
#endif
3. 结合 ZendMM:为什么 Swoole 还要提供 php_allocator / zend_string_allocator?
Swoole 作为 PHP 扩展时,有一个现实问题:如果所有内部缓冲区都走系统 malloc,那么这些内存既不容易被 PHP 的 memory_get_usage 观测,也不一定跟随请求生命周期释放。为此,Swoole 提供了“把部分内存交给 ZendMM 管”的 allocator,让某些缓冲区直接变成 Zend 引擎可管理、可统计的内存。
3.1 php_allocator:让部分 Swoole 内部结构走 emalloc/efree(纳入 ZendMM 管控)
在 Swoole 作为 PHP 扩展运行时,有些内部结构会选择使用 ZendMM 来分配,这样做的好处是:
- 这些内存会被 ZendMM 统计(更容易用 memory_get_usage 观察)
- 更贴合 PHP 请求生命周期(RINIT/RSHUTDOWN)
- 文件:ext-src/php_swoole.cc
- 链接:https://github.com/swoole/swoole-src/blob/v4.8.13/ext-src/php_swoole.cc
static void *_sw_emalloc(size_t size) { return emalloc(size); }
static void _sw_efree(void *address) { efree(address); }
static swoole::Allocator php_allocator{
_sw_emalloc,
_sw_ecalloc,
_sw_erealloc,
_sw_efree,
};
3.2 zend_string_allocator:把缓冲区做成 zend_string,并在 Server 内部使用
Swoole 提供 sw_zend_string_allocator(),用于把一些缓冲区直接做成 zend_string(由 ZendMM 管控),并且在 Server 创建时用于 message_bus 等模块:
- 文件:ext-src/swoole_server.cc
- 链接:https://github.com/swoole/swoole-src/blob/v4.8.13/ext-src/swoole_server.cc
serv->message_bus.set_allocator(sw_zend_string_allocator());
if (serv->is_base_mode()) {
serv->recv_buffer_allocator = sw_zend_string_allocator();
}
4. 回收与归还 OS:请求结束会发生什么?什么时候 RSS 才会下降?
在常驻 Worker 中,请求结束后“释放”确实会发生,但释放的去向可能是:回到 ZendMM 缓存、回到系统分配器缓存、回到 Swoole 内存池,或者解除映射(munmap)。因此你看到的 RSS 不一定会像“短进程脚本”那样掉下来,这是一种性能权衡。
4.1 请求结束:释放通常发生,但多数是“回到缓存”,不是“还给 OS”
在常驻 Worker 中,请求结束后:
- PHP 对象大多会析构,释放回 ZendMM(但 ZendMM 会缓存 chunk 复用)
- Swoole 对象如果生命周期结束,会 sw_free(但系统分配器会缓存/碎片化)
- 池化内存(如 GlobalMemory)可能根本不逐块 free,而是 Worker 生命周期结束才回收
4.2 真正“归还 OS”的几条硬路径(按确定性排序)
1) sw_shm_free(共享内存)→ munmap
2) 协程栈启用 SW_CONTEXT_PROTECT_STACK_PAGE → 协程结束 munmap
3) Reactor 里可选 malloc_trim:尝试让 glibc 归还空闲页(效果依赖碎片/allocator)
4) Worker 进程退出/重启:OS 回收一切(生产常用兜底:max_request)
malloc_trim 在 Swoole 4.8 中的实现位置:
- 文件:include/swoole_config.h(开关与间隔)
链接:https://github.com/swoole/swoole-src/blob/v4.8.13/include/swoole_config.h
- 文件:src/reactor/base.cc(定时调用 malloc_trim)
链接:https://github.com/swoole/swoole-src/blob/v4.8.13/src/reactor/base.cc
5. 实战:如何用监控把“ZendMM 正常但 RSS 增长”的原因定位出来?
当你排查“疑似内存泄露”时,不要只盯 memory_get_usage(true/false)。在常驻 Worker 中,更推荐用“多指标闭环”去判断增长来源:到底是 ZendMM 内部缓存、系统分配器碎片,还是 Swoole 的连接/协程/内存池持有导致。
5.1 先看这三组指标(最小闭环)
- 脚本实际使用(ZendMM 视角):memory_get_usage(false)
- ZendMM 真实占用(含缓存):memory_get_usage(true)
- OS 视角物理内存:RSS(建议再加 Private_Dirty / PSS)
5.2 用趋势关联定位增长来源(推荐做法)
当你发现:
- PHP memory_get_usage(true/false) 很稳定
- 但 RSS(尤其是 Private_Dirty)持续增长
通常更可能是:
- Swoole 侧连接缓冲/输出积压(慢客户端/Keep-Alive)
- 协程未退出导致协程栈常驻
- 系统分配器碎片/缓存不归还
- 或少数情况下的 C 层路径泄漏(需要进一步结合 conn/co 走势定位)
问题记录
记录一些使用过程中,Swoole内存方面的问题
1.挂载到协程 Context 的数据”走 Swoole 内存还是 ZendMM?
“挂载到协程 Context 上的数据”走哪套内存,取决于你挂的是什么东西(C 缓冲区 vs PHP 变量/对象)。
1.1 Context 本体(协程上下文对象、协程栈)——走 Swoole/C 层
在 Swoole 4.8 源码里,协程栈在 src/coroutine/context.cc 分配:
- 开启 SW_CONTEXT_PROTECT_STACK_PAGE:mmap/munmap
- 否则:sw_malloc/sw_free(默认落到系统 malloc/free)
所以 协程 Context(至少栈这块)绝对不走 ZendMM,它是 Swoole 自己分配的。
1.2 如果是 PHP 变量/对象(zval、数组、字符串、对象)——走 ZendMM
- PHP 闭包 use ($bigArray) 捕获的大数组
- Swoole\Coroutine::getContext() 返回的那个 PHP 容器里塞进去的字符串/数组/对象
- 任意 PHP 变量生命周期跟着协程跑
这些都是 PHP 引擎对象,底层内存来自 emalloc/efree → ZendMM。即使它们“关联在协程上”,也不改变其分配来源。
1.3 如果是 Swoole 的 Buffer/Socket/自定义 C 扩展分配的内存——走 Swoole/系统分配器
例如某些底层 buffer(swString/C++ string/buffer)、网络收发缓冲、OpenSSL/HTTP2 等库的缓存,只要它们在 C/C++ 层 sw_malloc/malloc/new 出来,就不走 ZendMM。
2.正常情况下,协程结束 Context 会释放吗?
会销毁 Context,并执行析构释放栈等资源。
典型流程是:协程函数返回(或异常导致协程结束)→ 协程被调度器标记结束 → C++ 协程对象及其 Context 被销毁,Context::~Context() 会释放栈:
- mmap 栈:munmap(stack_, stack_size_)(更“真实”归还 OS 映射)
- malloc 栈:sw_free(stack_)(是否还 OS 取决于分配器/碎片)
3.哪些情况下“看起来没释放”(其实是协程没结束 / 内存没还 OS)?
- 协程没结束:还在等 IO、Channel、Timer、sleep、死循环、hook 的阻塞调用等
→ Context 不会释放,因为协程仍存活。 - 协程结束了,但 RSS 不降:栈走 sw_free → 回到 malloc arena,不一定立刻还内核。PHP 侧对象释放回 ZendMM → ZendMM 缓存 chunk,不一定 munmap
- 把大对象挂在“全局/静态变量/长生命周期容器”(例如某个全局数组存了协程 context 里的对象引用)
→ 协程结束后 PHP 对象仍被引用,ZendMM 不会回收这部分。
4.协程栈什么时候走 munmap(栈用 mmap 分配)?
在 Swoole 4.8 里,协程栈走 sw_malloc 还是走 mmap/munmap,不是运行时“随机选择”,而是由编译期宏决定的,核心开关就是:
- SW_CONTEXT_PROTECT_STACK_PAGE
源码位置:src/coroutine/context.cc(你之前文章里贴过的那段 #ifdef SW_CONTEXT_PROTECT_STACK_PAGE)
