一个 fork 的面试题引发的思考

2020年5月1日 · 4 years ago

一个 fork 的面试题引发的思考

酷壳有个经典文章: 一个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 = 0i = 1

i = 0fork() 出一个子进程,此时有两个进程,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,有三种情况要处理:

  1. 不写入缓冲区(__SNBF) Unbuffered: write up to BUFSIZ bytes at a time.
  2. 完全缓冲 Fully buffered: fill partially full buffer, if any, and then flush.
  3. 行缓冲(__SLBF) Line buffered: like fully buffered, but we must check for new lines.

而 Libc 里提供的 stdin, stdoutstderr 定义如下:

#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 运行的,符合预期。

参考资料