Shell 管道

2020-05-25T18:02:13+08:00

Shell 管道是 POSIX Shell 要求的功能。ls | nl 启动 2 个进程,管道把这 2 个进程串联起来,所以说管道是一种 IPC 机制。管道可以用 Unix 系统调用 pipe(2) 实现。下面我尝试实现它。

实现 ls | nl (2 个命令)

一次系统调用 pipe(2) 获得一对文件描述符,fd0 可读,fd1 可写。把 ls 进程的 stdout 替换为 fd1,把 nl 进程的 stdin 替换为 fd0:

ls -> | -> nl
   fd1 fd0

就实现了 ls | nl

/* ls-nl.c --- 实现 ls | nl */
#include <unistd.h>             /* pipe */
#include <stdio.h>              /* perror */
#include <stdlib.h>             /* exit */
#include <string.h>             /* strcmp */

static void
die(char* msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int
main(int argc, char* argv[])
{
    if (argc < 3 || strcmp(argv[1], "--help") == 0) {
        fprintf(stderr, "usage: %s cmd1 cmd2\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    char* cmd1 = argv[1];
    char* cmd2 = argv[2];

    int pipefd[2];
    if (pipe(pipefd) == -1)
        die("pipe");

    int fd0 = pipefd[0];
    int fd1 = pipefd[1];

    switch (fork()) {
    case -1:
        die("fork");

    case 0:
        if (dup2(fd1, STDOUT_FILENO) == -1)
            die("dup fd1 as stdout");

        execlp(cmd1, cmd1, NULL);
        die("execlp");

    default:
        close(fd1);             /* 必须关闭写端,否则读不到 EOF */
        if (dup2(fd0, STDIN_FILENO) == -1)
            die("dup fd0 as stdin");

        execlp(cmd2, cmd2, NULL);
        die("execlp");
    }
}

有两点值得注意。一是管道的关闭,管道的文件描述符成对出现,当「所有的」写端都关闭时,读取端才会获得 EOF,不要忘记 fork(2) 会复制文件描述符,所以 fork 之后,总共这 1 个管道关联着 4 个文件描述:fd0、fd1、fd0’、fd1’,只有当写端 fd1 和 fd1’ 都关闭时,读取端才会获得 EOF。上面我手动关闭了 fd1,而 fd1’ 会在子进程结束之后自动关闭(进程退出时会关闭所有其打开的文件描述符)。

二是 Shell 管道的结束,POSIX 明确要求管道的结束必须等最后一个命令结束,而其它命令可以等也可以不等,Bash、Zsh 会等所有命令结束。我的上面的代码只会等最后一个命令,第二个命令一结束,程序就退出了。但是我的代码有个问题:

yes | head

退出之后,yes 进程会变成 orphan 进程,在后台永远运行下去,正确的做法是主动终止它(或者在子进程里 close(fd0) 也行,yes 进程在发现管道没人读之后貌似也会终止)。

实现 ls | head | nl (N 个命令)

3 个命令需要 3 个子进程,3 个管道。N 个命令需要 N 个子进程,N-1 个管道。

/* pipe-test-4.c --- 实现 pipeline */
#include <unistd.h>             /* pipe */
#include <stdio.h>              /* perror */
#include <stdlib.h>             /* exit */
#include <string.h>             /* strcmp */

static void
die(char* msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int
main(int argc, char* argv[])
{
    if (argc < 3 || strcmp(argv[1], "--help") == 0) {
        fprintf(stderr, "usage: %s cmd1 cmd2 ...\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    int p1fds[2];
    int p2fds[2];

    pipe(p1fds);
    if (fork() == 0) {
        close(p1fds[0]);
        dup2(p1fds[1], STDOUT_FILENO);
        execlp(argv[1], argv[1], NULL);
        die("exec");
    }

    for (int i = 2; i < argc-1; i++) {
        close(p1fds[1]);
        pipe(p2fds);
        if (fork() == 0) {
            close(p2fds[0]);
            dup2(p1fds[0], STDIN_FILENO);
            dup2(p2fds[1], STDOUT_FILENO);
            execlp(argv[i], argv[i], NULL);
            die("exec");
        }

        p1fds[0] = p2fds[0];
        p1fds[1] = p2fds[1];
    }

    close(p1fds[1]);
    dup2(p1fds[0], STDIN_FILENO);
    execlp(argv[argc-1], argv[argc-1], NULL);
    die("exec");
}

测试:

~/src/my-website/shell-pipeline $ ./pipe-test-4 ls head nl rev rev
     1  compile_commands.json
     2  ls-nl
     3  ls-nl-v2
     4  ls-nl-v2.c
     5  ls-nl.c
     6  ls-sort-nl
     7  ls-sort-nl.c
     8  pipe-test
     9  pipe-test-2
    10  pipe-test-2.c
~/src/my-website/shell-pipeline $

上面的代码没做错误检查,因为我花了好几个小时才写出来,最后懒得加了。此外,这段代码跟上一个一样,不会等全部进程都结束才结束,而且也不会清理它们,会导致 orphan 进程。