起因
这是偶然间看到的一篇文章,感觉收获颇丰,故转载。转载自芦雨强的网络日志
干货分割线
作为一个PHP开发者,我们经常需要关注内存管理。PHP引擎在我们运行脚本之后做了很好的清理工作,短周期执行的web服务器模型意味着即使是烂代码也不会长时间影响。
我们很少需要走出舒适的边界–比如我们尝试在一个小的VPS上为创建一个大项目运行Composer,或者当我们在小服务器上读取一个大文件。
这是后续将在本教程中呈现的问题。
教程代码可以在github找到
衡量成功
确定我们完善代码的唯一方式是把烂代码和修正过的代码进行比较。换句话说,我们不知道它是否是解决办法,除非我们知道它帮了多少。
有两个我们需要关心的指标。第一个是CPU的使用。我们想要过程快或者慢?第二个是内存的使用。脚本运行使用了多少内存?这些通常是成反比的-意味着我们可以在看CPU的使用时候,不看内存的使用,反之亦然。
在一个异步程序模型中(比如多进程或者多线程的PHP应用),CPU和内存使用都需要谨慎考虑的。在传统PHP架构中,当它们中的哪个达到服务器极限的时候通常就会有问题。
在PHP中测量CPU使用不切实际。如果你关注,可以考虑在Ubuntu或者MacOs中使用top命令。Windows可以考虑安装一个linux子系统,你就可以在Ubuntu上使用top。
这个教程的目的是测量内存使用。我们将看到「传统」脚本中内存的使用情况,之后将会优化并且测量,最后我希望你可以做一个学习后的选择。
1 | //php.net文档中格式化字节的方法 |
我们将会在脚本的最后使用这个函数,因此可以在第一时间看到哪个脚本使用了更多的内存。
选项
我们可以采取很多高效读取文件方法。但是有两种常用的场景。我们可以先读取后处理数据,然后输出处理后的数据或者执行其他操作。我们可能也想要转换一个数据流而不用获取数据。
对于第一种情况,我们读取一个文件,然后每一万行创建一个独立的队列进程。我们需要至少把一万行放到在内存中,然后把他们发送到队列管理器。
对于第二种情况,我们压缩一个特别大的API响应。我们不在乎它说什么,但我们需要确保它是以压缩形式备份的。
两种情况下,我们都需要读取大文件,只不过一个关注数据一个不关注。让我们探索这些选项吧。。。
一行一行读文件
有很多处理文件的函数。让我们使用一个简单明了的文件读取:
1 | // from memory.php |
1 | // from reading-files-line-by-line-1.php |
我们正在读取一个包含莎士比亚全集的文本文件。文本文件大约5.5MB,消耗了12.8MB的内存。现在,让我们使用生成器来读取每一行:
1 | // from reading-files-line-by-line-2.php |
这个文本文件同样大小,但是消耗了393KB的内存。这也说明不了什么,除非我们使用读取的数据做一些事。假设我们把文档以每两个空行分成小片段。就像:
1 | // from reading-files-line-by-line-3.php |
猜一下我们现在用了多少内存?尽管我们把文档分割成了1216个片段,我们却只用了458KB的内存,意外吗?鉴于生成器的性质,我们内存消耗最大的是需要在循环中存储最大文本块的内存。在这种情况下,最大的块是101,985个字符。
我已经写了使用生成器的性能提升和Nikita Popov的生成器库,所以你想要了解更多就去看吧。
生成器也有其他用法,但对读取大文件有很明显的性能提升。如果我们需要去处理数据,生成器也是最好的方式。
文件间的管道输送
在某些情况下,我们不需要处理数据,而是把一个文件的数据传递到另一个文件。这通常被叫做管道输送(大概因为我们只看到了两头,没看到管道内。。。当然它不是透明的)。我们可以通过使用流方法获取它们。写了个从一个文件传递到另一个的脚本,方便我们可以测量内存使用:
1 | // from piping-files-1.php |
不出意外地,这个脚本使用比文件的拷贝更多的内存。这是因为它不得不读取、把文本内容放到内存中,然后写入到一个新文件。对于小文件还好。但是当我们处理一个大文件,就不妙了。。。
让我们使用流的方式从一个文件传递到另一个(或者叫管道输送)
1 | // from piping-files-2.php |
这段代码很奇怪。我们打开两个文件的句柄,第一个使用读模式,第二使用写模式。然后我们从第一个复制到第二个。然后关闭两个文件的句柄。是不是惊喜到你了,内存只使用了393KB。
这看起来是不是很熟悉。不就是我们使用生成器的代码一行一行读取然后存储吗?这是因为第二个变量使用fgets指定每行读取多少字节(默认-1或者直到一个新行)
stream_copy_to_stream的第三个参数是完全相同的参数(具有完全相同的默认值)。stream_copy_to_stream正在读取一个流,一次一行,并将其写入另一个流。 它跳过了生成器产生值的部分,因为我们不需要使用该值。
管道输送这些文本对我们来说没用,所以让我们仔细思考一下其他可能的例子。假设我们想要从CDN输出一个图像,重定向应用的路由。代码如下:
1 | // from piping-files-3.php |
我们可以使用以上代码解决一个应用的路由问题。但我们想从CDN获取而不是把文件存储在本地文件系统中。我们可能使用更优雅的(像Guzzle)替代file_get_contents,但是效果一样。
图片的内存使用大约581KB。现在,我们试着使用流替代?
1 | // from piping-files-4.php |
内存使用会略少(400KB),但是结果却一样。如果我们需要更多的内存信息,我们可以打印到standard output。事实上,PHP为实现这个提供了简单的方法。
1 | $handle1 = fopen( |
其他流
有一些其他流我们可以管道传递、读、或者写:
- php://stdin (只读)
- php://stderr (只写, 像 php://stdout)
- php://input (只读) 获取原请求体
- php://output (只写) 可以写到缓冲区
- php://memory 和 php://temp (读写)存储临时数据的地方。php://temp不同的是以文件存储,php://memory存储在内存
过滤器
还有一个使用流的技巧叫过滤器。它们是中间步骤,提供管理流而不暴露给我们的功能。设想一下我们想要压缩莎士比亚.txt。可能会使用Zip扩展:
1 | // from filters-1.php |
整洁的代码,但是却消耗了10.75MB。我们使用过滤器改进:
1 | // from filters-2.php |
可以看到使用php://filter/zlib.defalte的过滤器来压缩资源。我们可以把一个压缩后的数据管道传递到另一个文件。内存消耗896KB。
我知道这不是同一个格式,或者使用zip压缩更好。但是你不得不怀疑:如果你选择不同的格式可以节省掉12倍的内存,何乐而不为呢?
可以通过另一个zlib的解压缩过滤器解压文件:
1 | // from filters-2.php |
流已经在理解PHP中的流 和 PHP流与效率中大量提及。如果你想要了解更多,点开看看。
自定义流
fopen和file_get_contents有他们自己的默认设置,但是可以完全的自定义。为了方便理解,自己创建一个新的流:
1 | // from creating-contexts-1.php |
在这个例子中,我们尝试向API发出POST请求。API端是安全的,但是仍需要使用http上下文属性(用于http和http)。我们设置一些头并且打开API文件句柄。考虑到安全,我们以只读方式打开。
可以自定义很多东西,所以如果你想了解更多,最好查看文档。
自定义协议的过滤器
在本文结束之前,来谈谈自定义协议。 如果你看文档,你可以找到一个示例类来实现:
1 | Protocol { |
我们不打算实现在教程中,因为我认为这是值得的自己完成过程。需要做很多工作,但是一旦这个工作完成,可以很容易地注册的流包装:
1 | if (in_array('highlight-names', stream_get_wrappers())) { |
类似地,可以自己创建一个自定义流过滤器。文档有一个过滤器类的例子:
1 | Filter { |
很容易注册:
1 | $handle = fopen('story.txt', 'w+'); |
高亮名字过滤器需要去匹配新的过滤器类的过滤器名属性。也可以在php://filter/highligh-names/resource=story.txt字符串中使用自定义过滤器。定义过滤器比定义协议要容易得多。 其中一个原因是协议需要处理目录操作,而过滤器只需处理每个数据块。
如果你有强烈的进取心,鼓励你编写协议的过滤器。如果你可以将过滤器应用于stream_copy_to_stream操作,那么即使处理大容量的大文件,你的应用程序内存也不会超阈值。 试着编写一个调整图像大小的过滤器或加密应用程序的过滤器。
总结
尽管这不是我们经常处理的问题,在读取大文件时也很容易陷入困境。在异步应用中,当我们不注意内存使用时,很容易就把整个服务搞挂。
这个教程希望给你讲解一些新想法(或者唤醒你的记忆),以便你能在读、写大文件时想得更多。当开始熟练掌握流和生成器后,停止使用像file_get_contents函数:一些莫名其妙问题就在程序中消失了。这就是意义所在!
声明
本文转载自芦雨强的网络日志