PHP8.5怎么实现生成器节省内存占用

发布时间:2026/10/3 3:15:04

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)自己做前后对比来验证效果。
延伸阅读

更多相关文章

2026/10/3 3:15:04

PHP8.5怎么实现策略模式切换不同算法

前言需要先纠正一个概念:策略模式不是 PHP 8.5 的特性。它是《设计模式》里提出的对象行为型模式,与语言版本无关,用 PHP 5 也能写,用 PHP 8.5 也能写。之所以标题里带着"PHP8.5",更合理的理解是"在 PH…

2026/10/3 3:15:04

fcitx5皮肤定制全攻略:主题配置、Rime联动与避坑指南

如果你和我一样,日常在Linux桌面上靠fcitx5输入法过日子,那大概率早晚会走到“看默认皮肤不顺眼”这一步。默认的白底黑字候选框说不上哪里不好,但就是和精心调过的桌面壁纸、GTK主题放在一起时,怎么看怎么扎眼。今天这篇就专门聊…

2026/10/3 3:15:04

fcitx5皮肤定制全攻略:从原理到Rime搭配实战

折腾Linux桌面这些年,输入法换来换去,最后在fcitx5上彻底安家。倒不是因为它打字多快,而是这套框架的皮肤系统实在太好用了:候选框、状态条、托盘图标全都能按自己喜好重画,装完一套顺手主题,整个桌面的完成…

2026/10/3 4:25:08

深圳2020 POI数据清洗与坐标统一实战指南

简介:本资源为深圳市2020年高实用性GIS地理信息数据集,面向城市规划、交通管理、商业选址及地理信息科研领域的初/中级用户,解决多源POI与地形基础数据缺失、格式不统一、空间分析门槛高等实际问题。压缩包共137个文件,54.04MB&am…

2026/10/3 4:25:07

3DGS异常显示祛除实战:Mask2Former与CUDA环境下的浮尘幽灵高斯清理

1. 3DGS异常显示问题的背景与祛除思路1.1 为什么3DGS场景里会冒出“幽灵”和“浮尘”做3D Gaussian Splatting重建的朋友,大概率都遇到过这样的画面:明明只拍了一栋建筑或者一个物体,训练完之后场景里却飘着一团团半透明的“雾”,…

2026/10/3 4:25:07

Air780EP模组AT+MQTT接入OneNET平台实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/3 4:20:07

基于PubMed与智能体的综述生成:从100篇文献到万字初稿

1. 先说痛点:100篇文献到底有多难"啃"完1.1 写综述最耗时的不是写作,是文献处理做科研的人应该都有这种体会:真正动手写综述之前的文献筛选阶段,才是最折磨人的。我见过太多人刚下载完100篇PDF,兴冲冲打开En…

2026/10/2 8:16:46

东莞市品牌网站建设报价常见报错与解决

东莞品牌网站建设报价单背后:一份保姆级建站教程避坑实录 网站做好了没人访问,这大概是很多老板最头疼的事。花了大几万做的品牌站,上线后流量惨淡,比路边摊还冷清。别急着骂外包公司,很多“东莞品牌网站建设报价”里藏着不少猫腻,比如用模板站冒充定制…

2026/10/2 18:20:53

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解 【免费下载链接】spirula-studio Cross-vendor 3D Gaussian Splatting trainer - video to splat to mesh, Vulkan or CUDA. 项目地址: https://gitcode.com/GitHub_Trending/sp/spirula-studio Sp…

2026/10/1 10:48:55

SEO怎么推广速查手册新手避坑实战指南

SEO怎么推广速查手册新手避坑实战指南 模板网站太丑不够用?别急着加滤镜,那是治标不治本。很多老板盯着后台流量掉得眼红,却还在纠结首页Banner的圆角是不是3像素。这就像穿着西装去挖土,姿势不对,努力白费。我整理这份 速查手册…

2026/10/3 0:04:31

国内大学生必备的AI写作辅助软件是哪款?

国内高校学生在论文写作过程中,越来越依赖AI辅助工具提升效率,主流方案以本土化全流程工具为核心,结合通用大模型与专业插件,覆盖选题构思、框架搭建、初稿撰写、查重降重、格式调整等关键环节,本文将深入解析当前主流…

2026/10/3 0:04:31

Codex接入Jev模型完整指南:配置方法、本地部署与踩坑排查

最近不少人在讨论 Codex 搭配 Jev 这套玩法,我一开始没太当回事,直到自己把 Jev 接进 Codex跑了几轮编码任务之后,才明白那些说“直接起飞”的人是怎么想的。Codex 作为工具本身已经够能打了,但模型固定、上下文策略固定&#xff…

2026/10/3 0:04:31

GitHub 热门: NVIDIA/Model-Optimizer

👋 Hi,我擅长 AI 大模型应用落地、意识解码与 AI 开发工具链 。 💡 创业路上,用技术换时间,一起把 AI 变成生产力 🚀 >GitHub 热门: NVIDIA/Model-Optimizer 凌晨两点,你刚把跑通了的 Qwen3.…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑