一篇文章讲清 PHP 中生成器省内存的底层原理

阿乐
阿乐 管理员 年卡会员
发布于 2026-09-04 04:44 ·2 浏览 ·0 回复

为什么你的内存爆了,而别人没有?

在 PHP 社区里,生成器(Generator)经常被当作一种“黑魔法”来谈论:它能让处理十万条 Excel 数据的脚本稳稳运行,也能让遍历一个“无限”序列成为可能。但很多人对它的理解停留在“惰性求值”“边用边取”这种口头禅上。今天,我们就从底层内存模型入手,把这件事彻底讲透。

先看一个经典对比场景:

// 方案A:直接返回数组
function fetchRows() {
    $rows = [];
    for ($i = 0; $i < 1000000; $i++) {
        $rows[] = "row_" . $i;
    }
    return $rows;
}

// 方案B:使用生成器
function fetchRowsGen() {
    for ($i = 0; $i < 1000000; $i++) {
        yield "row_" . $i;
    }
}

`memory_get_peak_usage()` 的实测差异通常是几十 MB 对几百 KB。为什么会差这么多?这得从 PHP 变量存储的底层结构说起。

核心机制一:yield 不是返回值,是“暂停键”

要理解生成器,必须先明白 `yield` 与 `return` 的本质区别。

`return` 结束后,函数栈帧被销毁,所有局部变量释放。若返回一个数组,则该数组无论多大,都会完整存在于内存中,直到外部变量不再引用它。

`yield` 则不同:当执行到 yield 时,**当前函数的所有上下文(变量、行号、循环状态)会被冻结序列化,并保存到一个独立的执行栈中**。函数并没有结束,它只是被按下了暂停键。下次调用 `->next()` 时,PHP 会从刚才冻结的位置恢复执行,继续进入下一次循环。

这意味着什么呢?每一刻只有一行数据 ($i) 和它的状态指针占用内存,而不是全部数据。

$start = memory_get_usage(); // 约 0.4MB
foreach (fetchRowsGen() as $row) {
    // 每次循环内存增量几乎为 0
}
echo memory_get_usage() - $start; // 输出极小值

核心机制二:PHP 的写时复制与引用计数

但有人会问:“如果我把每行数据塞进一个数组再存起来,生成器不也一样会爆内存吗?”

问得好。这就要谈谈 PHP 变量底层的内存设计——引用计数(refcount) 与 写时复制(Copy on Write)。

看这段代码:

$a = str_repeat("x", 1024 * 1024); // 1MB 字符串,refcount=1
$b = $a;                            // refcount=2,不复制内存!

此时 `$b = $a` 并没有额外占用 1MB 内存,只是将 zend_string 结构的 refcount 增加。真正的内存复制发生在写入那一瞬间:

$b .= "y"; // 此时才真正复制一份,各占 1MB

生成器的高明之处在于:每次 yield 出去的值,在绝大多数情况下只读,不会写。外部 foreach 拿到这行数据做只读处理时,根本不会触发新的内存分配。如果你在循环里把数据推入一个大数组,那问题不在生成器,而在你的操作本身——任何方式都无法避免这种“全部保留”的需求。

核心机制三:生成器对象的内部结构

从 C 语言层面看,生成器实例是一个 `zend_generator` 结构体,内部持有一个关键的 `execute_data` 指针,指向当前执行上下文。这个上下文里包含了活动符号表(局部变量)、操作码指针,以及 yield 的位置。

当你调用 `$gen->current()`,PHP 内核从 execute_data 里取出当前生成的值;调用 `$gen->next()`,则重新进入执行引擎,向下执行到下一个 yield。

用通俗的话说:**生成器内部维护了一套可中断、可恢复的执行环境,而普通函数执行完即销毁。**

不要太迷信生成器:什么时候不适合

生成器并非万能药,有几种典型场景它无能为力:

1. 你需要随机访问(比如取第 N 条数据),生成器只能单向顺序遍历。
2. 你确实需要将所有数据收集起来做排序、分组——这必然要占用同样大的内存。
3. 你的源数据是一个已完整加载的数组,遍它生成器不减少初始数组本身占用的内存。

最后一类场景尤其常见:从数据库 `fetchAll()` 拿回全部数据后,再用生成器“包一层”。这不叫优化,这叫自欺欺人。正确的做法是让数据库游标配合 `yield`,从数据库层面流式取数。

总结一下

生成器省内存的底层逻辑可拆解为三点:

- 暂停机制:yield 冻结当前状态而非销毁,每次只保留一条数据生命周期;
- 读时零拷贝:结合 PHP 的写时复制,只读不写则不产生新内存分配;
- 小体积对象:生成器本身仅是一个类似协程的结构体,占用内存比保存全部数据的小几个数量级。

回归到工程视角,生成器最适合解决逐行处理大数据流的问题。理解了底层机制,你就不会再把它当黑魔法,而是能在需求面前清晰地判断:该用还是不该用。

全部回复 0

还没有回复,来抢沙发~