Linux进程间通信总结
每个进程都有自己独立的虚拟地址空间,一个进程不能直接访问另一个进程的内存。但在很多应用场景中,多个进程需要协同工作,交换数据。IPC 机制就是操作系统提供的一套让不同进程能够互相通信、同步操作的机制。
进程间通信方式主要有:
- 匿名管道
- 命名管道
- 消息队列
- 共享内存
- 信号量
- 套接字
匿名管道
管道是内核中的一块缓冲区,它有一个读取端和一个写入端。一个进程从写入端写入数据,另一个进程从读取端读出数据。数据以先进先出(FIFO)的方式流动。
int pipe(int pipefd[2]);
特点:
- 半双工: 数据只能在一个方向上流动。如果需要双向通信,需要创建两个管道。
- 亲缘关系: 匿名管道通常用于父子进程或者兄弟进程之间。因为子进程会继承父进程打开的文件描述符,从而共享这个管道。
- 生命周期: 管道的生命周期随进程,当所有使用它的进程都关闭了对它的引用(文件描述符)后,管道就会被销毁。
- 字节流: 管道传输的是无格式的字节流,没有消息边界的概念。
int main() {
int pipe_fd[2]; // pipe_fd[0] is for reading, pipe_fd[1] is for writing
pid_t pid;
char buffer[128];
char *message = "Hello from parent!";
// 1. 创建管道
if (pipe(pipe_fd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
// 2. 创建子进程
pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid > 0) { // 父进程
// 关闭不使用的读取端
close(pipe_fd[0]);
// 3. 向管道写入数据
printf("Parent (PID: %d) is sending message: '%s'\n", getpid(), message);
write(pipe_fd[1], message, strlen(message));
// 关闭写入端,这样子进程的read才会返回0(EOF)
close(pipe_fd[1]);
// 等待子进程结束
wait(NULL);
printf("Parent process finished.\n");
} else { // 子进程
// 关闭不使用的写入端
close(pipe_fd[1]);
// 4. 从管道读取数据
int n = read(pipe_fd[0], buffer, sizeof(buffer));
buffer[n] = '\0'; // Add null terminator
printf("Child (PID: %d) received message: '%s'\n", getpid(), buffer);
// 关闭读取端
close(pipe_fd[0]);
exit(EXIT_SUCCESS);
}
return 0;
}
命名管道
命名管道,也叫FIFO,克服了匿名管道只能用于亲缘进程的限制。
FIFO 是一种特殊的文件类型,它在文件系统中以一个路径名存在。任何知道这个路径名的进程都可以打开它进行读写,从而实现通信。
int mkfifo(const char *pathname, mode_t mode);
特点:
- 无关进程: 可以在完全不相关的进程之间进行通信。
- 文件系统实体: 作为一个文件存在,可以使用
ls -l查看,其文件类型为p。 - 遵循FIFO规则: 仍然是先进先出的字节流通信。
- 阻塞行为: 默认情况下,
openFIFO 用来读取的进程会阻塞,直到有另一个进程open同一个 FIFO 用来写入。
当 open 一个 FIFO 时,是否设置非阻塞标志(O_NONBLOCK)的区别:
- 若没有指定
O_NONBLOCK(默认),只读open要阻塞到某个其他进程为写而打开此 FIFO。类似的,只写open要阻塞到某个其他进程为读而打开它。 - 若指定了
O_NONBLOCK,则只读open立即返回。而只写open将出错返回-1, 如果没有进程已经为读而打开该·FIFO·,其errno置ENXIO。
#define FIFO_PATH "/tmp/myfifo"
// write.c
int main() {
// 1. 创建命名管道
mkfifo(FIFO_PATH, 0666);
// 2. 打开FIFO进行写入
int fd = open(FIFO_PATH, O_WRONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
char *message = "Hello from writer!";
printf("Writer: Sending message: '%s'\n", message);
// 3. 写入数据
write(fd, message, strlen(message));
// 4. 关闭FIFO
close(fd);
printf("Writer: Finished.\n");
}
// reader.c
int main() {
char buffer[128];
// 1. 打开FIFO进行读取 (会阻塞直到有写入者打开)
int fd = open(FIFO_PATH, O_RDONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 2. 读取数据
int n = read(fd, buffer, sizeof(buffer));
buffer[n] = '\0';
printf("Reader: Received message: '%s'\n", buffer);
// 3. 关闭并删除FIFO
close(fd);
unlink(FIFO_PATH); // 删除FIFO文件
printf("Reader: Finished and cleaned up FIFO.\n");
}
消息队列
消息队列是内核中维护的一个消息链表。它克服了管道无格式字节流的缺点,允许进程发送和接收带有类型和特定格式的消息。
每个消息队列由一个唯一的标识符(key)来标识。进程通过这个 key 来获取队列ID,然后向队列中发送(msgsnd)或接收(msgrcv)消息。
// 创建或打开消息队列:成功返回队列ID,失败返回-1
int msgget(key_t key, int flag);
// 添加消息:成功返回0,失败返回-1
int msgsnd(int msqid, const void *ptr, size_t size, int flag);
// 读取消息:成功返回消息数据的长度,失败返回-1
int msgrcv(int msqid, void *ptr, size_t size, long type,int flag);
// 控制消息队列:成功返回0,失败返回-1
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
特点:
- 消息结构: 存放在队列中的是带有特定结构和类型的消息,而不仅仅是字节流。
- 类型筛选: 接收方可以按消息类型选择性地接收消息,而不是必须按先进先出的顺序。
- 独立生命周期: 生命周期随内核,即使所有通信进程都退出了,消息队列仍然存在,直到被显式删除或系统重启。
// 定义消息结构
struct msg_buffer {
long msg_type;
char msg_text[100];
};
// write.c
int main() {
key_t key;
int msgid;
struct msg_buffer message;
// 1. 生成唯一的key
key = ftok("progfile", 65);
// 2. 获取消息队列ID (如果不存在则创建)
msgid = msgget(key, 0666 | IPC_CREAT);
if (msgid == -1) {
perror("msgget");
exit(EXIT_FAILURE);
}
// 3. 准备要发送的消息
message.msg_type = 1; // 消息类型必须大于0
strcpy(message.msg_text, "Hello from Message Queue!");
printf("Sender: Sending message...\n");
// 4. 发送消息
if (msgsnd(msgid, &message, sizeof(message.msg_text), 0) == -1) {
perror("msgsnd");
exit(EXIT_FAILURE);
}
printf("Sender: Message sent.\n");
return 0;
}
// read.c
int main() {
key_t key;
int msgid;
struct msg_buffer message;
key = ftok("progfile", 65);
msgid = msgget(key, 0666 | IPC_CREAT);
printf("Receiver: Waiting for message...\n");
// 1. 接收消息 (类型为1)
if (msgrcv(msgid, &message, sizeof(message.msg_text), 1, 0) == -1) {
perror("msgrcv");
exit(EXIT_FAILURE);
}
printf("Receiver: Received message: '%s'\n", message.msg_text);
// 2. 删除消息队列
if (msgctl(msgid, IPC_RMID, NULL) == -1) {
perror("msgctl");
exit(EXIT_FAILURE);
}
printf("Receiver: Message queue removed.\n");
return 0;
}
共享内存
共享内存是最高效的IPC方式,因为它允许多个进程直接读写同一块物理内存,避免了在内核和用户空间之间复制数据的开销。
系统在内存中创建一段特殊的区域,然后多个进程可以将这个区域映射到自己的虚拟地址空间中。之后,一个进程写入的数据可以被另一个进程立即看到,就像操作自己的本地内存一样。
// 创建或获取一个共享内存:成功返回共享内存ID,失败返回-1
int shmget(key_t key, size_t size, int flag);
// 连接共享内存到当前进程的地址空间:成功返回指向共享内存的指针,失败返回-1
void *shmat(int shm_id, const void *addr, int flag);
// 断开与共享内存的连接:成功返回0,失败返回-1
int shmdt(void *addr);
// 控制共享内存的相关信息:成功返回0,失败返回-1
int shmctl(int shm_id, int cmd, struct shmid_ds *buf);
特点:
- 速度最快: 数据不需要在进程之间进行任何拷贝,是速度最快的
IPC。 - 无同步机制: 共享内存本身不提供任何同步机制。必须自己处理并发访问的问题,通常结合信号量**来保证数据的一致性。
- 生命周期: 同消息队列一样,生命周期随内核,需要显式删除。
// write.c
int main() {
key_t key = ftok("shmfile", 65);
// 1. 创建或获取共享内存段
int shmid = shmget(key, 1024, 0666 | IPC_CREAT);
if (shmid == -1) {
perror("shmget");
exit(1);
}
// 2. 将共享内存附加到进程的地址空间
char *str = (char*) shmat(shmid, (void*)0, 0);
if (str == (char*)-1) {
perror("shmat");
exit(1);
}
// 3. 写入数据到共享内存
printf("Writer: Writing to shared memory: 'Hello from Shared Memory!'\n");
strcpy(str, "Hello from Shared Memory!");
// 4. 分离共享内存
shmdt(str);
printf("Writer: Finished.\n");
return 0;
}
// read.c
int main() {
key_t key = ftok("shmfile", 65);
// 1. 获取共享内存段ID
int shmid = shmget(key, 1024, 0666 | IPC_CREAT);
// 2. 附加共享内存
char *str = (char*) shmat(shmid, (void*)0, 0);
// 3. 读取数据
printf("Reader: Reading from shared memory: '%s'\n", str);
// 4. 分离共享内存
shmdt(str);
// 5. 删除共享内存段
shmctl(shmid, IPC_RMID, NULL);
printf("Reader: Shared memory segment removed.\n");
return 0;
}
信号量
信号量本质上是一个计数器,它不用于传输数据,而是用于控制多个进程对共享资源的访问,即用于进程同步。
信号量的值表示可用资源的数量。
- P操作 (等待
wait): 尝试获取资源。如果信号量的值大于0,则将其减1并继续执行。如果值为0,则进程阻塞,直到有其他进程释放资源。 - V操作 (信号
signal): 释放资源。将信号量的值加1。如果有进程因为等待该资源而阻塞,则唤醒其中一个。
主要用于解决进程/线程间的同步与互斥问题,常与共享内存结合使用。
struct sembuf {
short sem_num; // 信号量组中对应的序号,0~sem_nums-1
short sem_op; // 信号量值在一次操作中的改变量
short sem_flg; // IPC_NOWAIT, SEM_UNDO
};
// 创建或获取一个信号量组:若成功返回信号量集ID,失败返回-1
int semget(key_t key, int num_sems, int sem_flags);
// 对信号量组进行操作,改变信号量的值:成功返回0,失败返回-1
int semop(int semid, struct sembuf semoparray[], size_t numops);
// 控制信号量的相关信息
int semctl(int semid, int sem_num, int cmd, ...);
套接字
套接字是目前最通用、最强大的IPC机制。
通信的进程创建各自的套接字,其中一个作为服务器,另一个作为客户端。连接建立后,就可以像读写文件一样进行双向通信。
特点:
- 通用性: 既能用于本地
IPC,也能用于网络IPC。 - 双向通信: 一个连接建立后,客户端和服务器都可以自由地收发数据。
- 多种协议: 支持
TCP和UDP等多种协议。 - UNIX域套接字: 当用于本地通信时,使用
AF_UNIX地址族,它通过文件系统中的一个特殊文件进行通信。
#define SOCKET_PATH "/tmp/my_socket"
// write
int main() {
int server_fd, client_fd;
struct sockaddr_un server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[256];
// 1. 创建 UNIX 域套接字
server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
// 2. 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sun_family = AF_UNIX;
strncpy(server_addr.sun_path, SOCKET_PATH, sizeof(server_addr.sun_path) - 1);
unlink(SOCKET_PATH); // 如果已存在,先删除
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
// 3. 监听连接
listen(server_fd, 5);
printf("Server is listening...\n");
// 4. 接受客户端连接
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
printf("Server accepted connection.\n");
// 5. 读取数据
read(client_fd, buffer, sizeof(buffer));
printf("Server received: %s\n", buffer);
// 6. 发送数据
write(client_fd, "Hello from server!", 18);
close(client_fd);
close(server_fd);
unlink(SOCKET_PATH);
return 0;
}
// read
int main() {
int client_fd;
struct sockaddr_un server_addr;
char buffer[256];
// 1. 创建套接字
client_fd = socket(AF_UNIX, SOCK_STREAM, 0);
// 2. 连接服务器
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sun_family = AF_UNIX;
strncpy(server_addr.sun_path, SOCKET_PATH, sizeof(server_addr.sun_path) - 1);
connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
// 3. 发送数据
write(client_fd, "Hello from client!", 18);
// 4. 接收数据
read(client_fd, buffer, sizeof(buffer));
printf("Client received: %s\n", buffer);
close(client_fd);
return 0;
}
对比
性能对比
| 性能维度 | 管道 | 消息队列 | 共享内存 | 信号量 | 套接字 |
|---|---|---|---|---|---|
| 传输速率 | 中等。 受限于缓冲区大小,涉及两次数据拷贝(用户区→内核区→用户区)。 |
较慢。 与管道一样有两次数据拷贝,且内核管理更复杂,开销更大。 |
最快 (🥇)。 数据直接在内存中读写,无数据拷贝,速率接近内存读写速度。 |
不适用。 用于同步和互斥,不传输数据。但其自身操作非常轻量。 |
极快。 本地通信(UNIX域)非常快,但因仍涉及数据拷贝,速率慢于共享内存。网络通信速率受网络条件限制。 |
| 延迟 | 较低。 延迟主要受数据量和内核调度影响。 |
较高。 延迟受消息大小、队列长度和内核调度影响,是延迟最高的IPC之一。 |
最低 (🥇)。 几乎没有延迟,因为它避免了所有数据复制和内核中转。 |
极低。 操作延迟非常低,尤其是在没有锁竞争的情况下。 |
较低。 本地通信延迟很低。网络通信延迟取决于网络状况。 |
数据量对比
| 数据量维度 | 管道 | 消息队列 | 共享内存 | 信号量 | 套接字 |
|---|---|---|---|---|---|
| 数据种类 | 字节流。 无消息边界,适用于文本和二进制流。 |
结构化消息。 可传递带类型的、有明确边界的消息(如结构体)。 |
任意类型。 可以共享任何数据类型,从简单变量到复杂的数据结构。 |
整数计数。 传递的是一个信号或状态(计数值),不传递业务数据。 |
字节流。 主要传递字节流,复杂结构需应用层进行序列化/反序列化。 |
| 数据上限 | 小。 受限于管道缓冲区大小(通常为64KB)。 |
中等。 受系统对队列总大小和消息最大尺寸的限制。 |
极大。 受限于可用物理内存和系统配置,是容量最大的IPC方式。 |
无上限。 不传输数据。 |
大。 上限主要受网络带宽、发送/接收缓冲区大小和系统资源限制。 |
| 能否并发 | 有限支持。 通常只支持一个写进程和一个读进程。 |
支持。 多个进程可以并发读写,队列操作本身是原子的。 |
支持 (⚠️)。 天然支持并发访问,但必须使用信号量等外部同步机制,否则会产生严重数据竞争。 |
核心功能。 其本身就是用于管理并发、实现同步和互斥的工具。 |
支持。 服务器可并发处理多个客户端连接和数据传输。 |
| 是否阻塞 | 是。 读空管道或写满管道时,默认会阻塞。 |
是。 读空队列或写满队列时,默认会阻塞。 |
否 (⚠️)。 数据访问本身不会阻塞。但用于同步的信号量等操作会阻塞。 |
是。 当资源不可用时(P操作),进程会阻塞等待。 |
是。 默认的读写操作在数据未准备好或连接未建立时会阻塞。 |
稳定性对比
| 稳定性维度 | 管道 | 消息队列 | 共享内存 | 信号量 | 套接字 |
|---|---|---|---|---|---|
| 数据丢失风险 | 低。 写满时,写入操作会阻塞,不会丢数据。只有在非阻塞模式下或进程异常退出时可能丢失。 |
中等。 当队列满时,若未做错误处理(如重试),新消息可能会发送失败而被丢弃。 |
中等 (⚠️)。 机制本身不丢数据,但无同步的并发写入会导致数据覆盖或不一致,这是一种逻辑上的数据丢失。 |
无风险。 不传输数据,不存在数据丢失问题。 |
低 (流式) / 高 (数据报)。 流式套接字(如TCP)有可靠性保证,连接中断前不丢数据。数据报套接字(如UDP)无此保证,可能丢包。 |
| 中断处理 | 需要。write操作可能被信号中断,需检查返回值并重试。 |
需要。msgsnd操作可能被信号中断,需检查错误码(如EINTR)。 |
不适用。 数据传输不涉及特定系统调用,但同步操作(如 semop)可能被中断。 |
需要。semop操作可能被信号中断,需检查错误码。 |
需要。send/recv等操作可能被信号中断,需检查返回值并重试。 |
| 能否续传 | ❌ 不支持。 中断后需重新发送整个数据块。 |
❌ 不支持。 中断后需重新发送整个消息。 |
不适用。 无“传输”概念,它只是内存状态的共享。 |
不适用。 无数据传输。 |
❌ 机制本身不支持。 TCP协议负责单个数据包的重传,但若连接中断,应用需自行实现断点续传逻辑。 |