多进程模型与进程间通信: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。

注意:fork 之后,父子进程共享文件偏移量。如果父子进程同时写同一个文件,数据会乱掉。要么用 O_APPEND 标志,要么加锁。

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");
小技巧:信号量的初始值决定了并发度。初始值为 1 就是互斥锁,初始值为 N 就是允许 N 个进程同时访问。

嗯,这里要注意:信号量操作是系统调用,频繁加解锁会有性能开销。如果临界区代码很短,可以考虑用原子操作替代。

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 进程把日志发到队列里,一个专门的日志进程负责写入磁盘。解耦效果很好。

注意:消息队列有大小限制。如果生产速度大于消费速度,队列满了之后 mq_send 会阻塞或返回 EAGAIN。需要做好流控。

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 域套接字在内核内部直接拷贝数据,不经过网络层、传输层,没有校验和、没有路由查找、没有拥塞控制。说白了就是内存拷贝。

我建议在本地进程通信时优先考虑 Unix 域套接字。它比共享内存更易用,比消息队列更灵活,而且支持 select/epoll 做 IO 多路复用。我在做微服务网关时,各个模块之间就是用 Unix 域套接字通信的。

7. 知识体系总览

下面这张图总结了多进程模型和 IPC 的核心关系:

多进程模型与 IPC 知识体系 父进程(监听) fork() fork() 子进程 1 子进程 2 进程间通信(IPC)方式 共享内存 信号量 消息队列 Unix 域套接字 大量数据共享 同步与互斥 异步消息传递 本地双向通信

从图里可以看得很清楚:父进程 fork 出多个子进程,子进程之间通过共享内存、信号量、消息队列、Unix 域套接字等方式通信。每种方式各有优劣,选型时要根据场景来定。

8. 我的选型建议

做了这么多年后端,我总结了几条经验:

  • 需要传大量数据(比如视频帧、大日志)→ 共享内存 + 信号量
  • 需要双向通信(比如请求-响应模式)→ Unix 域套接字
  • 需要异步解耦(比如生产者-消费者)→ 消息队列
  • 只需要同步(比如保护临界区)→ 信号量或互斥锁
  • 父子进程简单通信(比如传一个状态)→ 管道就够了
避坑指南:我曾经在项目里用共享内存传结构体,结果结构体里有指针,子进程读出来直接段错误。记住:共享内存里不能放指针,只能放偏移量或数组。

嗯,多进程模型和 IPC 就讲到这里。这些知识是网络服务器的基础,后面讲 epoll 和线程池时还会用到。你先把 fork 和几种 IPC 方式练熟,后面就顺了。


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