流缓冲影响父子进程通讯的问题

a.k.a 《在 C 程序中禁用管道中 stdout 的流缓冲》

注意,本文中代码使用了 C99 特性,如声明 for 循环控制变量和 VLA 数组;同时使用了 GNU 的扩展。请使用 -std=c99 -D_GNU_SOURCE 编译。

最近,盖子正在重写服务器上的 php-loop 工具。这个工具的作用的不断重复运行一个程序。叫 php 的原因,是因为最初(也是现在)的需求是用来不停地运行 php-cgi,防止它死掉导致服务中断。

由于时常有人通知盖子虚拟主机上的 php-cgi 出错,因此日志是十分重要的。然而,php-cgi 打印的日志没有时间戳,因此,盖子的 php-loop 必须能够对 php-cgi 的输出做一点处理。

这种需求一点也不新鲜,思路自然从这里开始 —— fork() 出一个子进程去处理一些事情,并把随时把进展通过管道告知父进程。

然而,将这种方法扩展到 execvp 后,却发现由于 stdout 的流缓冲,无法及时获得输出信息。

父子进程通信的简单程序

下面这个程序是一个简单的示例

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>

void putchar_tmsp(char c)
{
    static time_t tmsp = 0;
    if (!tmsp) {
        tmsp = time(NULL);
        char *ctime_nonewline = ctime(&tmsp);
        ctime_nonewline[strlen(ctime_nonewline) - 1] = '\0';
        printf("[%s] ", ctime_nonewline);
    }
    putchar(c);
    if (c == '\n') {
        tmsp = 0;
    }
}

int main(int argc, char **argv)
{
    int file_pipe[2];
    pipe(file_pipe);

    switch (fork()) {
        case 0: {
            /* child */
            close(file_pipe[0]);

            char progress[6];
            for (int i = 0; i <= 5; i++) {
                sprintf(progress, "%d%%\n", i * 20);
                write(file_pipe[1], progress, strlen(progress));
                sleep(1);
            }
            close(file_pipe[1]);
        }
        default: {
            /* parent */
            close(file_pipe[1]);

            char buffer[BUFSIZ + 1];
            ssize_t size;
            while ((size = read(file_pipe[0], buffer, BUFSIZ)) > 0) {
                for (int i = 0; i < size; i++) {
                    putchar_tmsp(buffer[i]);
                }
            }
        }
    }
    return 0;
}

这个程序相当直观,父子进程关闭一些没用的文件描述符就开始通信了。一端写管道,一端读管道。而且子进程什么任务都没做,函数(系统)调用没有任何错误检查,仅仅是一个例子嘛……-

子进程的进度信息会汇报给父进程,然后在 putchar_tmsp 的帮助下,带着时间戳打印出来。

[Sun Apr 13 13:53:45 2014] 0%
[Sun Apr 13 13:53:46 2014] 20%
[Sun Apr 13 13:53:47 2014] 40%
[Sun Apr 13 13:53:48 2014] 60%
[Sun Apr 13 13:53:49 2014] 80%
[Sun Apr 13 13:53:50 2014] 100%

重定向标准输出到管道

顺着这个思路,我们可以直接一个外部程序,然后把它的标准输出重定向到管道。设置好重定向后,子进程就可以使用 execvp 把自己替换成那个要运行的程序,而设置好的重定向会仍然存在。父进程就会将所有的输出加入时间戳。Bingo!

case 0: {
    /* child */
    close(file_pipe[0]);
    dup2(file_pipe[1], STDOUT_FILENO);
    close(file_pipe[1]);

    char *args[2];
    args[0] = argv[1];
    args[1] = NULL;
    execvp(argv[1], args); /* never return expect error */
}

很好!现在使用 ./pipe ls 运行程序,就会发现所有的 ls 输出都带上了时间戳。

[Sun Apr 13 13:55:59 2014] diretory1
[Sun Apr 13 13:55:59 2014] file1
[Sun Apr 13 13:55:59 2014] file2

输出缓冲

如果一切都是那么顺利的话,文章到这里也就应该结束了。我写了一篇简单的教程 —— 如果是这样的话,我根本没有必要写这篇博文了。下面就来揭露出真正困扰盖子的问题。

为了检验时间戳的准确性,我们又编写了一个程序 time,它打印出当前的时间戳,睡一会,它的源代码如下:

#include <stdio.h>
#include <time.h>
#include <unistd.h>

#define LOOPS 1

int main(void)
{
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < LOOPS; j++) {
            printf("%ld", time(NULL));
        }
        putchar('\n');
        sleep(3);
    }
    return 0;
}

然后,我们用 ./pipe ./time 运行这个程序。

哦?./pipe 时时没有输出……过了大概十秒钟后,我们得到了以下输出

[Sun Apr 13 14:04:22 2014] 1397369053
[Sun Apr 13 14:04:22 2014] 1397369056
[Sun Apr 13 14:04:22 2014] 1397369059

啥?What? 纳尼?./pipe 的时间戳是 ./time 退出之后的时间戳,时间戳并不是即时添加的?跟踪一下程序:

close(4)  = 0  // close(file_pipe[1])
read(3,        // read(file_pipe[0],

read() 函数在子进程退出之前就一直这样阻塞着……时间戳是退出一刻的时间戳也就不足为奇了。

事实上,这种事情在我们日常的系统使用中就已经存在了,只不过我们没有注意到它。比如 ./time | less 可以看到一样的效果,只有 ./time 退出后,才能看见三个时间戳。但是,find / | less 的输出看上去却又是实时更新的 —— 这才是预期中的行为,也是 Unix 比 DOS 管道的一个优点 —— 而不是等 find 把整个根目录都扫描一遍以后,less 才能看到结果。

而这个问题的原因和解决方法,网络上很早就有相关的讨论了,也许你也知道并且用过。

  • What is buffering? Or, why does my command line produce no output: tail -f logfile | grep 'foo bar' | awk ...
  • Turn off buffering in pipe

通常,当程序的 stdout 指向终端的时候,使用的是行缓冲,这也是为什么 printf() 如果不换行而且不 fflush() 的话,就会暂时看不到输出;C++ 要加 endl 的原因。然而较不为人知的是,在 glibc 的实现中,当程序的 stdout 指向管道的时候,会转为使用全缓冲,缓冲的大小,据路边社说,是 4 KB。这就解释了 ./time | lessfind / | less 现象不同的原因:./time 的数据量太小,以至于没有写满缓冲。

为了证实这一点,我们把 ./time 修改一下

#define LOOPS 1000

再试试?

[Sun Apr 13 14:24:02 2014] 13973702421397370242139737024213...
[Sun Apr 13 14:24:05 2014] 13973702451397370245139737024513...
[Sun Apr 13 14:24:08 2014] 13973702481397370248139737024813...

可以看到,虽然屏幕被刷花了,但是时间戳确实因为缓存满而实时更新了。好了,实验完时候,最好还是把 LOOPS 的定义改回来,等一会儿还需要实验呢……盖子也不希望刷你的屏啊。

可行的思路

在命令行下,这种问题的解决方法是相当多,正如你在上面链接中所看到的。这种方法的原理不外乎只有三种:

  • setvbuf(stdout, 0, _IONBF, 0);
  • 伪终端,如 unbuffer
  • 以某种方法修改缓冲策略,如 stdbuf

在 C 程序中,我们使用哪种?

你可能会说第一项和第三项矛盾了,非也。然而,将 setvbuf(stdout, 0, _IONBF, 0); 放到 execvp() 前面,并不能修改缓冲策略,解决问题。可以简单的认为,在 execvp() 后,缓冲的策略再一次被重设了 —— 的确,在 ./time 中增加 setvbuf(stdout, 0, _IONBF, 0); 的确是有用的。不过,总不能为了一个偏门的需要,修改其它程序的源代码吧,而且这个方法的副作用也是很大的,所有的输出缓冲都没有了,导致性能低下。

那么伪终端呢?这个方法的确是不错,但这要对程序进行较大的改动,而且,因为是伪终端,我们只能读到虚拟终端机上的文字,使得区分 stdoutstderr 也成为了一个新问题。

最后,只剩下了第三种方法。

libstdbuf

stdbuf 是 GNU Coreutils 提供的工具,它的做法应该具有代表性。代码传送门如下

  • stdbuf.c
  • libstdbuf.c

原来,GNU 使用了一个技巧解决了这个郁闷的问题。首先,libstdbuf.c 读取 _STDBUF_ 系列环境变量,根据环境变量的内容,执行 setvbuf() 设置缓冲策略;编译为共享库 libstdbuf.sostubuf 设置好相应的环境变量,然后将 libstdbuf.c 从系统中搜索出来,并插入到 LD_PRELOAD 中!接下来程序被 execvpe(),由于 LD_PRELOAD 先于任何函数加载,因此成功修改了缓冲策略。LD_PRELOAD 的作用就不用多解释了,调试程序利器。 要知道更多的实现细节,请阅读上面源码的注释。

_STDBUF_ 系列变量的文档如下:

  • _STDBUF_I - stdin
  • _STDBUF_O - stdout
  • _STDBUF_E - stderr

三个变量的取值均为 L0 或字节数。L 代表行缓冲 (line buffer);0 可以看作是字节数的一个特例,零字节即无缓冲;或者指定其它字节数。另外,由于行缓冲对于 stdin 没有意义,因此 L 对于 stdin 无效。

使用 libstdbuf

明白了 libstdbuf 这个库的用法,我们就可以利用这个库来编写程序了。为了方便使用,我编写了一个 libstdbuf.h 的头文件:

#define STDIN_BUF "I"
#define STDOUT_BUF "O"
#define STDERR_BUF "E"
#define LINE_BUF "L"
#define NO_BUF "0" /* invalid for STDIN_BUF */

#define LIBSTDBUF_PATH "/usr/libexec/coreutils/libstdbuf.so"
#define STDBUF(stream, mode) ("_STDBUF_"stream"="mode)

其中,由于搜索 libstdbuf.so 的过程过于复杂,盖子直接将 LIBSTDBUF_PATH 硬编码了。如果要将其改造成通用的程序,可以参照上面的 GNU 代码。至于 STDBUF 宏,诀窍在于 C 语言中多个连续的字符串被视作一个字符串,如 "abcd""efg" 将被视作整体,这样就不必在运行时拼接字符串了。涉及到内存分配的部分都是很烦人的啦~

另外,使用 getenv(), putenv()setenv() 去设置 LD_PRELOAD 似乎是无效的,与 LD_PRELOAD 设置的时机有关,具体原理有待探究。因此,我们使用 execvpe() (这是一个 GNU 扩展)来改写最后的代码。

...
#include "libstdbuf.h"
...

case 0: {
    /* child */
    close(file_pipe[0]);
    dup2(file_pipe[1], STDOUT_FILENO);
    close(file_pipe[1]);

    char *args[2];
    args[0] = argv[1];
    args[1] = NULL;

    char *envs[3];
    envs[0] = "LD_PRELOAD="LIBSTDBUF_PATH;
    envs[1] = STDBUF(STDOUT_BUF, LINE_BUF);
    envs[2] = NULL;

    execvpe(argv[1], args, envs); /* never return expect error */
}

最后运行程序 ./pipe ./time。最终,我们终于得到了期望的效果。

[Sun Apr 13 15:48:03 2014] 1397375283
[Sun Apr 13 15:48:06 2014] 1397375286
[Sun Apr 13 15:48:09 2014] 1397375289

要说可移植性嘛……这个问题本来就是平台相关的问题,就算是伪终端也是平台相关的特性。话说……没有那台机器上没装 coreutils 吧 :) 最后,虽然本文的代码都经过了测试,但请不要盲目复制粘贴本文的代码,因为里面省略了大量的错误检查,还有一些其它的问题,出了问题请不要怪本盖子啦~



文章来源:https://biergaizi.info/archives/2014/04/1832.html

你可能感兴趣的:(流缓冲影响父子进程通讯的问题)