多进程模型与进程间通信:fork与进程模型、共享内存、信号量、消息队列、Unix域套接字
多进程模型,说白了就是让程序“分身”。每个分身跑独立的地址空间,互不干扰。这在网络服务器里特别常见——你想想看,一个父进程负责监听,来一个连接就 fork 一个子进程去处理。我早年做网关项目时,就是靠这套模型扛住了每秒几万的并发连接。
1. fork 系统调用与进程模型
fork 是 Unix/Linux 下创建子进程的唯一方式。调用一次,返回两次——在父进程里返回子进程的 PID,在子进程里返回 0。这个设计很巧妙,但也很容易让人迷糊。
我习惯把 fork 后的代码写成这样:
pid_t pid = fork();
if (pid < 0) {
// fork 失败,通常是因为内存不够或进程数超限
perror("fork error");
exit(1);
} else if (pid == 0) {
// 子进程
do_child_work();
exit(0);
} else {
// 父进程
waitpid(pid, NULL, 0);
}
这里有个坑:子进程会继承父进程的文件描述符、信号处理函数、环境变量等。如果你不关掉不需要的 fd,子进程退出时父进程的 socket 可能还开着,导致资源泄漏。我曾经在线上排查过一个“连接数缓慢增长”的问题,最后发现就是子进程没关监听 socket 的 fd。
2. 进程间通信(IPC)概览
进程间通信,说白了就是让不同进程交换数据。Linux 下 IPC 方式很多,我按使用频率排个序:
| IPC 方式 | 适用场景 | 性能 | 我的评价 |
|---|---|---|---|
| 共享内存 | 大量数据交换 | 最高 | 首选,但要配合同步 |
| Unix 域套接字 | 本地进程通信 | 高 | 比 TCP loopback 快 30% |
| 消息队列 | 异步消息传递 | 中等 | 适合解耦 |
| 信号量 | 同步/互斥 | 高 | 不能传数据 |
| 管道 | 父子进程简单通信 | 低 | 功能有限 |
你想想看,如果两个进程要频繁交换几百 KB 的数据,用管道或消息队列就太慢了。共享内存才是正解。
3. 共享内存(Shared Memory)
共享内存是最高效的 IPC 方式。多个进程映射同一块物理内存,数据写入后立即可见。没有内核态到用户态的数据拷贝,所以快。
我常用的 API 是 POSIX 共享内存:
// 创建或打开共享内存
int shm_fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(shm_fd, 4096); // 设置大小
// 映射到进程地址空间
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, shm_fd, 0);
// 使用共享内存
sprintf((char*)ptr, "Hello from process %d", getpid());
// 解除映射
munmap(ptr, 4096);
close(shm_fd);
我在项目中遇到过一个问题:两个 worker 进程通过共享内存交换请求数据,结果偶尔出现数据错乱。排查了半天,发现是忘记加锁了。加上 POSIX 信号量后,问题消失。
4. 信号量(Semaphore)
信号量本质上是一个计数器,用来控制多个进程对共享资源的访问。P 操作(wait)减一,V 操作(post)加一。如果计数器为 0,P 操作会阻塞。
我习惯用 POSIX 命名信号量:
// 创建信号量,初始值为 1
sem_t *sem = sem_open("/my_sem", O_CREAT, 0666, 1);
// P 操作
sem_wait(sem);
// 临界区代码
// ... 操作共享内存 ...
// V 操作
sem_post(sem);
// 清理
sem_close(sem);
sem_unlink("/my_sem");
嗯,这里要注意:信号量操作是系统调用,频繁加解锁会有性能开销。如果临界区代码很短,可以考虑用原子操作替代。
5. 消息队列(Message Queue)
消息队列适合异步通信。一个进程往里写消息,另一个进程从里面读。消息有类型,可以按类型读取,实现优先级。
POSIX 消息队列的用法:
// 创建消息队列
struct mq_attr attr;
attr.mq_maxmsg = 10; // 最大消息数
attr.mq_msgsize = 1024; // 每条消息最大大小
mqd_t mq = mq_open("/my_queue", O_CREAT | O_RDWR, 0666, &attr);
// 发送消息
char buf[] = "hello";
mq_send(mq, buf, strlen(buf) + 1, 0); // 优先级为 0
// 接收消息
char recv_buf[1024];
unsigned int prio;
mq_receive(mq, recv_buf, 1024, &prio);
// 清理
mq_close(mq);
mq_unlink("/my_queue");
消息队列有个好处:发送方和接收方不需要同时在线。消息会暂存在内核缓冲区里。我曾经用消息队列做日志收集,各个 worker 进程把日志发到队列里,一个专门的日志进程负责写入磁盘。解耦效果很好。
6. Unix 域套接字(Unix Domain Socket)
Unix 域套接字是本地进程通信的利器。它和 TCP socket 的 API 几乎一样,但不需要经过网络协议栈,所以更快。实测比 TCP loopback 快 30%-50%。
服务端代码示例:
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/my_socket.sock");
unlink(addr.sun_path); // 删除旧文件
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 5);
int client = accept(sock, NULL, NULL);
char buf[1024];
read(client, buf, sizeof(buf));
write(client, "ok", 2);
close(client);
close(sock);
unlink(addr.sun_path);
客户端代码:
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/my_socket.sock");
connect(sock, (struct sockaddr*)&addr, sizeof(addr));
write(sock, "hello", 6);
char buf[1024];
read(sock, buf, sizeof(buf));
close(sock);
我建议在本地进程通信时优先考虑 Unix 域套接字。它比共享内存更易用,比消息队列更灵活,而且支持 select/epoll 做 IO 多路复用。我在做微服务网关时,各个模块之间就是用 Unix 域套接字通信的。
7. 知识体系总览
下面这张图总结了多进程模型和 IPC 的核心关系:
从图里可以看得很清楚:父进程 fork 出多个子进程,子进程之间通过共享内存、信号量、消息队列、Unix 域套接字等方式通信。每种方式各有优劣,选型时要根据场景来定。
8. 我的选型建议
做了这么多年后端,我总结了几条经验:
- 需要传大量数据(比如视频帧、大日志)→ 共享内存 + 信号量
- 需要双向通信(比如请求-响应模式)→ Unix 域套接字
- 需要异步解耦(比如生产者-消费者)→ 消息队列
- 只需要同步(比如保护临界区)→ 信号量或互斥锁
- 父子进程简单通信(比如传一个状态)→ 管道就够了
嗯,多进程模型和 IPC 就讲到这里。这些知识是网络服务器的基础,后面讲 epoll 和线程池时还会用到。你先把 fork 和几种 IPC 方式练熟,后面就顺了。
公众号:蓝海资料掘金营,微信 deep3321