Contents
  1. 1. 背景
  2. 2. 知识点
    1. 2.1. 关于缓冲区的类型
  3. 3. 分析
    1. 3.1. exit 与缓冲 flush
  4. 4. 真正的恢复 stdout
  5. 5. Cocoa 上的一些偏差
  6. 6. 多进程、多线程下的 printf

背景

在前文 重定向 stdout 随后恢复——探究 printf 中发现重定向过后的 stdout 缓存策略发生了改变,本文就具体描述下 stdout 到底有什么缓存策略。

知识点

  • printf 默认情况下打开的 stdout 标准输出流,在上面写数据。重定向可以改变打开的流,写到别的流上。
  • printf 作为标准 IO 的一部分,它是带缓冲区的
  • 重定向之后缓冲区的类型从行缓冲变成全缓冲
  • exit 函数在执行的时候会自动 flush 所有流,根据每个操作系统自己实现的而定

关于缓冲区的类型

1、全缓冲 这是一块由 malloc 生成的内存块,用于缓冲数据。操作磁盘上的文件通常由标准 IO 库进行全缓冲,在一个流上执行第一次 IO 操作的时候相关函数调用 malloc 生成缓冲区。这也就是为什么重定向 stdout 之后由原来的行缓冲变成了全缓冲,因为重定向到了磁盘上的文件,磁盘上的文件进行 IO 需要全缓冲。全缓冲的特点是写满之后就进行实际的 IO 操作。

2、行缓冲 只要遇到换行符就进行实际的 IO 操作(在这里都是写,暂时不讨论读的情况)以及行缓冲区填满的时候(尽管还是没有遇到一个换行符),因为它本质上还是一个全缓冲只不过带上了遇到换行符就 flush 的特性而已。

3、不缓冲 标准 IO 函数对字符不缓冲,通常直接调用 write 函数写到文件上。通常 stderr 就是不带缓冲的,因为希望错误都能及时的看到。
stdout 带缓冲、stderr 不带缓冲,这一点跟流行的日志库 CocoaLumberjack 某个设计比较类似:
level 是 info 的是异步打印,是 error 的同步打印。

分析

这次着重来看打印的情况

    int main(int argc, const char * argv[]) {
        printf("I am in stdout\n");
        int my_copy = dup(fileno(stdout));
        
        fprintf(stderr, "fd number my_copy is %d\n", my_copy);
        
        // 重定向 stdout 到本地文件
        FILE *my_file = freopen("/Users/sean/Desktop/mylog", "a+", stdout);
        
        fprintf(stderr, "fd number my_file is %d\n", fileno(my_file));
        fprintf(stderr, "fd number stdout is %d\n", fileno(stdout));
        
        printf("I am in mylog\n");
        
        // 恢复
        dup2(my_copy, fileno(my_file));
        
        printf("I am in stdout, reborn\n");
        
        fprintf(stderr, "after restore fd number my_file is %d\n", fileno(my_file));
        fprintf(stderr, "after restore fd number stdout is %d\n", fileno(stdout));
        return 0;
    }

对应日志输出为

    I am in stdout
    fd number my_copy is 3
    fd number my_file is 1
    fd number stdout is 1
    after restore fd number my_file is 1
    after restore fd number stdout is 1
    I am in mylog
    I am in stdout, reborn
    Program ended with exit code: 0

这里关于缓冲的问题表现为

A. 为什么打印到 stderr 上的日志比前面的 ‘I am in stdout, reborn’ 还早
B. ‘I am in mylog’ 这句话应该是重定向到了文件里,怎么又在恢复重定向之后又跑到 stdout 里来了。

解释:

A. 重定向恢复之后 stdout 配备的缓冲策略是一块全缓冲区,这块缓冲区在重定向之后第一次执行 IO 操作的时候由 malloc 生成。

设置 malloc 的符号断点可以看到
printf("I am in mylog\n"); 语句之后调用了 malloc 函数
malloc 的断点

同时,第二次进行 printf 的时候 malloc 函数没有调用了。尽管这次 printf 是恢复重定向操作之后的,但配备的缓冲区还是之前那个。

B. 本应该写到文件里的日志跑到 stdout 里来了,这是因为还没等到全缓冲区把数据写到文件里又恢复了重定向改变了数据的流向,让他流向了 stdout。之所以在没有恢复重定向的时候每次都能在文件里看到正常的数据是因为程序在结束的时候操作系统自动调用了 flush 操作。至于前文发现 fflush 函数不止调用了一次是因为有多个流需要 flush,自然不止一次。flush 具体来说就是 exit 自动调用的,这块儿的调用栈证明可以看到

所以在恢复了重定向之后并且程序运行结束之时,fflush 的符号断点断到了。只有 fflush 函数执行完毕,才能在终端上看到后面的打印。

前文提到的第二个程序代码的效果基于此就更好分析了,前文基本上猜测的也是对的就不多写了。

exit 与缓冲 flush

值得一提的是 exit 函数调用的 fflush 操作,前段时间写的另外一篇文章Main 函数退出之后发生什么? 里面讲到 main 函数结束的时候默认是用 exit 函数来结束。exit 函数会调用 fflush 函数来冲洗所有的流,但是有的操作系统把这种功能取消掉了。另外 _exit 作为被 exit 调用的函数,它提供了一种不处理缓冲的功能。

所以在多进程编程中,fork 之后的子进程使用 _exit 函数来退出可以避免把父进程缓冲区的数据在子进程中打印出来,因为 fork 之后子进程会复制父进程中的缓冲区。

所以 Main 函数退出会做:执行 atexit 中注册的函数 -> 调用 exit -> fflush -> _exit -> 返回内核

真正的恢复 stdout

有了前文的理论基础之后,想要做到彻底的恢复重定向就简单了。只需要把重定向之后新创建的那份缓冲区设置成行缓冲就行了。

setlinebuf(my_file); 一执行就 ok 了。

main(int argc, const char * argv[]) {
    printf("I am in stdout\n");
    int my_copy = dup(fileno(stdout));

    // 重定向 stdout 到本地文件
    FILE *my_file = freopen("/Users/sean/Desktop/mylog", "a+", stdout);
    setlinebuf(my_file);

    printf("I am in mylog\n");

    // 恢复
    dup2(my_copy, fileno(stdout));

    printf("I am in stdout, reborn\n");

    fprintf(stderr, "after restore fd number my_file is %d\n", fileno(my_file));
    fprintf(stderr, "after restore fd number stdout is %d\n", fileno(stdout));
}

这样打印的结果就看不到该去文件的数据,也能按照程序执行的顺序看到日志。

I am in stdout
I am in stdout, reborn
after restore fd number my_file is 1
after restore fd number stdout is 1
Program ended with exit code: 0

Cocoa 上的一些偏差

while (1) {
    fprintf(stdout, "stdout log");
    fprintf(stderr, "stderr log");
    usleep(1000);
}

按照上面的分析,这段代码得到的结果是输出了很多 ‘stderr log’ 之后才会一口气输出缓冲区里的 ‘stdout log’。
使用 gcc 编译并运行的结果是符合预期的。

但是这段代码放到 Cocoa 环境中却并不是这样结果是按照程序执行的顺序挨个打印,stdout 似乎有着比行缓冲容量还要更低的缓冲,似乎是没有缓冲。。。。

多进程、多线程下的 printf

先说多进程下的情况,缓冲区会复制一份所以缓冲区是进程不共享的,但是文件表项是共享的。但是操作系统可能对这部分进行了加锁的管理,多进程 printf 不会引发故障最多是日志错乱。但是自己试验中发现错乱的概率很低,只遇到了一个。

多线程下缓冲区是共享的,对缓冲区的访问需要操作系统来加锁。网上说的错乱的情况也没复现,可能是方式方法不对。

Contents
  1. 1. 背景
  2. 2. 知识点
    1. 2.1. 关于缓冲区的类型
  3. 3. 分析
    1. 3.1. exit 与缓冲 flush
  4. 4. 真正的恢复 stdout
  5. 5. Cocoa 上的一些偏差
  6. 6. 多进程、多线程下的 printf