管道是个好东西

临近春节假期之际,我司提供给客户用的命令行工具还遭遇一个新需求:

支持管道输入/输出。

需求背景:

原始的视频流体积巨大(一个十分钟视频的原始视频流就占用 2G 多空间),为了节约机器资源,客户希望能将原始的视频流直接通过管道输入,而不必转码生成临时媒体文件。

简而言之,客户希望支持这种调用方式:

1
cat some_big_rawvideo | ./my_prog

比如,我们用到的 ffmpegffplay 工具也支持通过 -pipe来指定管道输入输出:

1
2
3
ffmpeg -i input.mp4 -f avi - | ffplay -
ffmpeg -i input.mp4 -f avi pipe: | ffplay pipe:
ffmpeg -i input.mp4 -f avi pipe:1 | ffplay pipe:0

不幸的是,我们现有的工具并不支持,它只能根据命令行中的文件路径来指定输入输出。

1
my_prog -i /path/to/inputfile -o /path/to/outputfile

咋办?改!

已知

首先,小明知道,对于标准的命令行程序,它遵从基本的「一进二出」规范。

1
2
3
4
5
6
7
8
9
                                                   ---> stdout, pipe:1
/
/
||======================||
stdin, pipe:0 ---> || cmd ||
||======================||
\
\
---> stderr, pipe:2

管道

管道是 unix 设计哲学之一,其核心思想就是将前一个命令的标准输出作为后一个命令的标准输入

比如programo0 | program1 | program2 的输入输出示意图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
                                                     
stdin stdout stdin stdout stdin stdout
|| ======================= ====================== /\
|| /\ || /\ || ||
|| || || || || ||
\/ || \/ || \/ ||
||==========|| ||==========|| ||==========||
|| program0 || || program1 || || program2 ||
||==========|| ||==========|| ||==========||
|| || ||
|| || ||
\/ \/ \/
stderr stderr stderr

基于这么一条简单到爆的原则,管道通过 | 把一系列命令连接起来:第一个命令的输出会作为第二个命令的输入通过管道传给第二个命令,第二个命令的输出又会作为第三个命令的输入……,最终结果为管道行中最后一个命令的输出。

举个栗子:

1
cat /etc/passwd | grep /bin/bash | wc -l

这条命令使用了两个管道,利用第一个管道将 cat 命令(显示passwd文件的内容)的输出送给grep命令,grep命令找出含有/bin/bash的所有行;第二个管道将grep的输出送给wc命令,wc命令统计出输入中的行数。这个命令的功能在于找出系统中有多少个用户使用bash

stdin stdout stderr

unix 世界中,一切皆文件。文件描述符是与打开文件或者数据流相关联的整数,0、1、2 是系统保留的三个文件描述符,分别对应标准输入、标准输出、标准错误。

  • 0: stdin 标准输入串流 (键盘輸入)
  • 1: stdout 标准输出串流 (终端屏幕)
  • 2: stderr 标准错误输出串流 (终端屏幕)

重定向

比如:

1
my_prog <inputfile >outfile 2>&1

将标准输入重定向到 inputfile(意味着 my_prog不再从标准输入而是从 inputfile 中读取数据),将标准输出和标准错误结果都重定向到 outfile

如何才能让一个命令行程序支持管道?

So,小明上 StackOverflow 上先看看各位同仁怎么说:

To be “pipe compatible” your program will need to read from stdin and write to stdout.

原来如此:「为了支持管道,你的程序需要从 stdin读取输入并且将输出写到 stdout

小试牛刀

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//pipe-std.c
#include <stdio.h>

int main()
{
FILE * fi = stdin;
FILE * fo = stdout;
char buf[1024];
int r_cnt, w_cnt;

while ((r_cnt = fread (buf, 1, 1024, fi)) > 0) {
w_cnt = fwrite (buf, 1, r_cnt, fo);
if (w_cnt != r_cnt) {
fprintf (stderr, "some err: %d, %d\n", r_cnt, w_cnt);
return -1;
}
}

return 0;
}

这个示例非常简单,编译、运行:

1
2
3
4
5
6
hxz@pc0170:~/workspace/c++$ gcc -o pipe-std pipe-std.c
hxz@pc0170:~/workspace/c++$ ls -l ~/test/input/2.mp4
-rw-r--r--@ 1 hxz staff 1958612 Nov 27 18:01 /Users/hxz/test/input/2.mp4
hxz@pc0170:~/workspace/c++$ cat ~/test/input/2.mp4 | ./pipe-std >copy.mp4 2>error
hxz@pc0170:~/workspace/c++$ ls -l copy.mp4
-rw-r--r-- 1 hxz staff 1958612 Mar 1 14:22 copy.mp4

瞧见了没,这个简单的程序通过管道将输入的内容~/test/input/2.mp4复制到输出copy.mp4

实践出真知

原理搞懂了,接下来就是体力活了。在实际码代码过程中,还是发现了几点管道程序需要注意的地方:

  • 由于是从管道读取输入内容,而管道每获取到片段内容就会发送到下一级程序处理,这意味着我们不能事先获知输入文件的大小了。因此,像 get_file_size之类的方法将不再可用。

  • 管道内容只能顺序读取,不可逆回溯,也不可重复读取同一段内容。所以啊,我们的程序要珍惜每一次读取的机会,不能open了再open,只能opendo_work1do_work2……close 。具体到 ffmpeg 解码程序就是,avformat_open_input 这个api只能用一次,一次就要把需要的信息全部预加载进来。

彦祖老师 wechat