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

玄墨染
玄墨染 正式会员正式会员认证极客认证极客
发布于 2026-08-02 20:43 ·100 浏览 ·0 回复
内容摘要

PHP生成器省内存的底层原因在于yield并非返回值而是暂停当前函数,冻结并保存局部变量、循环位置等执行上下文,每次只保留当前迭代所需状态;同时PHP的引用计数与写时复制使yield出的只读值通常不触发额外复制,若外部主动累积全部数据,则任何方式都会占用同等内存,问题不在生成器本身。

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

在 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 的写时复制,只读不写则不产生新内存分配;
  • 小体积对象:生成器本身仅是一个类似协程的结构体,占用内存比保存全部数据的小几个数量级。

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

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-58.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~