概述
进程间通信(IPC,Inter-Process Communication)是指在不同进程之间传递数据和信息的机制。Linux 提供了多种 IPC 方式,包括管道、信号、信号量、消息队列、共享内存和套接字等。
方式
一、管道(Pipe)
管道是 Linux 中最基础的进程间通信方式,通过内核缓冲区实现数据传输,分为无名管道和有名管道,适用于不同场景的进程通信。
1. 无名管道(PIPE)
核心特点
- 亲缘关系限制:仅支持父子 / 兄弟等有亲缘关系的进程通信(通过 fork 继承文件描述符)。
- 半双工通信:数据单向流动,固定
fd[0]
为读端,fd[1]
为写端。 - 内存级文件:无实际磁盘文件,数据存在于内核缓冲区,进程退出后数据消失。
关键函数:pipe
#include <unistd.h>
int pipe(int fd[2]); // fd[0]=读端,fd[1]=写端,成功返回 0,失败返回 -1
通信步骤(以父子进程为例)
步骤 1:创建管道
int fd[2];
if (pipe(fd) == -1) {
perror("pipe create failed");
exit(1);
}
- 作用:在内核中创建缓冲区,返回两个文件描述符。
步骤 2:fork 生成子进程
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
exit(1);
}
- 目的:通过子进程继承父进程的管道文件描述符。
步骤 3:父子进程分工读写
- 父进程(写端):
if (pid > 0) { close(fd[0]); // 关闭读端,仅用写端 const char *msg = "Hello from parent!"; write(fd[1], msg, strlen(msg)); // 向管道写数据 close(fd[1]); // 写完关闭写端 wait(NULL); // 等待子进程结束 }
- 子进程(读端):
else { close(fd[1]); // 关闭写端,仅用读端 char buf[1024] = {0}; ssize_t n = read(fd[0], buf, sizeof(buf)); // 从管道读数据 if (n > 0) { printf("Child read: %s\n", buf); } close(fd[0]); }
- 关键点:读写端分离,避免双向干扰。
步骤 4:处理关闭顺序(重要!)
- 先关写端:读端
read
返回 0(管道空且关闭)。 - 先关读端:写端
write
会触发SIGPIPE
信号(需用signal
处理或忽略)。
// 示例:处理 SIGPIPE 信号
signal(SIGPIPE, SIG_IGN); // 忽略该信号,避免进程终止
完整示例(父子进程通信)
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
void handle_pipe_signal(int sig) {
printf("Caught SIGPIPE (signal %d)\n", sig);
}
int main() {
int fd[2];
signal(SIGPIPE, handle_pipe_signal); // 注册信号处理函数
if (pipe(fd) == -1) {
perror("pipe");
exit(1);
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(1);
} else if (pid == 0) {
// 子进程:读端
close(fd[1]);
char buf[1024];
ssize_t n = read(fd[0], buf, sizeof(buf));
if (n > 0) {
printf("Child received: %s\n", buf);
}
close(fd[0]);
} else {
// 父进程:写端
close(fd[0]);
const char *msg = "Hello, child!";
write(fd[1], msg, strlen(msg));
close(fd[1]); // 关闭写端后,子进程读端会收到 EOF
}
return 0;
}
2. 有名管道(FIFO)
核心特点
- 无亲缘限制:通过文件系统路径名(如
/tmp/myfifo
)实现任意进程通信。 - 持久化存储:管道以文件形式存在(类型为
p
),可通过ls -l
查看。 - 阻塞机制:读 / 写端未打开时,
open
会阻塞(除非用O_NONBLOCK
)。
创建方式
方式 1:命令行创建(mkfifo
)
mkfifo /tmp/myfifo # 创建有名管道文件
ls -l /tmp/myfifo # 查看:类型为 p(pipe)
方式 2:编程创建(mkfifo
函数)
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode); // 成功返回 0,失败返回 -1
// mode:文件权限,如 0666(读写权限)
读写流程(以客户端 - 服务器模式为例)
步骤 1:创建管道(服务端执行)
// server.c
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main() {
if (mkfifo("/tmp/myfifo", 0666) == -1 && errno != EEXIST) {
perror("mkfifo");
return 1;
}
// 后续打开管道进行读写...
return 0;
}
步骤 2:打开管道(读端 / 写端)
- 读端(阻塞模式):
int fd_read = open("/tmp/myfifo", O_RDONLY); // 阻塞直到写端打开
- 写端(非阻塞模式):
int fd_write = open("/tmp/myfifo", O_WRONLY | O_NONBLOCK); // 不阻塞
步骤 3:读写数据(与普通文件一致)
- 写端示例(客户端):
// client.c(写端) #include <fcntl.h> #include <stdio.h> #include <string.h> #include <unistd.h> int main() { int fd = open("/tmp/myfifo", O_WRONLY); if (fd == -1) { perror("open write"); return 1; } char *msg = "Hello, FIFO!"; write(fd, msg, strlen(msg)); close(fd); return 0; }
- 读端示例(服务端):
// server.c(续) int fd = open("/tmp/myfifo", O_RDONLY); char buf[1024] = {0}; ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { printf("Server read: %s\n", buf); } close(fd); unlink("/tmp/myfifo"); // 清理管道文件(非必须,可保留)
注意事项
- 文件权限:需确保读写进程对管道文件有对应权限(读端需
O_RDONLY
,写端需O_WRONLY
)。 - 阻塞行为:
- 读端
open
时,若写端未打开,会阻塞直到写端打开。 - 写端
open
时,若读端未打开,默认阻塞(加O_NONBLOCK
则返回 -1)。
- 读端
3. 标准流管道(popen/pclose)
核心功能
- 简化 shell 命令调用:通过管道连接进程与外部命令的标准输入 / 输出。
- 文件流接口:使用
FILE*
指针,兼容fread/fwrite
等标准 IO 函数。
关键函数
popen
:创建管道并执行命令
#include <stdio.h>
FILE* popen(const char *command, const char *mode);
// command:要执行的 shell 命令(如 "ls -l")
// mode:"r"(读命令输出)或 "w"(向命令输入)
// 返回:成功返回文件流指针,失败返回 NULL
pclose
:关闭管道
int pclose(FILE *stream); // 成功返回命令退出码,失败返回 -1
示例:读取命令输出(如 cat /etc/os-release
)
#include <stdio.h>
int main() {
FILE *fp = popen("cat /etc/os-release", "r"); // 执行命令,获取读流
if (fp == NULL) {
perror("popen");
return 1;
}
char buf[256];
while (fgets(buf, sizeof(buf), fp)) { // 逐行读取命令输出
printf("%s", buf);
}
pclose(fp); // 关闭管道,释放资源
return 0;
}
示例:向命令输入数据(如 wc -w
统计单词数)
#include <stdio.h>
#include <string.h>
int main() {
const char *text = "hello world\nlinux ipc\n";
FILE *fp = popen("wc -w", "w"); // 向命令标准输入写数据
if (fp == NULL) {
perror("popen");
return 1;
}
fwrite(text, sizeof(char), strlen(text), fp); // 写入数据
pclose(fp); // 关闭时命令执行完毕,输出统计结果
return 0;
}
4. 管道对比与适用场景
特性 | 无名管道(PIPE) | 有名管道(FIFO) | 标准流管道(popen) |
---|---|---|---|
进程关系 | 亲缘关系 | 任意进程 | 主进程与外部命令 |
持久化 | 内存中,进程退出消失 | 文件系统存在(需手动删除) | 临时管道,pclose 后消失 |
接口方式 | read/write | open/read/write | fread/fwrite |
典型场景 | 父子进程数据传递 | 跨进程日志监控 | 执行 shell 命令并处理输出 |
总结
管道是 Linux IPC 的基础,无名管道适合亲缘进程快速通信,有名管道突破进程关系限制,标准流管道简化命令交互。掌握管道的阻塞机制、文件描述符操作和信号处理,是深入理解进程通信的关键。后续可结合信号量、共享内存等进阶方式,实现更复杂的同步与数据交换。
二、信号(Signal)
信号是 Linux 中一种轻量级的进程间通信方式,通过软件模拟硬件中断机制,用于通知进程发生了异步事件(如用户输入、子进程结束、非法内存访问等)。进程可以通过捕获信号执行特定操作,或选择忽略、使用默认处理方式。
1. 信号基础:从 “软中断” 到进程通知
核心概念
- 异步事件:信号的到来无需进程主动等待,类似硬件中断(如键盘按下
Ctrl+C
发送SIGINT
信号)。 - 信号类型:系统预定义 30+ 种信号(如
SIGKILL
、SIGCHLD
),每个信号有唯一编号(如SIGINT
对应 2)和默认行为(如终止进程、忽略、内存转储等)。 - 信号处理:进程可自定义信号处理逻辑,或沿用系统默认行为,或忽略信号(但
SIGKILL
和SIGSTOP
不可被忽略或捕获)。
查看系统信号列表
kill -l # 列出所有信号及其编号
2. 信号处理三方式:默认、忽略、捕获
方式 1:默认处理(系统预设行为)
- 示例:收到
SIGINT
(Ctrl+C
)时,进程默认终止。 - 适用场景:无需特殊处理的信号(如
SIGCHLD
子进程结束信号,默认忽略)。
方式 2:忽略信号(丢弃事件)
#include <signal.h>
signal(SIGINT, SIG_IGN); // 忽略 SIGINT 信号,Ctrl+C 无效
- 注意:
SIGKILL
(编号 9)和SIGSTOP
(编号 19)不可被忽略,用于强制终止 / 暂停进程。
方式 3:捕获信号(自定义处理逻辑)
通过 signal
函数注册信号处理函数,进程收到信号时自动调用该函数。
#include <signal.h>
typedef void (*sighandler_t)(int); // 信号处理函数原型,参数为信号编号
sighandler_t signal(int signum, sighandler_t handler);
// signum:目标信号(如 SIGINT)
// handler:处理函数指针,或 SIG_IGN(忽略)、SIG_DFL(默认)
步骤 1:定义信号处理函数
void handle_sigint(int sig) {
printf("收到信号 %d(SIGINT),不终止进程,继续运行...\n", sig);
// 可在此添加自定义逻辑(如清理资源、保存状态)
}
步骤 2:注册处理函数
int main() {
signal(SIGINT, handle_sigint); // 将 SIGINT 信号绑定到 handle_sigint 函数
printf("程序运行中,按 Ctrl+C 触发信号处理...\n");
while (1) sleep(1); // 循环运行,演示信号异步触发
return 0;
}
关键点
- 异步触发:信号处理函数会打断进程当前操作,类似 “回调函数”。
- 可重入函数:处理函数中避免使用非可重入函数(如
printf
是安全的,但全局变量操作需加锁)。
3. 信号发送:主动通知进程的 “信使”
函数 1:kill
— 向指定进程发信号
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
// pid:目标进程 ID(`pid > 0` 为单个进程,`pid = 0` 为当前进程组所有进程)
// sig:信号类型(如 SIGTERM、SIGKILL)
// 返回:成功 0,失败 -1(如 pid 不存在)
示例:杀死指定进程
#include <stdio.h>
#include <signal.h>
#include <sys/types.h>
int main() {
pid_t target_pid = 12345; // 替换为实际进程 ID
if (kill(target_pid, SIGTERM) == -1) { // 发送终止信号(可捕获)
perror("kill failed");
return 1;
}
printf("已向进程 %d 发送 SIGTERM 信号\n", target_pid);
return 0;
}
- 命令行等价:
kill -SIGTERM 12345
或kill -15 12345
。
函数 2:raise
— 向自身发信号
#include <signal.h>
int raise(int sig); // 等价于 kill(getpid(), sig)
// 示例:主动触发 SIGUSR1 信号
raise(SIGUSR1);
函数 3:pause
— 挂起进程直到收到信号
#include <unistd.h>
int pause(void); // 成功时不返回(仅当捕获信号且处理函数未终止进程时继续执行)
示例:等待信号唤醒
printf("等待信号唤醒...\n");
pause(); // 进程在此阻塞,直到收到任意信号(除忽略的信号)
printf("信号处理完成,继续执行...\n");
4. 实战场景:僵尸进程与信号处理
什么是僵尸进程?
子进程退出后,父进程未调用 wait
/waitpid
回收资源,导致子进程状态保留在系统中,成为 “僵尸进程”(状态为 Z
)。
问题影响
- 占用进程表资源,过多会导致系统性能下降。
- 父进程退出后,僵尸进程由
init
进程(PID 1)接管,但仍需手动清理。
解决方案:捕获 SIGCHLD
信号
子进程结束时,系统自动向父进程发送 SIGCHLD
信号,父进程可通过处理该信号回收资源。
步骤 1:注册信号处理函数
#include <signal.h>
#include <sys/wait.h>
void handle_child(int sig) {
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 非阻塞回收所有子进程
if (WIFEXITED(status)) { // 子进程正常退出
printf("子进程 %d 退出,状态码 %d\n", pid, WEXITSTATUS(status));
} else { // 子进程被信号终止
printf("子进程 %d 被信号 %d 终止\n", pid, WTERMSIG(status));
}
}
}
步骤 2:在父进程中绑定信号
int main() {
signal(SIGCHLD, handle_child); // 绑定 SIGCHLD 信号处理函数
// 创建子进程示例
pid_t pid = fork();
if (pid == 0) {
// 子进程逻辑
exit(0); // 子进程退出,触发 SIGCHLD 信号
} else {
// 父进程继续运行,无需阻塞等待子进程
while (1) sleep(1);
}
return 0;
}
关键函数 waitpid
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
// pid:-1 表示任意子进程,0 表示同进程组子进程
// status:存储子进程退出状态(NULL 时忽略)
// options:WNOHANG 表示非阻塞,无退出子进程时立即返回
5. 常见信号分类与典型用途
信号名称 | 编号 | 默认行为 | 典型场景 |
---|---|---|---|
SIGINT | 2 | 终止进程 | 用户按下 Ctrl+C ,中断程序运行 |
SIGKILL | 9 | 强制终止进程(不可捕获 / 忽略) | 紧急终止无响应进程(kill -9 <pid> ) |
SIGCHLD | 17 | 忽略 | 子进程结束时通知父进程,触发资源回收 |
SIGPIPE | 13 | 终止进程 | 向已关闭读端的管道写数据时触发(需处理避免崩溃) |
SIGTERM | 15 | 终止进程(可捕获) | 优雅终止进程(kill <pid> 默认发送此信号) |
SIGUSR1/SIGUSR2 | 10/12 | 终止进程 | 用户自定义信号,用于进程间自定义通信 |
6. 信号处理最佳实践
- 避免僵尸进程:永远为
SIGCHLD
信号注册处理函数,使用waitpid
非阻塞回收子进程。 - 谨慎处理
SIGPIPE
:网络编程或管道通信时,捕获该信号并忽略,避免程序意外终止:signal(SIGPIPE, SIG_IGN); // 忽略管道破裂信号
- 区分信号类型:
SIGKILL
用于强制终止,SIGTERM
用于正常终止(允许进程清理资源)。 - 可重入性:信号处理函数中仅调用可重入函数(如
_exit
、write
),避免使用全局变量或复杂逻辑。
总结
信号是 Linux 进程间通信的 “轻骑兵”,适合异步通知和简单控制(如终止、暂停进程)。掌握 signal
函数的使用、信号发送机制(kill
/raise
)以及僵尸进程处理,是编写健壮程序的基础。后续可结合信号量、共享内存等方式,实现更复杂的进程同步与协作。
三、System V 信号量
System V 信号量是 Linux 中用于进程同步的核心机制之一,通过维护一个非负整数计数器(信号量值),控制多个进程对共享资源的访问。它支持 “信号量集”(多个信号量的集合),适用于复杂的同步场景,如生产者 - 消费者问题、资源竞争控制等。
1. 信号量核心概念:从 “资源计数器” 到进程同步
什么是信号量?
- 本质:一个非负整数变量,用于表示可用资源的数量。
- PV 操作:
- P 操作(申请资源):信号量值减 1,若结果为负则阻塞进程,直到资源可用。
- V 操作(释放资源):信号量值加 1,若有进程阻塞则唤醒。
- 信号量集:包含多个信号量的集合(类似数组),每个信号量通过编号(从 0 开始)唯一标识,适用于管理多种资源。
典型应用场景
- 互斥访问:确保共享资源同一时间仅被一个进程访问(二进制信号量,初始值为 1)。
- 资源计数:控制同时访问资源的进程数量(如连接池,初始值为资源总数)。
2. 关键函数:信号量集的创建、操作与控制
函数 1:semget
— 创建 / 获取信号量集
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int flag);
- 参数解释:
key
:信号量集的唯一标识(可通过ftok
函数生成,或用IPC_PRIVATE
创建私有信号量集)。nsems
:信号量集中信号量的数量(创建时需指定,获取已有集合时设为 0)。flag
:- 权限标志:如
0666
(读写权限)。 - 创建标志:
IPC_CREAT
(不存在则创建)、IPC_EXCL
(与IPC_CREAT
合用,确保创建新集合)。
- 权限标志:如
- 返回值:成功返回信号量集 ID(整数),失败返回 -1。
示例:生成唯一 key
key_t key = ftok("/tmp/semaphore.txt", 'S'); // 基于文件路径和项目 ID 生成 key
函数 2:semop
— 执行信号量操作(PV 操作)
#include <sys/sem.h>
int semop(int semid, struct sembuf *sops, size_t nops);
- 参数解释:
semid
:信号量集 ID(由semget
返回)。sops
:指向struct sembuf
数组的指针,每个元素描述一个信号量操作:struct sembuf { short sem_num; // 信号量在集合中的编号(0 开始) short sem_op; // 操作值:-1(P 操作,申请资源)、+1(V 操作,释放资源) short sem_flg; // 标志位,常用 `SEM_UNDO`(进程退出时自动恢复信号量值) };
nops
:操作数组的长度(一次可操作多个信号量,实现原子操作)。
- 返回值:成功返回 0,失败返回 -1。
函数 3:semctl
— 控制信号量集(初始化、删除等)
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...); // 可变参数依赖于 cmd
- 参数解释:
semid
:信号量集 ID。semnum
:目标信号量编号(单个信号量操作时使用,集合操作时设为 0)。cmd
:操作类型(常用值):SETVAL
:设置信号量初始值(需第四个参数union semun
)。GETVAL
:获取信号量当前值(返回值为信号量值)。IPC_RMID
:删除信号量集(无需第四个参数)。
union semun
自定义(需用户声明):union semun { int val; // 用于 SETVAL 初始化信号量 struct semid_ds *buf; // 用于 IPC_STAT/IPC_SET unsigned short *array; // 用于 GETALL/SETALL(操作信号量集) };
3. 使用步骤:从创建到销毁,手把手实现信号量同步
场景:父子进程同步,轮流打印 “父” 和 “子”
步骤 1:创建信号量集并初始化
#include <sys/sem.h>
#include <sys/ipc.h>
#include <unistd.h>
#include <stdio.h>
union semun {
int val;
};
int main() {
key_t key = ftok(".", 'S'); // 生成唯一 key
int semid = semget(key, 1, IPC_CREAT | 0666); // 创建包含 1 个信号量的集合
union semun arg;
arg.val = 1; // 初始化信号量为 1(表示可用资源数)
semctl(semid, 0, SETVAL, arg); // 对第 0 号信号量设置初始值
// 后续步骤...
}
- 关键点:
SETVAL
必须在semop
之前调用,否则信号量值未定义。
步骤 2:定义 PV 操作函数
// P 操作:申请资源(信号量减 1,阻塞直到 >= 0)
void sem_p(int semid) {
struct sembuf sop = {0, -1, SEM_UNDO}; // 操作第 0 号信号量,减 1,自动恢复
semop(semid, &sop, 1);
}
// V 操作:释放资源(信号量加 1)
void sem_v(int semid) {
struct sembuf sop = {0, +1, SEM_UNDO};
semop(semid, &sop, 1);
}
SEM_UNDO
作用:若进程异常退出,内核自动将信号量恢复为操作前的值,避免资源泄漏。
步骤 3:父子进程通过信号量同步
pid_t pid = fork();
if (pid == 0) {
// 子进程:先等待父进程释放信号量
for (int i = 0; i < 5; i++) {
sem_p(semid); // 申请资源(阻塞直到信号量 >= 1)
printf("子\n");
sem_v(semid); // 释放资源,允许父进程继续
}
} else {
// 父进程:先占用信号量,再释放
for (int i = 0; i < 5; i++) {
sem_p(semid);
printf("父\n");
sem_v(semid);
}
wait(NULL); // 等待子进程结束
}
- 执行效果:父与子交替打印,确保每次仅一个进程执行。
步骤 4:删除信号量集(避免残留)
semctl(semid, 0, IPC_RMID); // 立即删除信号量集,唤醒所有阻塞进程
4. 进阶应用:生产者 - 消费者问题(多信号量协作)
场景描述
- 共享缓冲区大小为
N
,生产者向缓冲区放数据,消费者取数据。 - 信号量设计:
empty
:空缓冲区数量(初始值N
,V 操作表示释放空缓冲区)。full
:满缓冲区数量(初始值 0,P 操作表示获取满缓冲区)。mutex
:互斥信号量(初始值 1,确保缓冲区操作互斥)。
代码框架(简化版)
#define N 5 // 缓冲区大小
int semid;
// 初始化信号量集(3 个信号量:empty, full, mutex)
void init_sem() {
union semun arg;
key_t key = ftok(".", 'S');
semid = semget(key, 3, IPC_CREAT | 0666);
// 初始化 empty=N, full=0, mutex=1
arg.val = N; semctl(semid, 0, SETVAL, arg); // empty(0 号)
arg.val = 0; semctl(semid, 1, SETVAL, arg); // full(1 号)
arg.val = 1; semctl(semid, 2, SETVAL, arg); // mutex(2 号)
}
// 生产者:放入数据
void producer() {
for (int i = 0; i < 10; i++) {
struct sembuf ops[] = {
{0, -1, SEM_UNDO}, // P(empty):申请空缓冲区
{2, -1, SEM_UNDO}, // P(mutex):互斥访问缓冲区
// 放入数据的逻辑...
{2, +1, SEM_UNDO}, // V(mutex):释放互斥锁
{1, +1, SEM_UNDO} // V(full):增加满缓冲区
};
semop(semid, ops, 4); // 原子操作 4 个信号量
}
}
// 消费者:取出数据
void consumer() {
for (int i = 0; i < 10; i++) {
struct sembuf ops[] = {
{1, -1, SEM_UNDO}, // P(full):申请满缓冲区
{2, -1, SEM_UNDO}, // P(mutex):互斥访问缓冲区
// 取出数据的逻辑...
{2, +1, SEM_UNDO}, // V(mutex):释放互斥锁
{0, +1, SEM_UNDO} // V(empty):增加空缓冲区
};
semop(semid, ops, 4);
}
}
- 关键点:
semop
支持一次操作多个信号量,确保多个 PV 操作的原子性(要么全成功,要么全失败)。
5. 信号量操作注意事项
(1)信号量初始值设置
- 二进制信号量(互斥):初始值为 1,确保唯一进程访问资源。
- 计数信号量(资源池):初始值为资源总数(如数据库连接数为 5,则初始值设为 5)。
(2)避免死锁
- 操作顺序:多个信号量操作时,确保所有进程以相同顺序申请信号量(如先申请
mutex
,再申请full
)。 - 超时机制:结合
semop
的非阻塞标志(但 System V 信号量不直接支持,需通过其他方式实现)。
(3)信号量集清理
- 手动删除:通过
semctl(semid, 0, IPC_RMID)
删除,避免信号量集残留(可用ipcs -s
查看系统信号量,ipcrm -s semid
命令删除)。 - 自动回收:信号量集在内核中持久化,即使进程退出也不会自动删除,必须显式调用
semctl
删除。
6. 代码示例:完整的信号量互斥演示
#include <sys/sem.h>
#include <sys/ipc.h>
#include <unistd.h>
#include <stdio.h>
union semun { int val; };
// 初始化信号量
int init_sem(key_t key, int initial_val) {
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
if (semid == -1) {
semid = semget(key, 1, 0); // 获取已存在的信号量集
return semid;
}
union semun arg;
arg.val = initial_val;
semctl(semid, 0, SETVAL, arg);
return semid;
}
// P 操作
void sem_p(int semid) {
struct sembuf sop = {0, -1, SEM_UNDO};
semop(semid, &sop, 1);
}
// V 操作
void sem_v(int semid) {
struct sembuf sop = {0, +1, SEM_UNDO};
semop(semid, &sop, 1);
}
int main() {
key_t key = ftok(".", 'S');
int semid = init_sem(key, 1); // 初始化信号量为 1(互斥)
if (fork() == 0) {
// 子进程:尝试获取信号量
sem_p(semid);
printf("子进程获取信号量,开始执行...\n");
sleep(2); // 模拟占用资源
sem_v(semid);
} else {
// 父进程:等待子进程释放信号量
sleep(1);
sem_p(semid);
printf("父进程获取信号量,开始执行...\n");
sem_v(semid);
wait(NULL);
semctl(semid, 0, IPC_RMID); // 删除信号量集
}
return 0;
}
总结
System V 信号量是进程同步的强大工具,通过 PV 操作和信号量集实现复杂的资源管理。新手需重点掌握:
- 信号量初始化(
semget
+semctl
); - PV 操作实现(
semop
与struct sembuf
); - 多信号量协作(如生产者 - 消费者问题中的互斥与资源计数)。
实际开发中,结合共享内存、消息队列等 IPC 方式,可构建高效的进程间通信与同步方案。
四、消息队列
消息队列是 Linux 中一种灵活的进程间通信方式,允许进程以 “消息” 为单位进行数据交换。每个消息包含类型和内容,接收方可以根据类型选择性获取消息,适用于需要分类处理数据的场景(如日志系统、任务调度)。
1. 核心概念:从 “消息信封” 到分类通信
什么是消息队列?
- 本质:内核中维护的消息链表,每个消息由类型(长整型)和内容(用户自定义数据)组成。
- 核心特性:
- 类型过滤:接收方可按消息类型(
mtype
)读取特定消息(如优先处理紧急消息)。 - 异步解耦:发送方和接收方无需同时运行,消息持久化存储直到被读取(依赖内核,进程重启后消失)。
- 多对多通信:多个进程可同时向队列写消息,多个进程可按类型读消息。
- 类型过滤:接收方可按消息类型(
与管道 / FIFO 的区别
特性 | 消息队列 | 管道 / FIFO |
---|---|---|
数据单位 | 结构化消息(带类型) | 字节流(无类型) |
读取方式 | 按类型选择性读取 | 先进先出(FIFO) |
进程关系 | 无亲缘限制 | 无名管道仅限亲缘进程 |
持久化 | 内核存储,进程退出不消失 | 内存 / 文件存储,关闭后消失 |
2. 关键函数:消息队列的全生命周期操作
函数 1:msgget
— 创建 / 获取消息队列
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
- 参数解释:
key
:消息队列的唯一标识(通过ftok
生成,或用IPC_PRIVATE
创建私有队列)。msgflg
:- 权限标志:如
0666
(读写权限)。 - 创建标志:
IPC_CREAT
(不存在则创建)、IPC_EXCL
(与IPC_CREAT
合用,确保创建新队列)。
- 权限标志:如
- 返回值:成功返回队列 ID(整数),失败返回 -1。
函数 2:msgsnd
— 发送消息到队列
#include <sys/msg.h>
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
- 参数解释:
msqid
:消息队列 ID(由msgget
返回)。msgp
:指向消息结构体的指针(必须以长整型mtype
开头)。msgsz
:消息体的大小(不包含mtype
,最大不超过系统限制,通常 4KB)。msgflg
:0
:阻塞直到队列有空间。IPC_NOWAIT
:非阻塞,队列满时返回 -1。
- 返回值:成功 0,失败 -1。
函数 3:msgrcv
— 从队列接收消息
#include <sys/msg.h>
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
- 参数解释:
msgtyp
:期望的消息类型,支持三种模式:0
:接收队列中的第一个消息(不考虑类型)。>0
:接收类型等于msgtyp
的第一个消息。<0
:接收类型小于等于msgtyp
绝对值的最小类型消息。
- 其他参数同
msgsnd
,msgflg
支持IPC_NOWAIT
(队列空时非阻塞)。
- 返回值:成功返回消息体大小,失败 -1。
函数 4:msgctl
— 控制消息队列(删除、查询状态)
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
- 常用
cmd
:IPC_RMID
:删除消息队列(立即标记为删除,后续操作拒绝)。IPC_STAT
:获取队列状态(存入buf
)。
3. 使用步骤:从创建到销毁,实现进程间消息交换
场景:非亲缘进程通信(客户端 - 服务器模式)
步骤 1:定义消息结构体(必须以 mtype
开头)
// common.h(共享头文件)
struct msgbuf {
long mtype; // 消息类型(自定义,如 1 表示请求,2 表示响应)
char mtext[100]; // 消息内容(用户自定义数据)
};
步骤 2:服务器端 — 创建队列并接收消息
// server.c
#include "common.h"
#include <sys/msg.h>
#include <stdio.h>
#include <string.h>
int main() {
key_t key = ftok(".", 'M'); // 生成唯一 key
int msqid = msgget(key, IPC_CREAT | 0666); // 创建消息队列
struct msgbuf msg;
while (1) {
// 接收类型为 1 的消息(客户端请求)
msgrcv(msqid, &msg, sizeof(msg.mtext), 1, 0);
printf("服务器收到消息:%s\n", msg.mtext);
// 发送响应消息(类型 2)
msg.mtype = 2;
strcpy(msg.mtext, "服务器已收到消息");
msgsnd(msqid, &msg, sizeof(msg.mtext), 0);
}
msgctl(msqid, IPC_RMID, NULL); // 删除队列(实际场景中可能由特定条件触发)
return 0;
}
步骤 3:客户端 — 发送请求并接收响应
// client.c
#include "common.h"
#include <sys/msg.h>
#include <stdio.h>
#include <string.h>
int main() {
key_t key = ftok(".", 'M');
int msqid = msgget(key, 0666); // 获取已存在的队列
struct msgbuf msg;
strcpy(msg.mtext, "你好,服务器!");
msg.mtype = 1; // 发送类型 1 的请求消息
msgsnd(msqid, &msg, sizeof(msg.mtext), 0);
// 接收类型为 2 的响应消息
msgrcv(msqid, &msg, sizeof(msg.mtext), 2, 0);
printf("客户端收到响应:%s\n", msg.mtext);
return 0;
}
步骤 4:编译运行(需先运行服务器)
gcc server.c -o server
gcc client.c -o client
./server & # 后台运行服务器
./client # 客户端发送消息并接收响应
4. 进阶用法:亲缘进程通信与消息类型过滤
场景:父子进程通过消息队列传递不同类型的状态信息
步骤 1:创建私有消息队列(IPC_PRIVATE
)
c
int msqid = msgget(IPC_PRIVATE, IPC_CREAT | 0666); // 生成仅当前进程可见的队列
步骤 2:父进程发送控制消息,子进程按类型处理
// 父进程(发送类型 1:开始任务,类型 2:终止任务)
pid_t pid = fork();
if (pid > 0) {
struct msgbuf msg;
msg.mtype = 1;
strcpy(msg.mtext, "开始执行任务");
msgsnd(msqid, &msg, sizeof(msg.mtext), 0); // 发送开始命令
sleep(2);
msg.mtype = 2;
strcpy(msg.mtext, "任务终止");
msgsnd(msqid, &msg, sizeof(msg.mtext), 0); // 发送终止命令
wait(NULL);
} else {
struct msgbuf msg;
while (1) {
msgrcv(msqid, &msg, sizeof(msg.mtext), 0, 0); // 接收任意类型消息
if (msg.mtype == 1) {
printf("子进程:收到开始命令,执行任务...\n");
} else if (msg.mtype == 2) {
printf("子进程:收到终止命令,退出\n");
exit(0);
}
}
}
关键点:
IPC_PRIVATE
生成的队列需通过进程间共享的 ID(如通过管道或共享内存传递)实现通信。- 子进程通过
msgtyp=0
接收所有类型消息,再根据mtype
分支处理。
5. 消息队列操作最佳实践
(1)消息结构体设计
- 必须以
long mtype
开头,否则msgrcv
无法正确解析消息类型。 - 消息体大小限制:避免超过系统限制(可通过
sysctl -a | grep msgmnb
查看,通常为 4096 字节)。
(2)阻塞与非阻塞模式选择
- 阻塞模式(默认):适合同步场景(如客户端等待服务器响应)。
- 非阻塞模式(
IPC_NOWAIT
):适合异步场景(如日志收集器不阻塞主线程)。
(3)队列清理
- 手动删除:通过
msgctl(msqid, IPC_RMID, NULL)
或命令ipcrm -q msqid
(ipcs -q
查看队列 ID)。 - 避免内存泄漏:服务器进程退出前务必删除队列,或通过
atexit
注册清理函数。
(4)错误处理
- 队列满 / 空处理:发送时检查
msgsnd
返回值,接收时结合errno == EAGAIN
判断非阻塞场景。
6. 完整示例:带错误处理的消息队列通信
// 错误处理增强版 client.c
#include "common.h"
#include <sys/msg.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main() {
key_t key = ftok(".", 'M');
int msqid = msgget(key, 0666);
if (msqid == -1) {
perror("msgget failed");
return 1;
}
struct msgbuf msg;
strcpy(msg.mtext, "测试消息");
msg.mtype = 1;
// 发送消息,处理队列满的情况
if (msgsnd(msqid, &msg, sizeof(msg.mtext), 0) == -1) {
if (errno == EAGAIN) {
printf("队列已满,发送失败\n");
} else {
perror("msgsnd failed");
}
return 1;
}
// 接收响应,处理队列空的情况
memset(&msg, 0, sizeof(msg));
ssize_t n = msgrcv(msqid, &msg, sizeof(msg.mtext), 2, 0);
if (n == -1) {
perror("msgrcv failed");
return 1;
}
printf("收到响应:%s\n", msg.mtext);
return 0;
}
总结
消息队列是 Linux IPC 中 “结构化通信” 的首选,适合需要分类处理数据的场景。新手需重点掌握:
- 消息结构体定义(以
mtype
开头); - 按类型读取消息(
msgrcv
的msgtyp
参数); - 队列的生命周期管理(创建、发送、接收、删除)。
实际应用中,结合信号量实现队列操作的互斥,或与共享内存结合传输大数据,可构建更复杂的分布式通信系统。
五、共享内存
共享内存是 Linux 中最高效的进程间通信(IPC)方式,允许多个进程直接访问同一块物理内存区域。这种机制避免了数据在内核与用户空间之间的拷贝,特别适合高频、大数据量的通信场景(如实时视频处理、数据库缓存)。
1. 核心原理:从物理内存到虚拟地址映射
共享内存的底层机制
- 物理内存分配:通过
shmget
函数创建共享内存段时,内核为其分配一块物理内存区域。 - 虚拟地址映射:进程通过
shmat
函数将共享内存段映射到自身的虚拟地址空间,实现直接读写。 - 数据一致性:多个进程对共享内存的修改会直接反映到物理内存,其他进程无需额外操作即可看到变化。
与其他 IPC 机制的性能对比
机制 | 数据拷贝次数 | 上下文切换次数 | 适用场景 |
---|---|---|---|
共享内存 | 0 | 0 | 高频、大数据量通信 |
管道 / FIFO | 2 | 2 | 简单数据传输 |
消息队列 | 2 | 2 | 分类消息传递 |
套接字 | 2 | 2 | 跨主机通信 |
优势:共享内存直接在用户空间操作,无需内核干预,性能比其他机制提升数倍。
2. 关键函数:共享内存的全生命周期管理
函数 1:shmget
— 创建 / 获取共享内存段
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
- 参数解析:
key
:共享内存的唯一标识(可通过ftok
生成,或用IPC_PRIVATE
创建私有段)。size
:共享内存大小(实际分配按页对齐,如 4096 字节)。shmflg
:- 权限标志:如
0666
(读写权限)。 - 创建标志:
IPC_CREAT
(不存在则创建)、IPC_EXCL
(与IPC_CREAT
合用,确保创建新段)。
- 权限标志:如
- 返回值:成功返回共享内存 ID,失败返回 -1。
函数 2:shmat
— 映射共享内存到进程地址空间
void *shmat(int shmid, const void *shmaddr, int shmflg);
- 参数解析:
shmid
:共享内存 ID(由shmget
返回)。shmaddr
:指定映射地址(通常设为NULL
,由内核自动选择)。shmflg
:SHM_RDONLY
:只读映射。SHM_RND
:若指定shmaddr
,地址按页对齐。
- 返回值:成功返回映射后的内存指针,失败返回
(void *)-1
。
函数 3:shmdt
— 解除内存映射
int shmdt(const void *shmaddr);
- 参数:
shmaddr
为shmat
返回的指针。 - 作用:断开进程与共享内存的映射,但不删除内存段。
函数 4:shmctl
— 控制共享内存(删除、查询状态)
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 常用
cmd
:IPC_RMID
:删除共享内存段(标记为删除,所有进程断开后释放)。IPC_STAT
:获取共享内存状态(存入buf
)。
3. 使用步骤:从创建到销毁,实现进程间数据共享
场景:父子进程通过共享内存交换数据
步骤 1:创建共享内存段并初始化
#include <sys/shm.h>
#include <sys/ipc.h>
#include <unistd.h>
#include <stdio.h>
int main() {
key_t key = ftok(".", 'S'); // 生成唯一 key
int shmid = shmget(key, 1024, IPC_CREAT | 0666); // 创建 1KB 共享内存
// 映射共享内存到进程地址空间
char *shm_ptr = (char *)shmat(shmid, NULL, 0);
if (shm_ptr == (char *)-1) {
perror("shmat failed");
return 1;
}
// 写入数据
strcpy(shm_ptr, "Hello, shared memory!");
// 解除映射(不删除内存段)
shmdt(shm_ptr);
// 创建子进程
pid_t pid = fork();
if (pid == 0) {
// 子进程重新映射共享内存
char *child_ptr = (char *)shmat(shmid, NULL, 0);
printf("子进程读取数据:%s\n", child_ptr);
shmdt(child_ptr);
return 0;
} else {
wait(NULL);
// 父进程删除共享内存段
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
}
关键点:
- 父子进程同步:父进程先写入数据,子进程通过
wait
确保数据已写入。 - 内存清理:
shmctl(shmid, IPC_RMID, NULL)
必须显式调用,否则共享内存段会残留(可用ipcs -m
查看,ipcrm -m shmid
手动删除)。
4. 进阶用法:非亲缘进程通信与同步机制
场景:客户端 - 服务器通过共享内存交换数据(需信号量同步)
步骤 1:定义共享内存结构体(含信号量)
// common.h
struct shared_data {
int flag; // 同步标志(0:未写入,1:已写入)
char buffer[1024];
};
步骤 2:服务器端 — 写入数据并通知客户端
// server.c
#include "common.h"
#include <sys/sem.h>
int main() {
// 创建共享内存
key_t shm_key = ftok(".", 'S');
int shmid = shmget(shm_key, sizeof(struct shared_data), IPC_CREAT | 0666);
// 创建信号量(初始值 0)
key_t sem_key = ftok(".", 'M');
int semid = semget(sem_key, 1, IPC_CREAT | 0666);
union semun { int val; } arg;
arg.val = 0;
semctl(semid, 0, SETVAL, arg);
// 映射共享内存
struct shared_data *shm_ptr = (struct shared_data *)shmat(shmid, NULL, 0);
// 写入数据并通知客户端
strcpy(shm_ptr->buffer, "服务器数据");
shm_ptr->flag = 1;
semop(semid, &(struct sembuf){0, 1, 0}, 1); // V 操作,释放信号量
shmdt(shm_ptr);
return 0;
}
步骤 3:客户端 — 等待数据并读取
// client.c
#include "common.h"
#include <sys/sem.h>
int main() {
// 获取共享内存和信号量
key_t shm_key = ftok(".", 'S');
int shmid = shmget(shm_key, 0, 0);
key_t sem_key = ftok(".", 'M');
int semid = semget(sem_key, 0, 0);
// 映射共享内存
struct shared_data *shm_ptr = (struct shared_data *)shmat(shmid, NULL, 0);
// 等待数据(P 操作)
semop(semid, &(struct sembuf){0, -1, 0}, 1);
printf("客户端读取数据:%s\n", shm_ptr->buffer);
shmdt(shm_ptr);
return 0;
}
关键点:
- 信号量同步:服务器通过
semop
释放信号量,客户端阻塞直到获取信号量。 - 权限管理:共享内存和信号量需设置相同的
key
,确保进程间正确访问。
5. 共享内存操作最佳实践
(1)数据结构设计规范
- 避免动态内存分配:共享内存要求数据连续存储,禁止使用
std::string
、std::vector
等动态容器,改用固定大小数组:struct Data { int id; char name[32]; // 固定长度字符串 int data[100]; // 固定大小数组 };
- 原子操作:对简单类型(如
int
)可使用std::atomic
保证操作原子性(需 C++11 支持)。
(2)错误处理与调试
- 内存映射失败:检查
shmat
返回值是否为-1
,并通过perror
输出错误信息。 - 内存不足:
shmget
可能因系统限制失败,需捕获ENOSPC
错误。 - 调试命令:
ipcs -m # 查看系统共享内存段 ipcrm -m 688 # 删除 ID 为 688 的共享内存段 pmap -X 1234 # 查看进程 1234 的内存映射(含共享内存)
(3)性能优化
- 大页支持:通过
shmget
的SHM_HUGETLB
标志申请大页内存(如 2MB),减少页表开销。 - 内存预分配:提前分配足够大的共享内存,避免频繁调整大小。
6. 对比与拓展:mmap
实现共享内存
场景:父子进程通过 mmap
实现匿名共享内存
#include <sys/mman.h>
#include <fcntl.h>
int main() {
int *shared_var = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (shared_var == MAP_FAILED) {
perror("mmap failed");
return 1;
}
*shared_var = 42; // 父进程写入数据
pid_t pid = fork();
if (pid == 0) {
printf("子进程读取数据:%d\n", *shared_var);
munmap(shared_var, sizeof(int));
return 0;
} else {
wait(NULL);
munmap(shared_var, sizeof(int));
return 0;
}
}
mmap
与 shmget
的区别
特性 | shmget + shmat | mmap |
---|---|---|
底层实现 | System V IPC | POSIX 内存映射 |
适用场景 | 任意进程间通信 | 亲缘进程或文件映射 |
同步机制 | 需手动实现(如信号量) | 自动同步(基于文件) |
内存释放 | 显式调用 shmctl | 调用 munmap |
7. 完整示例:带同步的共享内存通信
#include <sys/shm.h>
#include <sys/sem.h>
#include <stdio.h>
// 定义共享内存结构体(含同步信号量)
struct shared_data {
int value;
int semid; // 信号量 ID
};
int main() {
// 创建共享内存
key_t shm_key = ftok(".", 'S');
int shmid = shmget(shm_key, sizeof(struct shared_data), IPC_CREAT | 0666);
struct shared_data *shm_ptr = (struct shared_data *)shmat(shmid, NULL, 0);
// 创建信号量(初始值 0)
key_t sem_key = ftok(".", 'M');
shm_ptr->semid = semget(sem_key, 1, IPC_CREAT | 0666);
union semun arg;
arg.val = 0;
semctl(shm_ptr->semid, 0, SETVAL, arg);
// 写入数据并通知
shm_ptr->value = 100;
semop(shm_ptr->semid, &(struct sembuf){0, 1, 0}, 1);
// 子进程读取数据
pid_t pid = fork();
if (pid == 0) {
semop(shm_ptr->semid, &(struct sembuf){0, -1, 0}, 1);
printf("子进程读取值:%d\n", shm_ptr->value);
shmdt(shm_ptr);
return 0;
} else {
wait(NULL);
shmctl(shmid, IPC_RMID, NULL);
semctl(shm_ptr->semid, IPC_RMID, NULL);
return 0;
}
}
总结
共享内存是 Linux IPC 的 “高速公路”,通过直接内存访问实现高性能通信。新手需重点掌握:
- 函数调用流程:
shmget
→shmat
→ 读写 →shmdt
→shmctl
(删除)。 - 同步机制:结合信号量或互斥锁确保数据一致性。
- 性能优化:大页内存、预分配、避免动态数据结构。
实际开发中,根据场景选择shmget
(通用)或mmap
(文件映射 / 亲缘进程),并配合调试工具(如ipcs
、pmap
)排查问题。