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 信号,结果子进程变成僵尸进程,把系统资源吃光了。
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;
}
_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 进程管理的完整抽象层设计
好了,我们把前面讲的东西串起来,画一张图看看整体架构:
这张图很清楚地展示了我们的抽象层设计。上层代码只跟统一接口打交道,平台差异被完全隔离在中间层。我在实际项目中,这个架构让我们的代码在 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 模拟,因为更可控。
好了,这一章的内容就到这里。进程管理这块东西多,但只要你把抽象层搭好了,后面写代码会非常舒服。下一章我们聊聊线程管理,那又是另一番天地了。
公众号:蓝海资料掘金营,微信deep3321