引言

一个进程要并发地做几件事,最直接的手段是fork()出几个子进程分头去做。但是fork()之后,哪些数据是父子各一份,哪些是父子共享的,子进程退出后由谁收尾,每一个细节都直接影响程序的正确性,不弄清楚很容易写出依赖偶然行为的代码。

本文涉及系统调用fork(),exec(),wait(),和文件相关的系统调用,是对OSTEP(Operating Systems: Three Easy Pieces)第5章课后练习的总结。

正文

fork()基础调用

fork()调用必须包含头文件unistd.h,调用后系统将父进程的所有内存数据复制一份,生成一个子进程。 子进程看起来和父进程完全一样,不过子进程有不同的pid和不同的fork()返回值0,父进程的fork()返回子进程的pid。

#include <iostream>//cout
#include <unistd.h>//fork()

static void test1()
{
    int x{10};
    const int rc = fork();
    if (rc == 0) {
        cout << "child x=" << x << " &x=" << &x << '\n';
        x += 20;
        cout << "child x=" << x << '\n';
    }
    else {
        cout << "parent x=" << x << " &x=" << &x << '\n';
        x -= 20;
        cout << "parent x=" << x << '\n';
    }
}

父子进程谁先运行并不确定,取决于调度器的选择。

最终我们发现子进程的x=30,父进程的x=-10,说明fork()之后父子进程的内存数据是独立的:打印出来的变量地址相同,修改的却是各自的变量。

文件并行

使用文件系统调用修改文件,观测父子进程对文件偏移量的共享情况。

#include <iostream>
#include <fcntl.h>//open()
#include <sys/wait.h>//wait()
#include <unistd.h>//close(),read(),write()

static void test2()
{
    int fd = open("./test",O_RDWR);
    if (fd == -1) { return; }
    int rc = fork();
    if (rc == 0) {
        cout << "child fd=" << fd << '\n';
        char buf[8]{"child"};
        write(fd, buf, 5);
        char rd[16]{};
        read(fd, rd, 8);
        cout << "child read=" << rd << '\n';
    }
    else {
        wait(NULL);
        cout << "parent fd=" << fd << '\n';
        char buf[8]{"111"};
        write(fd, buf, 3);
        char rd[16]{};
        read(fd, rd, 8);
        cout << "parent read=" << rd << '\n';
    }
    close(fd);
    cout << "file closed" << '\n';
}
/*>./main
child fd=3
child read=111222ch
file closed
parent fd=3
parent read=ild
file closed
*/

父进程先打开文件获取文件描述符,然后执行fork()。刚打开的文件偏移量为0,子进程write()写入5字节、read()读取8字节后,偏移量变成13;父进程再write(),起始位置已经是13,而不是预期中的0。

这里可以看出文件偏移量并不属于某个进程:父子进程的fd指向同一个打开文件描述(open file description),偏移量只有一份,子进程close(fd)只是关闭自己那份描述符,偏移量仍停留在子进程离开时的位置。进程并发地修改同一个文件,写入的先后顺序无法预测。

父进程等待

父子之间只有父进程能等待子进程,一般使用wait()系统调用,返回结束的子进程的pid。子进程退出后并不会立刻消失:先进入僵尸状态,只保留pid和退出状态,同时向父进程发出SIGCHLD信号;wait()等到任意一个子进程退出后回收它残留的资源,子进程才算真正结束。

除了调用wait(),还能利用空管道的读阻塞,或者接收信号的方式,让父进程等待子进程结束。

等待信号方式:

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>

void handler(int) {}  // 空信号处理函数

void test3()
{
    signal(SIGUSR1, handler);

    pid_t pid = fork();
    if (pid == 0) {
        std::cout << "hello" << std::endl;
        kill(getppid(), SIGUSR1);  // 向父进程发送信号
        exit(0);
    } else {
        pause();  // 父进程挂起,直到收到信号
        std::cout << "goodbye" << std::endl;
    }
}

exec()系统调用

exec()是让子进程变成其他程序的系统调用,它实际是一个调用家族:execl()、execle()、execlp()、execv()、execvp(),都封装自同一个底层系统调用execve()。

后缀表示了这个家族成员的具体分类。l表示调用使用list列表形式的参数,v表示参数使用字符数组形式,e表示附带父进程的环境变量,p表示先在系统路径path中寻找程序。

#include <iostream>
#include <sys/wait.h>
#include <fcntl.h>//open()
#include <cstring>//strdup()
#include <unistd.h>//close(),read(),write()

void test4()
{
    int rc = fork();
    if (rc == 0) {
        execl("/usr/bin/ls", "ls", NULL);
        execle("/usr/bin/ls", "ls", "-a", NULL);
        execlp("ls", "ls", "-l", NULL);
        char *args[4];
        args[0] = strdup("ls");
        args[1] = strdup("-a");
        args[2] = strdup("-l");
        args[3] = NULL;
        execv("/usr/bin/ls", args);
        execvp(args[0], args);
    }
    else {
        wait(NULL);
        std::cout << "goodbye" << std::endl;
    }
}

exec()调用成功后,原进程的内存被清空,目标程序被加载到原来的地址空间,pid保持不变,和父进程的父子关系也不变,父进程依然可以wait()回收它。

这意味着一旦exec()调用成功,后面的所有代码都将不再执行;只有调用失败时才会返回-1,继续向下运行。

wait()与waitpid()

子进程调用wait()会直接返回-1,因为它没有子进程可等。

wait(NULL)实际调用的是更底层的waitpid(-1,NULL,0):-1表示等待任意一个子进程,换成具体的pid就只等那一个;中间的int*参数用于带回子进程的退出状态;最后的options参数控制等待行为,0表示一直阻塞到有子进程退出为止。

#include <iostream>
#include <unistd.h>//fork()
#include <sys/wait.h>//wait(),waitpid()

void test5()
{
    int rc = fork();
    if (rc == 0) {
        int wc = wait(NULL);
        if (wc == -1)
            cout << "hello" << std::endl;
    }
    else {
        waitpid(rc,NULL, 0);
        cout << "goodbye" << std::endl;
    }
}

重定向

在子进程中关闭标准输出描述符STDOUT_FILENO后,子进程的输出将不再打印到屏幕,1号描述符成为空闲。此时若执行打开文件的操作,操作系统会从0开始寻找最小的可用描述符,选中的就是刚空出来的1号;之后所有向标准输出的打印,实际都写进了打开的文件里。

标准输入、标准输出、标准错误的文件描述符固定为0,1,2。

#include <cstdio>//printf()
#include <iostream>
#include <fcntl.h>//open()
#include <unistd.h>//fork(),close()
#include <sys/wait.h>//wait()

void test6()
{
    int rc = fork();
    if (rc == 0) {
        close(STDOUT_FILENO);
        open("./test",O_WRONLY | O_CREAT, 0644);
        printf("hello\n");
    }
    else {
        wait(NULL);
        std::cout << "goodbye" << std::endl;
    }
}

pipe管道

可以使用pipe管道实现子进程之间的通信。

pipe()在内核中开辟一块缓冲区,返回一对文件描述符:向写端写入的数据可以从读端读出。父子进程靠fork()继承这对描述符,从而建立起通信。

初始化管道后,创建第一个子进程对管道进行写入并退出,创建第二个子进程对管道读取并退出,实现子进程之间的通信。

#include <iostream>
#include <unistd.h>//pipe(),fork()
#include <sys/wait.h>//wait()

void test7()
{
    int pipefd[2];
    pipe(pipefd);
    int rc = fork();
    if (rc == 0) {
        close(pipefd[0]); //关闭读端
        write(pipefd[1], "hello", 5);
        close(pipefd[1]); //关闭写端
        exit(0);
    }
    wait(NULL);
    rc = fork();
    if (rc == 0) {
        close(pipefd[1]); //关闭写端
        char buf[8]{};
        read(pipefd[0], buf, 8);
        cout << buf << '\n';
        close(pipefd[0]); //关闭读端
        exit(0);
    }
    wait(NULL);
    cout << "goodbye" << std::endl;
}

如果借助重定向的技巧,甚至可以将第一个子进程的标准输出连接到第二个子进程的标准输入。

#include <cstdio>//printf(),fflush(),scanf()
#include <unistd.h>//pipe(),fork()
#include <sys/wait.h>//wait()

void test8()
{
    close(STDOUT_FILENO);
    close(STDIN_FILENO);
    int pipefd[2];
    pipe(pipefd);//也可以用dup2(pipefd[0],STDIN_FILENO)显式重定向
    int rc = fork();
    if (rc == 0) {
        close(pipefd[0]); //关闭读端
        printf("hello");
        fflush(stdout);//显式刷新缓冲区以让数据进入管道
        close(pipefd[1]); //关闭写端,也可省略这两行让exit自动刷新并关闭管道
        exit(0);
    }
    wait(NULL);
    close(pipefd[1]); //父进程关闭写端,第二个子进程读完数据后才能收到EOF
    rc = fork();
    if (rc == 0) {
        char buf[8]{};
        scanf("%s", buf);
        close(pipefd[0]); //关闭读端
        exit(0);
    }
    wait(NULL);
    close(pipefd[0]);
}

先关闭0和1再创建管道,pipe()会把读端和写端正好分配到这两个描述符上:第一个子进程的printf()写向管道,第二个子进程的scanf()从管道读,标准输出和标准输入就这样接在了管道两端。注意父进程必须在fork()第二个子进程之前关闭自己持有的写端,否则读端等不到EOF,scanf()会一直阻塞。

小结

fork()给出独立的内存,exec()在不改变pid的前提下换掉整个程序,wait()负责回收退出的子进程,文件描述符和它的偏移量则是父子之间仅有的共享状态。这些api单个看都只是简单的原语,组合起来却是现代各种软件,尤其是shell,的重要根基:一条管道命令的背后,就是两次fork()、一组描述符的连接和两次wait()。

一个最优的实践是,按fork()、安排描述符、exec()、wait()的次序组织多进程代码:重定向和管道的连接都放在exec()之前完成,父子之间除了文件描述符不依赖任何共享状态,并发写同一个文件时再补上同步。

代码已上传至Learncpp仓库(process_lab.cpp)