PHP8.5怎么实现生成器节省内存占用
前言先纠正一个版本事实生成器Generator不是 PHP 8.5 的特性。它是PHP 5.5引入的yield from委托语法是PHP 7.0加的此后语义基本稳定。标题里的PHP8.5有误——当然你用 PHP 8.5 跑生成器完全没问题本文的示例就按 PHP 8.x 写并且只用到 8.0 及以后确定存在的语法联合类型、构造函数属性提升等。所以准确的说法是在 PHP 8.5 上怎么用生成器省内存而不是PHP 8.5 引入了生成器。第二个要澄清的误解是生成器省的不只是内存更是峰值内存。file()一次性把整个文件读进数组内存占用与文件大小成正比生成器每轮只持有一行的状态进程的峰值内存与文件大小无关。这两者在平均内存上也许差得不多但峰值内存才决定你的进程会不会被 OOM Killer内存不足终止器干掉。本文讲清楚生成器为什么省内存原理层面而不是据说很快、怎么写才真的省、以及那些你以为省了其实没省的写法。文中所有测量代码都可以直接跑具体数字请在自己的机器上实测。一、生成器为什么省内存它把数据集换成了状态普通函数返回的是一个已完成的值要么是标量要么是一个装满了所有元素的数组。数组必须在函数返回之前就全部构造好所以它必然占用 O(元素个数) 的内存。生成器函数内部含yield的函数被调用时不执行函数体而是立刻返回一个Generator对象。这个对象只保存三样东西函数当前执行到的位置、局部变量的当前值、以及调用方的上下文。每次yield把值交给调用方后函数栈帧被挂起而不是销毁下一次需要值时再从挂起处恢复。所以内存模型从O(n) 的数据变成了O(1) 的状态 每次一个元素。这就是全部的秘密。对比项返回数组的函数生成器函数返回值类型array或声明类型Generator对象yield决定无法声明为array求值时机调用时立即全部求值eager首次取值时才开始lazy内存量级O(n)n 是元素个数O(1)与元素个数无关峰值出现时刻函数返回前迭代过程中的任意时刻且恒定能否遍历两次可以不可以取完即失效count()直接可用不可用会报TypeError一个关键推论如果调用方最终还是把生成器转成了数组省内存的努力就全废了。最常见的翻车写法就是iterator_to_array($gen)。二、从读一个大文件开始假设要处理一个几百 MB 的 CSV 日志。反面教材?php // ❌ 一次性读入内存与文件大小成正比 function readAllLines(string $path): array { return file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); } foreach (readAllLines(/var/log/big.csv) as $line) { // 处理 $line }file()会把整个文件读进内存里的一个数组数组每个元素还各有一个字符串 zvalPHP 内部变量的数据结构的开销。文件多大内存就吃多大。生成器版本?php // ✅ 逐行读取任意时刻内存里只有一行 function readLines(string $path): Generator { $handle fopen($path, rb); if ($handle false) { throw new RuntimeException(无法打开文件: {$path}); } try { while (($line fgets($handle)) ! false) { yield rtrim($line, \r\n); } } finally { // 无论正常结束、break 提前退出还是抛异常都会走到这里 fclose($handle); } } foreach (readLines(/var/log/big.csv) as $line) { // 处理 $line }这里finally里的fclose()很关键。生成器被break打断时并不会自动清理资源finally保证了句柄一定被释放——这也是生成器相对手写迭代器的一个便利之处可以直接用try/finally表达清理逻辑。三、真正的杀手场景从数据库流式取数读文件还不是最痛的地方。最痛的是导出百万行数据——fetchAll()把整张表塞进 PHP 数组然后json_encode()又要复制一份内存直接翻倍。正确做法是让 PDO 使用非缓冲查询unbuffered query配合生成器一条一条地取?php // stream-export.php —— 需要 PHP 8.0需要 pdo_mysql 扩展 // 用法php stream-export.php /** * 流式产出查询结果内存与结果集行数无关。 * * 注意非缓冲查询期间同一个连接上不能执行其它查询。 */ function streamRows(PDO $pdo, string $sql): Generator { // 关键关闭客户端缓冲让 MySQL 逐条下发结果 $pdo-setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false); $stmt $pdo-query($sql); try { while (($row $stmt-fetch(PDO::FETCH_ASSOC)) ! false) { yield $row; } } finally { $stmt-closeCursor(); // 必须关闭游标否则连接会被占用 $pdo-setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true); } } $pdo new PDO( mysql:host127.0.0.1;dbnameshop;charsetutf8mb4, root, , [PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION] ); // 一边查一边写 CSV全程不构造大数组 $out fopen(php://stdout, wb); if ($out false) { exit(无法打开输出流\n); } fputcsv($out, [订单号, 金额, 下单时间]); $count 0; foreach (streamRows($pdo, SELECT order_no, amount, created_at FROM orders ORDER BY id) as $row) { fputcsv($out, [$row[order_no], $row[amount], $row[created_at]]); $count; } fclose($out); fprintf(STDERR, 共导出 %d 行峰值内存 %d 字节\n, $count, memory_get_peak_usage(true));这段代码里有三个容易漏掉的点PDO::MYSQL_ATTR_USE_BUFFERED_QUERY false是必须的。默认是缓冲模式意味着query()执行完整个结果集已经在 PHP 的客户端缓冲里了——生成器这时候已经晚了内存早就吃满了。非缓冲查询期间不能在同一条连接上发别的 SQL。常见错误是在循环体里用同一个$pdo去写日志表会报Cannot execute queries while other unbuffered queries are active。要么另开一条连接要么先把数据攒够一批再写。closeCursor()必须调用。生成器被提前break时游标不会自己关闭连接会一直挂在那里。顺手测一下峰值内存想知道效果用memory_get_peak_usage()自己测。注意区分两个参数memory_get_usage()是当前用量memory_get_peak_usage(true)是 PHP 从系统申请到的峰值内存以 2MB 为粒度向上取整后者更能反映对进程的实际压力。?php // memory-probe.php —— 需要 PHP 8.0自造一个 20 万行的文件来对比 $path sys_get_temp_dir() . /gen-probe.csv; $fp fopen($path, wb); for ($i 0; $i 200000; $i) { fwrite($fp, row-{$i}, . str_repeat(x, 50) . \n); } fclose($fp); function report(string $label, string $path, callable $runner): void { $before memory_get_usage(); $peakBefore memory_get_peak_usage(true); $runner($path); printf( %-16s 当前增量%8d B 峰值%8d B\n, $label, memory_get_usage() - $before, memory_get_peak_usage(true) - $peakBefore ); } report(file() 全量读入, $path, function (string $p): void { $rows file($p, FILE_IGNORE_NEW_LINES); $n count($rows); }); report(生成器逐行, $path, function (string $p): void { $h fopen($p, rb); $n 0; while (fgets($h) ! false) { $n; } fclose($h); }); unlink($path);跑完后你会看到两种截然不同的增长形态file()那一行的当前增量会随文件行数线性上涨生成器那一行的增量基本是个常量。具体数值取决于机器与文件内容请以自己跑出来的结果为准本文不替你的机器下结论。真正有意义的是趋势对比。四、生成器的进阶用法yield from做委托。PHP 7.0 起可以用yield from把一个可迭代结构摊平进当前生成器不需要手写循环?php function inner(): Generator { yield 1; yield 2; } function outer(): Generator { yield 0; yield from inner(); // 委托等价于 foreach (inner() as $v) { yield $v; } yield from [3, 4]; // 数组也能委托 yield 5; } var_dump(iterator_to_array(outer())); // [0, 1, 2, 3, 4, 5]yield from还有一个细节上的好处它会把send()的值和异常透传给内层生成器而手写foreach做不到这一点。双向通信send()。生成器不只是生产值还能接收值?php function accumulator(): Generator { $sum 0; while (true) { // yield 表达式的值就是调用方 send() 进来的值 $value yield $sum; if ($value null) { return $sum; // 通过 getReturn() 取回 } $sum $value; } } $gen accumulator(); $gen-current(); // 启动跑到第一个 yield $gen-send(10); // 加 10 $gen-send(5); // 加 5 $gen-send(null); // 结束 echo $gen-getReturn(), PHP_EOL; // 15注意getReturn()的时机只有生成器完全迭代结束之后才能取到返回值中途调用会抛Exception。惰性求值带来的副作用时机变化。生成器函数体直到第一次取值才执行?php function lazy(): Generator { echo 函数体开始执行\n; yield 1; } $gen lazy(); // 什么都不会输出 echo 赋值完成\n; $gen-current(); // 这时才输出 函数体开始执行这个特性会让函数调用时就该发生的副作用打日志、校验参数、抛异常推迟到迭代时是排查时很容易看错方向的一类 bug。常见坑点1. 用iterator_to_array()把生成器转成数组// ❌ 一行代码把省下来的内存全花回去还多了一次全量复制 $rows iterator_to_array(streamRows($pdo, $sql)); foreach ($rows as $row) { /* ... */ }// ✅ 让生成器从头到尾保持流的形态 foreach (streamRows($pdo, $sql) as $row) { /* ... */ }2. 试图对生成器count()或$gen[0]// ❌ Generator 不是数组count() 直接 TypeError $n count(readLines($path)); $first readLines($path)[0];// ✅ 遍历中自己计数取首个元素用 current() 或先 foreach 一次 $n 0; foreach (readLines($path) as $_) { $n; }3. 想遍历两次同一个生成器// ❌ 第二次 foreach 什么都不输出生成器已经耗尽且无法 rewind $gen readLines($path); foreach ($gen as $line) { /* 第一遍 */ } foreach ($gen as $line) { /* 空的 */ }// ✅ 生成器函数是每次调用产生一个新的可迭代对象重复调用即可 foreach (readLines($path) as $line) { /* 第一遍 */ } foreach (readLines($path) as $line) { /* 第二遍拿到全新生成器 */ }对已经跑过一段的生成器调用rewind()会抛Exception: Cannot rewind a generator that was already run这个报错信息本身就说明问题。4. 声明了: array返回类型却写了yield// ❌ 致命错误含 yield 的函数返回类型只能是 Generator/Iterator/Traversable 等 function rows(): array { yield 1; }// ✅ 返回类型老老实实写 Generator function rows(): Generator { yield 1; }5. 在生成器里抛异常却在外面用try包错了位置// ❌ 异常发生在迭代那一刻包住创建语句是没用的 try { $gen readLines(/不存在的文件); } catch (RuntimeException $e) { // 永远不会进来 }// ✅ try 要包住迭代过程 try { foreach (readLines(/不存在的文件) as $line) { /* ... */ } } catch (RuntimeException $e) { error_log($e-getMessage()); }6. 生成器里的return被当成返回给调用方// ❌ 以为 $result 拿到了数组 function gen(): Generator { yield 1; return [1, 2]; } $result gen(); // 拿到的是 Generator 对象// ✅ 返回值只能在迭代结束后用 getReturn() 取 $g gen(); foreach ($g as $v) {} $result $g-getReturn(); // [1, 2]7. 用yield $k $v生成重复的键转数组时被悄悄覆盖// ❌ 两行 id 都是 1iterator_to_array 默认保留键只留下最后一个 function dup(): Generator { yield 1 a; yield 1 b; } var_dump(iterator_to_array(dup())); // [1 b]// ✅ 明确传 false让它按顺序追加不拿键做索引 var_dump(iterator_to_array(dup(), false)); // [a, b]8. 生成器被break打断后资源没释放// ❌ 循环只取前 10 行就退出文件句柄一直挂着直到脚本结束 foreach (readLines($hugeFile) as $line) { if ($i 10) { break; } }// ✅ 在生成器内部用 try/finally 保证 fclose/closeCursor 一定执行 // 见本文第二节 readLines() 的写法总结关注点说明真实版本生成器是PHP 5.5引入yield from是PHP 7.0标题里写 8.5 不准确省内存的原理把 O(n) 的数据换成 O(1) 的挂起状态逐次求值关键 APIyield、yield from、send()、getReturn()、current()必须配合的条件调用方不能转数组数据库查询要关掉客户端缓冲单向性生成器只能用一次不能rewind()不能count()清理逻辑用try/finally写在生成器内部break也能触发结论生成器节省峰值内存靠的不是魔法而是把一次性构造的数据集变成了逐个产生的流。真正要盯住的是两个地方——上游数据库/文件句柄是否真的流式MYSQL_ATTR_USE_BUFFERED_QUERY有没有关、下游调用方有没有用iterator_to_array()把它变回数组。这两处任何一处没做对生成器就只剩语法糖的价值内存一点都省不下来。建议在导出、批量处理、长列表遍历这三类场景里优先引入生成器并用memory_get_peak_usage(true)自己做前后对比来验证效果。