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
才能看到结果。而这个问题的原因和解决方法,网络上很早就有相关的讨论了,也许你也知道并且用过。
通常,当程序的 stdout 指向终端的时候,使用的是行缓冲,这也是为什么 printf()
如果不换行而且不 fflush()
的话,就会暂时看不到输出;C++ 要加 endl
的原因。然而较不为人知的是,在 glibc 的实现中,当程序的 stdout 指向管道的时候,会转为使用全缓冲,缓冲的大小,据路边社说,是 4 KB。这就解释了 ./time | less
和 find / | 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);
在 C 程序中,我们使用哪种?
你可能会说第一项和第三项矛盾了,非也。然而,将 setvbuf(stdout, 0, _IONBF, 0);
放到 execvp()
前面,并不能修改缓冲策略,解决问题。可以简单的认为,在 execvp()
后,缓冲的策略再一次被重设了 —— 的确,在 ./time
中增加 setvbuf(stdout, 0, _IONBF, 0);
的确是有用的。不过,总不能为了一个偏门的需要,修改其它程序的源代码吧,而且这个方法的副作用也是很大的,所有的输出缓冲都没有了,导致性能低下。
那么伪终端呢?这个方法的确是不错,但这要对程序进行较大的改动,而且,因为是伪终端,我们只能读到虚拟终端机上的文字,使得区分 stdout
和 stderr
也成为了一个新问题。
最后,只剩下了第三种方法。
stdbuf
是 GNU Coreutils 提供的工具,它的做法应该具有代表性。代码传送门如下
原来,GNU 使用了一个技巧解决了这个郁闷的问题。首先,libstdbuf.c
读取 _STDBUF_
系列环境变量,根据环境变量的内容,执行 setvbuf()
设置缓冲策略;编译为共享库 libstdbuf.so
;stubuf
设置好相应的环境变量,然后将 libstdbuf.c
从系统中搜索出来,并插入到 LD_PRELOAD
中!接下来程序被 execvpe()
,由于 LD_PRELOAD
先于任何函数加载,因此成功修改了缓冲策略。LD_PRELOAD 的作用就不用多解释了,调试程序利器。 要知道更多的实现细节,请阅读上面源码的注释。
而 _STDBUF_
系列变量的文档如下:
三个变量的取值均为 L
、0
或字节数。L
代表行缓冲 (line buffer);0 可以看作是字节数的一个特例,零字节即无缓冲;或者指定其它字节数。另外,由于行缓冲对于 stdin 没有意义,因此 L
对于 stdin 无效。
明白了 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 吧 :) 最后,虽然本文的代码都经过了测试,但请不要盲目复制粘贴本文的代码,因为里面省略了大量的错误检查,还有一些其它的问题,出了问题请不要怪本盖子啦~