终端闲思录(2)- 终端与缓冲的关系

我们已经知道标准三剑客(标准输入、标准输出、标准错误)的本质是文件描述符,其连接的目的地可以是任意类型的文件,终端只是常用的目的地之一。那么,当目的地类型不同,IO 的行为是否也有所不同呢?那位架构师所说“控制台是同步的”是否过于危言耸听了呢?

让我们看回作为“Linux 一切皆文件”的通用 I/O 接口:

1
2
3
4
5
6
#include <unistd.h>
ssize_t read(int fd, void *buffer, size_t count);
Returns number of bytes read, 0 on EOF, or –1 on error

ssize_t write(int fd, void *buffer, size_t count);
Returns number of bytes written, or –1 on error

这是 glibc 对系统调用readwrite的封装,大部分应用的 IO 都是对这两个封装函数的调用,即便是不使用 C 库的语言,其标准库也提供对系统调用readwrite的封装,比如 Go 语言,其标准库底层直接对接的系统调用,与 glibc 处于同一层级。

从接口定义可知,读与写都需要传入一个存储读写内容的缓冲区指针buffer以及存储内容大小的count,显而易见的是:在所需传输内容大小一定的情况下,buffer越小,对readwrite的调用次数越多,反之则调用次数越少,我们来看一个不同 buffer 大小对传输时间影响的例子:

图 1-1 复制 100MB 大小的文件所需时间

读和写大体涉及三块时间:系统调用的时间内核与用户空间数据复制的时间内核和磁盘交互的时间。可见系统调用的成本还是比较可观的,当 BUF_SIZE 增长到 4096 的时候,总耗时趋于稳定,BUF_SIZE 的增加对性能的提升不再显著,这是因为与其它两个时间耗时相比,系统调用的时间成本已经微不足道了。

上面的例子混合了读与写,初次操作不可不免的需要从磁盘传输数据到页高速缓存中,让我们再看一个只有写的例子:

图 1-2 写一个 100MB 大小的文件所需的时间

在 Linux 中,写是异步的,内容会先进入页高速缓存,之后write系统调用返回,真正落盘的操作由内核异步完成,因此只要内存充足,write的性能总是可以得到保证的。

注:图中两例引用自Linux/UNIX系统编程手册,用于说明系统调用的昂贵,我偷懒没有自己写例子运行。

总之,如果有大量内容需要使用 I/O 接口传输,或者需要长时间不定时调用 I/O 接口,通过采用合理的大块空间来缓冲数据,以减少系统调用的次数,可以极大地提高 I/O 的性能,此 glibc 中 stdio 之所为也!

C 库(Linux 中为 glibc)将文件读写抽象成了一个名为FILE*的流(stream,标准三剑客会被抽象为 stdin、stdout、stderr),其中就包含我们上面提到的缓冲处理,这避免了程序员自行处理数据缓冲。C 库有三种缓冲类型:

  • _IOFBF(全缓冲)。单次读写数据的大小与缓冲区大小相同,指代磁盘文件的流默认采用此模式。
  • _IOLBF(行缓冲)。对于写入,在遇到换行符时才执行(除非缓冲区已填满);对于读取,每次读取一行数据。当连接终端时默认采取行缓冲。
  • _IONBF(无缓冲)。不对 I/O 进行缓冲,每个 stdio 库函数将立即调用readwrite,连接到终端的标准错误即是这种类型。

用一个简单的程序来测试终端对 C 库缓冲的影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <unistd.h>
void _isatty();

int main() {

for (;;) {
_isatty();
sleep(1);
}
return 0;
}

void _isatty(){
if (isatty(fileno(stdout))) {
printf("stdout is connected to a terminal (line buffered)\n");
fprintf(stderr,"an error painted to stderr\n");
} else {
printf("stdout is not connected to a terminal (fully buffered)\n");
fprintf(stderr,"an error painted to stderr, but redirected\n");
}
}

请分别在终端中直接运行(./isatty)和重定向到文件运行(./isatty > isatty.log 2>&1 &),观察终端和 isatty.log 文件的输出,会发现如下现象:

  1. 重定向(不再指向终端)会让标准输出变为全缓冲。
  2. 重定向对 stderr 无影响,默认情况下依然是无缓冲。

由此可知,当向标准输出写日志时,其缓冲行为与是否连接到终端有关,当作为后台服务运行时,即便将日志打到标准输出,写日志的行为也是有缓冲的。至于标准错误,如果对错误信息没有即时要求,也是可以调整其缓冲模式的,不必依赖默认设置。

当然,这是 C 库的做法,换一种不依赖 C 库的语言,行为可能就会不同,我们来看一下 Go 语言的表现(我总是举 Go 语言的例子,熟悉是一方面,更重要的是,Go 与 C 库脱离的很彻底):

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
package main

import (
"bufio"
"fmt"
"log/slog"
"os"
"time"

"golang.org/x/term"
)

func main() {
w := bufio.NewWriter(os.Stdout)
h := slog.NewJSONHandler(w, nil)

logger := slog.New(h)

// 检查标准输出是否连接到终端
a := func() {
if term.IsTerminal(int(os.Stdout.Fd())) {
fmt.Printf("标准输出连接到终端--from fmt\n")
fmt.Fprintf(os.Stderr, "标准错误连接到终端--from fmt\n")
slog.Info("标准输出连接到终端--from slog")
logger.Info("标准输出连接到终端--from slog json logger")

} else {
fmt.Printf("标准输出未连接到终端--from fmt\n")
fmt.Fprintf(os.Stderr, "标准错误未连接到终端--from fmt\n")
slog.Info("标准输出未连接到终端--from slog")
logger.Info("标准输出未连接到终端--from slog json logger")
}
}

for {
a()
time.Sleep(1 * time.Second)
}
}

请再次分别在终端中直接运行(./isatty)和重定向到文件运行(./isatty > isatty.log 2>&1 &),观察终端和 isatty.log 文件的输出,会发现如下现象:

  1. 无论是否连接到终端,对标准输出和标准错误的输出行为不会发生任何变化

  2. 想要异步写入,需要自行构建带缓冲的 logger,如此例中的:

    1
    2
    3
    4
    w := bufio.NewWriter(os.Stdout)
    h := slog.NewJSONHandler(w, nil)

    logger := slog.New(h)

    而 slog 默认的 logger 是写入 stderr 的,不信你可以运行./isatty > isatty.log &仅仅重定向标准输出试试。

产生这种现象的原因是 Go 标准库并没有像 C 库那样对标准库的 I/O 大包大揽,文件流*os.File是无缓冲的,Go 标准库函数的 I/O 基本都是基于接口,且提供有实现了缓冲 I/O 以及 I/O 接口的bufio包。

上面例子中的 json logger 就是使用bufio基于标准输出创建了一个带缓冲的writer,而 slog 包中创建 Handler 仅需传入实现writer接口的对象即可,因此我们得到一个带有缓冲的 logger。

图 1-3 I/O 缓冲

不论标准库使用如何方式提供缓冲,其目的始终是减少系统调用,图 1-3 以 C 库为例展示了这种 I/O 模型,使用标准库函数将日志写入标准输出,是可以设置合理的缓冲区的,并不存在“同步、性能低下”的担忧。因此,我们可以确定如下几点:

  1. 标准三剑客是文件描述符,任何基于文件的读写库函数都可以向标准输出和标准错误写入。
  2. 向标准输出和标准错误写入不影响日志框架的使用。
  3. 即便日志框架不提供缓冲区,也是可以提供一个实现了缓冲的 writer 以实现异步写入。

那么,是否就可以由此断定在容器中将日志写入标准输出与标准错误跟写入文件相比就没有差别了呢?当然不是的,这要取决于标准输出与标准错误出重定向之目的地的写入能力,我们不妨拿logback来做个测试,logback是 java 领域应用广泛的日志框架,其往标准输出写入和文件写入分别是由consolefile两个 appender 实现的,所以,我们首先在写入目的地的写入能力相同的情况下测试两个 appender 的能力是否有所差异,方法就是在console输出时重定向到文件,这样实际的 I/O 就都是普通文件 I/O 了。

写入条数 console file 写入大小
1000000 1729ms 1771ms 85m
5000000 7414ms 7708ms 480m
10000000 14545ms 15854ms 800m
20000000 32030ms 31754ms 1.7g
40000000 59479ms 59339ms 3.4g

简单粗暴地将类似的内容以不同的条数写入,观察总的执行时间。由上述表格可知,这两个appender的自身能力可以说基本相同。在此基础上可以放在 k8s 上运行了,我这里仅对40000000的情况做一下测试,注意不要和上面表格对比,上面是 nvme 硬盘的响应时间,接下来的测试是普通企业 sas 盘的响应时间:

写入条数 console file 写入大小
40000000 111778ms 82679ms 3.4g
40000000 100143ms 87610ms 3.4g
40000000 98589ms 91603ms 3.4g
40000000 97093ms 91193ms 3.4g
40000000 98536ms 86348ms 3.4g

仅就这单一的测试场景而论,使用console appender的性能会略逊一筹,大约有 10% 左右的性能损失,产生这种结果的原因是由容器中标准输出的目的地不同于文件造成的。

k8s 中的容器,除了使用exec附加到容器 namespace 启动的进程有控制终端之外,大部分以后台进程运行的程序是没有控制终端的,不妨进入容器的 namespace 看看服务进程的标准三剑客指向哪里:

1
2
3
4
5
6
7
8
9
10
11
/ # ps aux
PID USER TIME COMMAND
1 root 0:06 /usr/local/bin/isatty
7 root 0:00 sh
13 root 0:00 ps aux
/ # cd /proc/1/fd
/proc/1/fd # ls -l
total 0
lrwx------ 1 root root 64 Dec 12 02:25 0 -> /dev/null
l-wx------ 1 root root 64 Dec 12 02:25 1 -> pipe:[54791]
l-wx------ 1 root root 64 Dec 12 02:25 2 -> pipe:[54792]

1 号进程是我们的测试进程,可见其标准输入指向/dev/null,标准输出和标准错误分别指向不同的管道,不知你是否好奇这是如何做到的呢?容器的日志又是如何写到/var/log/containers中的呢?

参考文献

  1. UNIX环境高级编程
  2. Linux/UNIX系统编程手册