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
退出之后的时间戳,时间戳并不是即时添加的?strace
跟踪一下程序:
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 | 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);
- 伪终端,如 unbuffer
- 以某种方法修改缓冲策略,如 stdbuf
在 C 程序中,我们使用哪种?
你可能会说第一项和第三项矛盾了,非也。然而,将 setvbuf(stdout, 0, _IONBF, 0);
放到 execvp()
前面,并不能修改缓冲策略,解决问题。可以简单的认为,在 execvp()
后,缓冲的策略再一次被重设了 —— 的确,在 ./time
中增加 setvbuf(stdout, 0, _IONBF, 0);
的确是有用的。不过,总不能为了一个偏门的需要,修改其它程序的源代码吧,而且这个方法的副作用也是很大的,所有的输出缓冲都没有了,导致性能低下。
那么伪终端呢?这个方法的确是不错,但这要对程序进行较大的改动,而且,因为是伪终端,我们只能读到虚拟终端机上的文字,使得区分 stdout
和 stderr
也成为了一个新问题。
最后,只剩下了第三种方法。
libstdbuf
stdbuf
是 GNU Coreutils 提供的工具,它的做法应该具有代表性。代码传送门如下
原来,GNU 使用了一个技巧解决了这个郁闷的问题。首先,libstdbuf.c
读取 _STDBUF_
系列环境变量,根据环境变量的内容,执行 setvbuf()
设置缓冲策略;编译为共享库 libstdbuf.so
;stubuf
设置好相应的环境变量,然后将 libstdbuf.c
从系统中搜索出来,并插入到 LD_PRELOAD
中!接下来程序被 execvpe()
,由于 LD_PRELOAD
先于任何函数加载,因此成功修改了缓冲策略。LD_PRELOAD 的作用就不用多解释了,调试程序利器。 要知道更多的实现细节,请阅读上面源码的注释。
而 _STDBUF_
系列变量的文档如下:
- _STDBUF_I – stdin
- _STDBUF_O – stdout
- _STDBUF_E – stderr
三个变量的取值均为 L
、0
或字节数。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 吧 🙂 最后,虽然本文的代码都经过了测试,但请不要盲目复制粘贴本文的代码,因为里面省略了大量的错误检查,还有一些其它的问题,出了问题请不要怪本盖子啦~
2014年08月04日 — 10:02
学习到了
2014年04月14日 — 01:15
😉
2014年04月13日 — 21:54
你可以把进程的 stdin 和 stdout 连到 pty,但是 stderr 还是用管道 🙂
又或者,你把 stdout 和 stderr 连到不同的 pty?
2014年04月15日 — 12:19
这个主意不错。