1. 标准I/O库的缓冲机制
Linux上C标准I/O库封装了底层的write()
、read()
等系统调用,以减少系统调用次数。
比如现在需要打印10000次”hello world”,如果直接用系统调用
1 | for (int i = 0; i < 10000; i++) |
那么系统调用write()
会执行10000次。
如果用C标准库的printf()
或fputs()
之类,比如
1 | for (int i = 0; i < 10000; i++) |
在我的系统(Ubuntu 16.04)上write()
只会执行108次,查看write()
调用次数可以借助strace
工具,比如我是用
1 | strace ./a.out 2>err |
查看的,如果想实际看看缓冲区大小,可以直接打开err文件,可以看到下列输出
1 | write(1, "hello worldhello worldhello worl"..., 1024) = 1024 |
每次执行C标准库的打印函数时,并未立刻调用write()
将字符串打印到屏幕上(严谨点说,write()
也只不过是将内容递交给内核缓冲区),而是等到填满缓冲区后再调用write()
。
通过这样的缓冲机制,可以减少系统调用的次数,普通函数调用不涉及到用户态和内核态的切换,因此开销远低于系统调用。
缓冲区大小没有严格定义,即使在同一系统上,打印到不同文件中的缓冲区大小也不一样。比如将输出重定向到普通文件
1 | strace ./a.out >out 2>err |
查看err文件可以看到缓冲区大小变成了4096。
再就是最后一次write()
,可以发现即使未等到填满缓冲区,仍然打印出来了,原因是程序正常终止(调用exit()
)时会关闭所有文件流,从而导致缓冲区被刷新,效果等价于调用fflush()
。
比如在每次fputs()
之后加上fflush(stdout);
,可以发现write()
调用执行了10000次。
或者在for循环之后,加上write(STDOUT_FILEFNO, "exit(0)", 7)
,可以发现err文件中最后两次write()
为
1 | write(1, "exit(0)", 7) = 7 |
上述缓冲方式为全缓冲,即等缓冲区填满才调用底层I/O函数。而常见的标准输入流和标准输出流都是采用行缓冲,即遇到换行符才调用底层I/O函数。
比如调用fgets()
函数时,等键盘敲入回车键时函数才会返回,如果是直接用read()
系统调用,则是等待固定字符数量被键盘敲入才返回。
又比如调用printf("hello\n");
时,由于末尾有换行符,因此会调用write()
将其打印到屏幕上而不是等输出缓冲区填满。
另外,标准错误流是全缓冲的。
2. 使用自定义缓冲区
正是因为标准库没有严格定义缓冲区的设计,因此才催生了用户自定义缓冲区的需求。
通过man setbuf
可以看到手册对下列函数的说明
1 | void setbuf(FILE *stream, char *buf); |
其中setbuffer()
和setlinebuf()
需要定义_BSD_SOURCE
宏。
stream
: 缓冲区对应的文件流buf
: 自定义缓冲区,若为NULL
则使用系统自带缓冲区;mode
: 缓冲方式,下列三个宏之一
mode宏 | 意义 |
---|---|
_IONBF |
无缓冲,此时buf和size失去了意义 |
_IOLBF |
行缓冲 |
_IOFBF |
全缓冲 |
size
: 缓冲区大小至少有size字节
本质上前3个库函数都是调用setvbuf()
函数,对应关系如下
原始调用 | 等价调用 |
---|---|
setbuf(stream, buf) |
setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ) |
setbuffer(stream, buf, size) |
setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size) |
setlinebuf(stream) |
setvbuf(stream, NULL, _IOLBF, 0) |
可见setbuf
和setbuffer
是用的自定义缓冲区,区别只是前者使用标准库的宏BUFSIZ
作为缓冲区大小,后者使用size
参数。setlinebuf
则仍然使用标准库的缓冲区,只不过缓冲机制改成行缓冲。
3. 自定义缓冲区的陷阱
一般情况下没必要自定义缓冲区,除非能证明在当前场景下你的自定义缓冲区有性能优势,以及这个性能提升能解决系统的效率瓶颈。毕竟自定义缓冲区会遇到一些问题,这也是这章要讲的。
3.1 缓冲区的生存周期
首先是手册上给出的示例
1 |
|
在stream
关闭之前,buf
必须存在,否则在关闭时会出问题。在第1章也提过,进程终止时会导致缓冲区被刷新。而main()
结束在这些额外操作之前,此时栈上的buf
空间会被回收。
正确的方式是将缓冲区定义为全局或静态变量,或者采用malloc()
动态申请内存作为缓冲区,并保证在stream
被关闭之后才free()
释放缓冲区占用内存。
PS: 虽然在我的系统上实际执行这段代码运行正常,设置在非main函数中定义局部缓冲区仍然运行正常,但这实质上是不合法的。
3.2 缓冲区溢出
它指定的是缓冲区的最小大小,然而即使你设定的size
比你的buf
数组大小要大,该函数调用也不会出错,这样就造成了缓冲区溢出的问题。
给出下列代码
1 |
|
输出结果是
1 | $ ./a.out |
来解释下原因,这里我设置的size
为8,也就是说,标准库把g_buf
开始的8个字节都当做缓冲区,由于g_buf
本身只有4个字节,因此会把后面4个字节,也就是g_i
所占的内存作为缓冲区。
使用gdb调试
1 | Breakpoint 1, main () at stdout.c:12 |
执行完第1次循环后,g_i
的值为1,由于系统是小端的,所以低位的0x01放在低地址,也就是g_buf[4]
的位置上。
执行完第2次循环后,缓冲区后4个字节变成了0x30 0x31 0x0a 0x00
,因此g_i
的值变成了0x000a3130,g_i < 5
不再成立,跳出了循环。
4. 再谈全缓冲方式
本来在写博客的时候到上一节就戛然而止了,但是突然发现一个问题。
注意到3.2中第2次跳出循环时,缓冲区中的内容:”\n10001\n”,第1次write()
的只有5个字节”10000”,而缓冲区大小是8。
也就是说,全缓冲并未按照我在第1节中我说明的那样去运作,即等到缓冲区满了才刷新。
用strace
查看write()
的调用情况验证了我的观点
1 | write(1, "10000", 5) = 5 |
更改代码重新运行,看看打印10000到10005是怎样?
1 | for (g_i = 10000; g_i < 10005; g_i++) |
1 | write(1, "10000", 5) = 5 |
这次可以看到填满缓冲区再打印的情况,但只是中间2次。由于printf
是格式化输出,在把int转换成字符串时会计算长度,会不会是这个原因呢?
修改代码如下
1 | printf("%s", "10001\n"); |
1 | write(1, "10001", 5) = 5 |
和预想的不一样,那么直接改成fputs("10001\n", stdout)
呢?
1 | write(1, "10001\n", 6) = 6 |
说明全缓冲模式下printf("%s", s)
和fputs(s, stdout)
并不是完全等价的。
看来只有去看源码了,查看glibc版本
1 | # ldd a.out | grep libc |
去glibc官网下载2.23版本的源码。
具体地源码阅读就不在本篇讲述,没有习惯glibc的一些宏,读起来还是很吃力的
这里简单讲一点,找到libio/vsnprintf.c
1 | int |
之后有空再详细看下怎么实现的,留下的问题:
C格式化主要解析的就是字符串和整型,overflow_buf
只有64个字节,对于整型是足够了,
但是对于较长的字符串是如何保存的呢?
或者说并不是用于保存多出字符的,而是为了计算理论长度的临时缓冲区?
比如对如下的格式化字符串和格式化参数
1 | const char* format = "%s %d"; |
s就直接strlen()
计算长度,i则strtol()
到overflow_buf
中再计算长度,最后求和?
再就是问题的关键,全缓冲模式下,假设已经判断出多出字符的数量,如何保存中断位置呢?
我也有个大概思路,如果是在字符串的位置中断,则尽可能用s填充缓冲区剩余部分,然后移动s指针。
如果是在整数中断,则overflow_buf
记录整数转换成的字符串,然后用"%s"
替换format的"%d"
。
总之还没有非常明确的思路,以后有空自己写个Buffer
类。