PHP Swoole的内存分配逻辑,Swoole 与 ZendMM 协同机制

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)。

这也是为什么你会看到一种典型现象:PHP 的 memory_get_usage 很正常,但进程 RSS 仍可能增长(增长点在 Swoole/C 层或系统分配器缓存)。

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};
    /* ... */
}
关键结论:sw_free() 之后,内存通常只是“回到系统分配器的缓存/arena”,不保证立刻归还 OS(RSS 未必下降)。

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) {}
结论:Worker 进程内存“涨上去后不降”不一定是泄露,可能就是池化/缓存设计使然(用空间换性能)。

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);
}
结论:只要对象最终走到了 sw_shm_free(),就会触发 munmap(),更接近“真正归还 OS”。

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
结论:如果协程栈走的是 sw_malloc,那么“协程结束”也不保证 RSS 下降;只有走 munmap 的栈,才更可能把映射交还 OS。

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();
}
一句话:Swoole 并不是“全部内存都走系统 malloc”,它会刻意把一部分缓冲区放进 ZendMM 的体系里,方便生命周期管理与统计观测。

4. 回收与归还 OS:请求结束会发生什么?什么时候 RSS 才会下降?

在常驻 Worker 中,请求结束后“释放”确实会发生,但释放的去向可能是:回到 ZendMM 缓存、回到系统分配器缓存、回到 Swoole 内存池,或者解除映射(munmap)。因此你看到的 RSS 不一定会像“短进程脚本”那样掉下来,这是一种性能权衡。

4.1 请求结束:释放通常发生,但多数是“回到缓存”,不是“还给 OS”

在常驻 Worker 中,请求结束后:

- PHP 对象大多会析构,释放回 ZendMM(但 ZendMM 会缓存 chunk 复用)
- Swoole 对象如果生命周期结束,会 sw_free(但系统分配器会缓存/碎片化)
- 池化内存(如 GlobalMemory)可能根本不逐块 free,而是 Worker 生命周期结束才回收

因此:在 Worker 里,“请求结束 = 全部归还 OS”基本不成立;更常见的是“释放→缓存→复用”。

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 走势定位)

推荐做法:把“RSS / Private_Dirty / php_real/php_used / coroutine_num / connection_num”定时打点到日志里,几轮压测就能确定增长来自 PHP 还是 Swoole。

问题记录

记录一些使用过程中,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)