酷壳有个经典文章: 一个fork的面试题 挺有趣的,不仅涉及 fork()
函数,还有一个缓冲区继承的技术点。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
int i;
for(i=0; i<2; i++){
fork();
printf("-");
}
sleep(1);
sleep(1);
return 0;
}
简单解释一下,上述代码只考虑 fork()
的话应该输出 6 个 "-"。因为有两层循环,i = 0
和 i = 1
。
i = 0
时 fork()
出一个子进程,此时有两个进程,print
两次。i = 1
时这两个进程又各自 fork()
出两个进程,一共四个,print
四次,所以一共六次。
但是这里还涉及 printf()
的缓冲设计,因为子进程在被 fork()
时会继承父进程的所有信息,包括缓冲区,所以有两个子进程在被 fork()
那一刻,拿到了父进程缓冲的 "-"
字符,加上自己的 print
,总共会多出来两个 "-"
。
此前在macOS 内核之一个 App 如何运行起来有介绍到被 fork()
的子进程会拿到所有的 vmap
之类的指针,所以理论上父进程所持有的内存就会自动被子进程继承,所以父进程buffered
数据子进程就可以接着往下走。
原文也提到我们可以加上换行符 "\n"
或调用 fflush()
来强行清空缓冲区。
我好奇的问题在于,这个 printf()
的缓冲是怎么设计的?他的源码是怎么写的?
stdout
的缓冲设计
我们 printf()
实际上是往 stdout
标准输出写入数据。原文解释缓冲设计时还提到另外一个例子:
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
fprintf(stdout,"hello-std-out");
fprintf(stderr,"hello-std-err");
sleep(1);
}
return 0;
}
上述代码在 macOS 上不会输出 stdout
只会输出 stderr
(未触及 buffer size limit 的前提下)。
如果你把 "hello-std-out" 末尾加上 "\n" 他就能正常输出了。
printf()
的源码实现在 Libc
, macOS 使用的版本可以在这里下载。
int
printf(char const * __restrict fmt, ...)
顶层实现比较简单,封装了几层加锁,优化之类的内部实现,我们直接看最底层 __sfvwrite
。
/*
* Write some memory regions. Return zero on success, EOF on error.
*
* This routine is large and unsightly, but most of the ugliness due
* to the three different kinds of output buffering is handled here.
*/
int __sfvwrite(fp, uio)
里面判断 fp
传入的 flags
,有三种情况要处理:
- 不写入缓冲区(__SNBF) Unbuffered: write up to BUFSIZ bytes at a time.
- 完全缓冲 Fully buffered: fill partially full buffer, if any, and then flush.
- 行缓冲(__SLBF) Line buffered: like fully buffered, but we must check for new lines.
而 Libc 里提供的 stdin
, stdout
和 stderr
定义如下:
#define STDIN_FILENO 0 /* standard input file descriptor */
#define STDOUT_FILENO 1 /* standard output file descriptor */
#define STDERR_FILENO 2 /* standard error file descriptor */
FILE __sF[3] = {
std(__SRD, STDIN_FILENO),
std(__SWR, STDOUT_FILENO),
std(__SWR|__SNBF, STDERR_FILENO)
};
FILE *__stdinp = &__sF[0];
FILE *__stdoutp = &__sF[1];
FILE *__stderrp = &__sF[2];
#define stdin __stdinp
#define stdout __stdoutp
#define stderr __stderrp
至此我们可以看到 stderr
带上了 __SNBF
flag,表示完全不 buffer,所以只要一调用 printf
它就会写入。
而 stdout
没有带这个 buffer,在 __sfvwrite()
的实现里,它先判断如果非 __SNBF
那就是要 buffer,然后判断 fp
没带上 __SLBF
那就是 fully buffered。
看到这里大家会不会有个疑问,__stdoutp
声明的时候不带 __SLBF
flag 那为什么上述例子加上换行它就自动 flush 了?
这里 fp
的 flags 是随时可以被修改的,stdio
封装的 setlinebuf()
接口就可以把当前 fp
加上 _IOLBF
mode,也就是带上 __SLBF
flag。
在我们上面的那个例子中,如果我们加上
setbuf(stdout, NULL);
或者
setvbuf(stdout, NULL, _IONBF, 0);
fp->flags
就会被修改,stdout
就能及时被打印出来。
所以虽然 Libc
声明的时候默认是 fully-buffered
但是中间可能会被修改。至于内核具体在什么地方修改了我暂时没找到,不过我们可以参考这篇文章
GNU libc (glibc) uses the following rules for buffering:
Stream | Type | Behavior |
---|---|---|
stdin | input | line-buffered |
stdout (TTY) | output | line-buffered |
stdout (not a TTY) | output | fully-buffered |
stderr | output | unbuffered |
我跑上述例子的时候是在 terminal 用 gcc 编译然后 ./a.out
运行的,符合预期。