目录

PHP 中的输出缓冲区

以前总以为 echoprint 之类的输出工具背后没什么了不起的逻辑,不就是给个字符串让终端显示一下吗?其实不然,每一个精心设计的语言特性,函数,方法…背后都可谓独具匠心,你看,PHP 语言从来都不会设计一个一无是处的工具出来,那些雕琢出来的特性都是那么的恰到好处。

缓冲区的一些说明

  • 正常情况下,任何会输出内容的函数都会用到输出缓冲区
  • 输出缓冲区不是唯一用于缓冲输出的层,它实际上是很多层中的一个
  • SAPI 中的输出缓冲区

数据写入顺序:echo/print => PHP 输出缓冲区 => SAPI 缓冲区 => TCP 缓冲区 => 浏览器

https://inotes.oss-cn-beijing.aliyuncs.com/php/201812/php-ob-main.png

默认的输出缓冲区

PHP-FPM 中,与缓冲区相关的配置。

这些值通过 ini_set() 设置后也不起作用,换句话说就是设置的太迟了,因为输出缓冲区层在 PHP 程序启动时,还没有运行任何脚本解析之前就已经启动了。这些值需要在 php.ini 或者在执行程序时使用 -d 选项才有效

output_buffering

默认值为 4096,设置为 Off 或者 0,表示禁用输出缓冲区;设置为 On,表示输出缓冲区不受限制,慎用;

implicit_flush

默认值为 Off,设置为 On,表示一旦有任何输出写到 SAPI 缓冲区层,它都会立即刷新,也就是把数据写到更低层,并且缓冲区会被清空

output_handler

回调函数,它可以在缓冲区刷新之前修改缓冲中的内容。PHP 的扩展提供了很多回调函数:

  • ob_gzhandler:使用 ext/zlib 压缩输出
  • mb_output_handler:使用 ext/mbstring 转换字符编码
  • ob_iconv_handler:使用 ext/iconv 转换字符编码
  • ob_tidyhandler:使用 ext/tidy 整理输出的 HTML 文本
  • ob_[inflate/deflate]_handler:使用 ext/http 压缩输出
  • ob_etaghandler:使用 ext/http 自动生成 HTTP 的 Etag

实例

设置缓冲区大小为 16 字节,使用 PHP 内置的 Web 服务器 SAPI。

1
$ php -d output_buffering=16 -d implicit_flush=1 -S 127.0.0.1:8080 -t ~/Downloads/test

浏览器访问脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php

// 程序脚本运行的后向缓冲区写入 15 个字节
echo str_repeat('Hello', 3);

// 进入 2 秒休眠状态
sleep(2);

// 此时这个字节填满了缓冲区,缓冲区会立即刷新自身,把数据传递给 SAPI 层的缓冲区
// 由于 implicit_flush=1,SAPI 层的缓冲区也会立即刷新到下一层
// 浏览器输出 HelloHelloHelloW
echo 'W';

// 进入 2 秒休眠状态
sleep(2);

// 此时将这 2 个字节写入到缓冲区,由于还不够填满缓冲区,这时还不会输出到浏览器
echo 'or';

// 进入 2 秒休眠状态
sleep(2);

// 此时脚本执行完毕,在执行完毕之前,将这 2 个字节写入到缓冲区,还是不够填满缓冲区
// 但这时脚本已经执行完毕,缓冲区将已有的数据全部输出浏览器
echo 'ld';

消息头和消息体

如果使用了输出缓冲区层,那么 PHP 会接管这些消息头和消息体的发送。

PHP 中有关与消息头的函数都使用了内部的 sapi_header_op() 函数,这个函数负责把内容写入到消息头缓冲区中,所以我们才能优雅的使用 header()setcookie() 诸如此类的方法。

在输出内容时,内容会先被写入到输出缓冲区(可能是多个),当缓冲区中的内容需要被发送时,PHP 会先发送消息头,再发送消息体,你看,所有的这些都不费吹灰之力,PHP 为我们搞定了所有的事情。

用户的输出缓冲区

多个缓冲区会组成一个堆栈结构,每个新建的缓冲区都会堆叠到之前的缓冲区上,每当它被填满或者溢出,都会执行刷新操作,然后把其中的数据传递给下一个缓冲区。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php

ob_start(function ($parameter) {
    static $index = 0;

    return $index++ . ': ' . $parameter . "\n";
}, 6);

ob_start('handle', 3);

function handle($parameter)
{
    return ucfirst($parameter);
}

// 此时字符串被写入到第二个缓冲区,缓冲区的长度不够设定值,不会刷新这个缓冲区
echo 'fo';

// 休眠 2 秒
sleep(2);

// 1. 此时字符串写入到第二个缓冲区后,由于 chunk_size 为 3,所以第二个缓冲区会刷新
// 2. 并且将返回的字符串 Foo 写入到第一个缓冲区
// 3. 此刻第一个缓冲区未被填满
echo 'o';

// 休眠 2 秒
sleep(2);

// 1. 此时字符串写入到第二个缓冲区后,立即刷新缓冲区
// 2. 并且将返回的字符串 HelloWorld 写入到第一个缓冲区
// 3. 上一次缓冲区的 Foo 与 本次的 HelloWorld 合并
// 4. 由于第一个缓冲区 chunk_size 为 10,这时缓冲已经被填满,所以第一个缓冲区会刷新输出到浏览器
echo 'helloWorld';

// 休眠 2 秒
sleep(2);

// 1. 此时字符串写入到第二个缓冲区后,立即刷新缓冲区
// 2. 并且将返回的字符串 Enough 写入到第一个缓冲区
// 3. 由于上一次的缓冲被填满刷新输出了,正好本次的缓冲区也刚好被填满,所有第一个缓冲区会刷新输出到浏览器
echo 'enough';

sleep(2);

// 重复此前的步骤,由于脚本已经执行结果,被迫刷新输出到浏览器
echo 'exit';

// 结果
0: FooHelloWorld 1: Enough 2: Exit

输出缓冲区实例

默认的缓冲区输出

在脚本处理结束之前,浏览器端不会输出,由于数据量太小,输出缓冲区没有写满。写入数据的顺序:PHP 缓冲区、TCP 缓冲区、浏览器

1
2
3
4
5
6
7
8
<?php

$i = 0;
while ($i < 10) {
    echo $i . '<br>';
    $i++;
    sleep($i);
}

关闭后的缓冲区输出

output_buffering 的值改为 0,执行脚本后,因应缓冲区的容量设置为 0,即禁用了 PHP 缓冲区机制。这时浏览器端会按程序定义的时间间隔不断输出,不会出现浏览器界面空白等待的情况。写入数据的顺序:TCP 缓冲区、浏览器

1
2
3
4
5
6
7
8
9
<?php

$i = 0;
while ($i < 10) {
    echo $i . '<br>';
    flush();
    sleep(1);
    $i++;
}

自定义的缓冲区输出

output_buffing 值改为默认的 4096

使用 dd 命令准备一个大小为 4096 文件

1
2
3
$ dd if=/dev/zero of=file4096 bs=4096 count=1
$ ll file4096
-rw-r--r--  1 majinyun  staff   4.0K Mar 16 11:16 file4096

脚本在每次执行时,由于读取的文件内容大小正好等于缓冲区的大小,所以会立即刷新缓冲区,输出到客户端浏览器,在此过程中浏览器不会出现空白等待期,而会持续输出程序中指定的内容

1
2
3
4
5
6
7
<?php

$filename = dirname(dirname(__DIR__)) . '/../Downloads/test/file4096';
for ($i = 0; $i < 10; $i++) {
    echo file_get_contents($filename) . $i . '<br>';
    sleep(1);
}

使用 ob_start 后的缓冲区输出

使用 ob_start() 后,PHP 的缓冲区会被扩展到足够大,直到 ob_end_flush() 函数调用或者脚本运行结束后,才发送缓冲区中的数据到客户端浏览器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php

ob_start();

$filename = dirname(__DIR__, 3) . '/Downloads/test/f4096';
for ($i = 0; $i < 10; $i++) {
    echo file_get_contents($filename) . $i . '<br>';
    sleep(1);
}
ob_end_flush();

执行脚本后,ob_get_contents() 会获取一分缓冲区中的副本,结果中有两次输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php

ob_start();

echo 'hello world' . '<br>';
$contents = ob_get_contents();

ob_end_flush();

echo $contents;

// 结果
hello world
hello world

执行脚本结束后,由于使用了 ob_end_clean() 函数,将 PHP 缓冲区中的内容给删除了,所以结果中仅有一次输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<?php

ob_start();

echo 'hello world' . '<br>';
$contents = ob_get_contents();

ob_end_clean();

echo $contents;

// 结果
hello world