16、进程管理:fork/exec 与 CreateProcess 的抽象、进程间通信 (pipe, socketpair)

进程管理,说白了就是操作系统怎么创建新进程、怎么让进程之间说话。你想想看,在 Linux 下我们写 fork() 写得飞起,到了 Windows 上突然变成 CreateProcess(),这俩玩意儿长得完全不一样。我当年第一次做跨平台项目时,就被这个坑得不轻——Linux 上好好的代码,移植到 Windows 直接编译不过。

嗯,今天我们就来聊聊怎么把这两套机制抽象成统一的接口,以及进程间通信的两种经典方式:pipe 和 socketpair。

16.1 fork/exec 与 CreateProcess 的本质差异

先说说 Linux 这边的玩法。Linux 创建进程分两步走:fork() 先复制一份当前进程,然后 exec() 把新进程的代码替换成你要执行的程序。这设计其实很优雅——你可以在 fork 之后、exec 之前做一些骚操作,比如重定向文件描述符、设置环境变量。

Windows 那边就不一样了。它直接一个 CreateProcess() 搞定所有事情,参数多得吓人。你传一个 STARTUPINFO 结构体进去,里面塞了各种标志位和句柄。说白了,Windows 把 fork 和 exec 合并成了一个原子操作。

我个人习惯把这两套东西抽象成下面这个接口:

// 跨平台进程创建抽象
typedef struct {
    const char* executable;   // 可执行文件路径
    char** argv;              // 命令行参数
    char** envp;              // 环境变量(可选)
    int stdin_fd;             // 标准输入重定向
    int stdout_fd;            // 标准输出重定向
    int stderr_fd;            // 标准错误重定向
} ProcessCreateInfo;

typedef struct {
    int pid;                  // 进程ID(-1表示失败)
    int stdin_fd;             // 写入子进程标准输入的管道端
    int stdout_fd;            // 读取子进程标准输出的管道端
    int stderr_fd;            // 读取子进程标准错误的管道端
} ProcessHandle;

// 统一的创建接口
ProcessHandle process_create(ProcessCreateInfo* info);

这个抽象层的好处是,上层代码完全不用关心底层是 fork 还是 CreateProcess。我在项目中用过这个模式,移植性非常好。

16.2 抽象层的实现要点

实现这个抽象层时,有几个坑你得注意。我曾经在 Linux 上忘记处理 SIGCHLD 信号,结果子进程变成僵尸进程,把系统资源吃光了。

注意:在 Linux 下,fork 之后必须用 waitpid() 或设置 SIGCHLD 处理函数来回收子进程。Windows 下则要用 WaitForSingleObject() 等待进程句柄。

Linux 端的实现大致是这样:

// Linux 实现
ProcessHandle process_create(ProcessCreateInfo* info) {
    ProcessHandle handle = {-1, -1, -1, -1};
    
    // 创建管道用于重定向
    int stdin_pipe[2], stdout_pipe[2], stderr_pipe[2];
    pipe(stdin_pipe);
    pipe(stdout_pipe);
    pipe(stderr_pipe);
    
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程
        dup2(stdin_pipe[0], STDIN_FILENO);
        dup2(stdout_pipe[1], STDOUT_FILENO);
        dup2(stderr_pipe[1], STDERR_FILENO);
        
        // 关闭不需要的管道端
        close(stdin_pipe[1]);
        close(stdout_pipe[0]);
        close(stderr_pipe[0]);
        
        execvp(info->executable, info->argv);
        _exit(127);  // exec 失败
    }
    
    // 父进程
    handle.pid = pid;
    handle.stdin_fd = stdin_pipe[1];   // 写入端
    handle.stdout_fd = stdout_pipe[0]; // 读取端
    handle.stderr_fd = stderr_pipe[0]; // 读取端
    
    // 关闭子进程持有的管道端
    close(stdin_pipe[0]);
    close(stdout_pipe[1]);
    close(stderr_pipe[1]);
    
    return handle;
}

Windows 端的实现则完全不同:

// Windows 实现
ProcessHandle process_create(ProcessCreateInfo* info) {
    ProcessHandle handle = {-1, -1, -1, -1};
    
    SECURITY_ATTRIBUTES sa = {sizeof(sa), NULL, TRUE};
    HANDLE hStdInRd, hStdInWr, hStdOutRd, hStdOutWr;
    
    CreatePipe(&hStdInRd, &hStdInWr, &sa, 0);
    CreatePipe(&hStdOutRd, &hStdOutWr, &sa, 0);
    
    STARTUPINFO si = {0};
    si.cb = sizeof(si);
    si.hStdInput = hStdInRd;
    si.hStdOutput = hStdOutWr;
    si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
    si.dwFlags |= STARTF_USESTDHANDLES;
    
    PROCESS_INFORMATION pi;
    CreateProcess(NULL, info->executable, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
    
    handle.pid = pi.dwProcessId;
    handle.stdin_fd = _open_osfhandle((intptr_t)hStdInWr, _O_WRONLY);
    handle.stdout_fd = _open_osfhandle((intptr_t)hStdOutRd, _O_RDONLY);
    
    CloseHandle(hStdInRd);
    CloseHandle(hStdOutWr);
    CloseHandle(pi.hThread);
    
    return handle;
}
小技巧:Windows 下用 _open_osfhandle() 可以把 Win32 句柄转换成 C 运行时文件描述符,这样上层代码就可以统一用 read()/write() 来操作了。

16.3 进程间通信:pipe 与 socketpair

进程创建好了,接下来就是怎么让它们说话。最基础的方式就是管道(pipe)。

管道说白了就是一块内核缓冲区,一头写、一头读。Linux 的 pipe() 返回两个文件描述符,一个只读、一个只写。Windows 的 CreatePipe() 也是类似,只不过返回的是 HANDLE。

但管道有个限制——它只能用于有亲缘关系的进程(父子进程或兄弟进程)。如果你想让任意两个进程通信,那就得用命名管道或者 socket。

我个人更推荐用 socketpair()。它创建一对互相连接的 socket,就像双向管道一样。而且 socketpair 在 Linux 和 Windows 上都有对应的实现(Windows 上用 WSASocketPair() 或者自己用 TCP socket 模拟)。

// 跨平台 socketpair 抽象
int cross_platform_socketpair(int domain, int type, int protocol, int sv[2]) {
#ifdef _WIN32
    // Windows 没有 socketpair,用 TCP 模拟
    // 这里简化处理,实际需要监听本地端口
    return -1;  // 需要自行实现
#else
    return socketpair(domain, type, protocol, sv);
#endif
}

为什么我偏爱 socketpair?因为它支持全双工通信,两边都可以读写。而 pipe 是半双工的,你得创建两个管道才能实现双向通信。你想想看,用 socketpair 写代码多清爽:

int sv[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);

pid_t pid = fork();
if (pid == 0) {
    // 子进程
    close(sv[0]);
    const char* msg = "Hello from child!";
    write(sv[1], msg, strlen(msg) + 1);
    close(sv[1]);
} else {
    // 父进程
    close(sv[1]);
    char buf[256];
    read(sv[0], buf, sizeof(buf));
    printf("Received: %s\n", buf);
    close(sv[0]);
}

16.4 进程管理的完整抽象层设计

好了,我们把前面讲的东西串起来,画一张图看看整体架构:

跨平台进程管理抽象层架构 统一进程管理接口 process_create() / process_wait() / process_kill() 平台抽象层 进程创建抽象 | 管道抽象 | socketpair抽象 | 信号/事件抽象 Linux 实现 fork() + execvp() pipe() / socketpair(AF_UNIX) waitpid() / SIGCHLD Windows 实现 CreateProcess() CreatePipe() / WSASocketPair() WaitForSingleObject() 操作系统内核 Linux 内核 / Windows NT 内核

这张图很清楚地展示了我们的抽象层设计。上层代码只跟统一接口打交道,平台差异被完全隔离在中间层。我在实际项目中,这个架构让我们的代码在 Linux、macOS、Windows 三套系统上都能跑,而且新增平台只需要实现中间层的几个函数就行。

16.5 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 文件描述符泄漏:fork 之后,父子进程都会持有管道两端。记得在子进程中关闭不需要的一端,父进程中也关闭不需要的一端。我曾经漏关了一个读端,结果子进程一直阻塞在 write 上。
  • 缓冲区大小:管道的内核缓冲区默认是 65536 字节(Linux 上)。如果你一次写入超过这个量,write 会阻塞。我建议用非阻塞 I/O 或者 select/poll 来管理。
  • Windows 句柄继承:CreateProcess 时记得把 bInheritHandles 设为 TRUE,否则子进程拿不到你创建的管道句柄。这个坑我花了一整天排查。
  • socketpair 的跨平台:Windows 没有原生的 socketpair,你得用 WSASocketPair()(需要 WinSock2)或者自己用 TCP 监听一个本地端口来模拟。我个人倾向于用 TCP 模拟,因为更可控。
核心要点:进程管理的跨平台抽象,关键在于把 fork/exec 和 CreateProcess 的差异封装起来,同时统一 pipe 和 socketpair 的接口。记住,抽象层不是为了消灭平台差异,而是为了隔离它们。

好了,这一章的内容就到这里。进程管理这块东西多,但只要你把抽象层搭好了,后面写代码会非常舒服。下一章我们聊聊线程管理,那又是另一番天地了。


公众号:蓝海资料掘金营,微信deep3321