一、引言
在前两章中,我们深入了解了进程的创建与调度——每个进程在各自的地址空间中独立运行,互不干扰。然而,现实世界中的程序绝非孤岛:Shell 需要通过管道串联命令,数据库需要用共享内存加速数据传递,Ctrl+C 需要瞬间终止前台进程……这些场景都依赖于同一套基础设施——进程间通信(Inter-Process Communication, IPC)。
Linux 提供了丰富的 IPC 机制,从最轻量的信号到最通用的 Unix 域套接字,每种机制都有其适用场景和设计权衡。本章将从信号这一最古老的异步通知机制出发,逐步深入管道、消息队列、共享内存、信号量,最终以 Unix 域套接字收尾,并在最后给出 IPC 机制的选择指南。
二、信号:异步通知机制的本质
1.1 信号是什么?
信号是 Linux 中最古老的 IPC 机制,本质上是内核向进程发送的一个小整数异步通知。它不携带数据(实时信号除外),仅表示”某件事发生了”。你可以把信号类比为现实生活中的门铃——门铃响了,你知道有人来了,但门铃本身不会告诉你来的是谁。
Linux 定义了多种标准信号,常见的包括:
| 信号编号 | 信号名 | 含义 | 默认行为 |
|---|---|---|---|
| 1 | SIGHUP | 终端挂断 | 终止 |
| 2 | SIGINT | 中断(Ctrl+C) | 终止 |
| 3 | SIGQUIT | 退出(Ctrl+\) | 终止 + Core |
| 9 | SIGKILL | 强制终止 | 终止(不可捕获) |
| 11 | SIGSEGV | 段错误 | 终止 + Core |
| 13 | SIGPIPE | 管道破裂 | 终止 |
| 14 | SIGALRM | 定时器到期 | 终止 |
| 15 | SIGTERM | 请求终止 | 终止 |
| 17 | SIGCHLD | 子进程状态变化 | 忽略 |
| 18 | SIGCONT | 继续执行 | 继续 |
| 19 | SIGSTOP | 停止执行 | 停止(不可捕获) |
| 28 | SIGWINCH | 终端窗口大小变化 | 忽略 |
SIGKILL(9)和 SIGSTOP(19)是两个不可捕获、不可阻塞、不可忽略的信号。这是内核的最终手段——当进程失控时,系统仍然能够强制终止或暂停它。
1.2 信号的产生
信号可以从多个来源产生:
1. 用户操作
# 键盘组合键产生信号Ctrl+C → SIGINT (2) # 中断前台进程Ctrl+\ → SIGQUIT (3) # 退出并生成 core dumpCtrl+Z → SIGTSTP (20) # 暂停前台进程
# 通过 kill 命令发送信号kill -SIGTERM 1234 # 向 PID 1234 发送 SIGTERMkill -9 1234 # 向 PID 1234 发送 SIGKILLkill -SIGCHLD 1234 # 向 PID 1234 发送 SIGCHLD2. 内核事件
当进程执行了非法操作时,内核的异常处理程序会产生信号:
// 以下操作会触发内核产生信号int *p = NULL;*p = 42; // 访问空指针 → SIGSEGV (11)
char buf[10];buf[100000] = 'x'; // 越界写入 → SIGSEGV (11)
int x = 1 / 0; // 整数除零 → SIGFPE (8)
raise(SIGALRM); // 进程主动向自己发信号3. 软件条件
某些软件条件满足时也会产生信号:
alarm(5); // 5秒后产生 SIGALRM// 管道读端已关闭,写端继续写 → SIGPIPE// 子进程退出 → 父进程收到 SIGCHLD1.3 信号的处理方式
进程对信号有三种处理方式:
- 执行默认行为:如终止、终止+core、忽略、停止等
- 忽略信号:将信号处理设置为
SIG_IGN(但 SIGKILL/SIGSTOP 不可忽略) - 捕获信号:注册自定义信号处理函数
sigaction:注册信号处理函数
sigaction 是注册信号处理函数的 POSIX 标准接口,比老旧的 signal() 更灵活、更安全:
#include <signal.h>#include <stdio.h>#include <unistd.h>
void sigint_handler(int signo) { printf("收到 SIGINT 信号 (编号 %d)\n", signo); // 注意:在信号处理函数中只能调用异步信号安全(async-signal-safe)的函数 // printf() 实际上不是异步信号安全的,这里仅为演示}
int main(void) { struct sigaction sa; sa.sa_handler = sigint_handler; // 设置处理函数 sigemptyset(&sa.sa_mask); // 初始化信号掩码为空 sa.sa_flags = 0; // 默认行为
// 注册 SIGINT 的处理函数 if (sigaction(SIGINT, &sa, NULL) == -1) { perror("sigaction"); return 1; }
printf("进程 PID: %d,按 Ctrl+C 触发信号处理...\n", getpid());
while (1) { pause(); // 等待信号到来 }
return 0;}sigaction 的 sa_flags 支持多个重要标志:
| 标志 | 作用 |
|---|---|
SA_RESTART | 被信号中断的系统调用自动重启 |
SA_SIGINFO | 使用 sa_sigaction 三参数处理函数,获取发送者信息 |
SA_NOCLDSTOP | 子进程停止时不产生 SIGCHLD |
SA_NOCLDWAIT | 子进程终止时不产生僵尸进程 |
SA_NODEFER | 处理信号时不自动阻塞该信号 |
信号处理函数中只能调用异步信号安全的函数。许多标准库函数(如 malloc、printf、exit)都不在安全列表中,因为它们可能持有内部锁,若被信号中断则可能死锁。完整的安全函数列表见 man 7 signal-safety。
三、信号在内核中的表示
2.1 信号的三种状态
信号从产生到处理,经历三种状态:
- 产生(Generate):内核或进程发送信号
- 待决(Pending):信号已产生但尚未投递给进程
- 投递(Deliver):信号被传递给进程并执行处理动作
2.2 内核数据结构
Linux 内核通过三个关键数据结构管理信号:
// 简化版,展示核心字段// 源码: include/linux/sched.h, include/linux/signal_types.h
struct task_struct { // 信号处理方式(共享,同一线程组的线程共享) struct sighand_struct __rcu *sighand;
// 待决信号(私有,每个线程独立) struct sigpending pending;
// 信号阻塞掩码 sigset_t blocked;
// ...};
struct sighand_struct { // 每个信号的处理方式 struct sigaction action[_NSIG]; // _NSIG = 64 refcount_t count; // 引用计数(fork时共享)};
struct sigpending { struct list_head list; // 待决信号链表 sigset_t signal; // 待决信号位图};核心设计要点:
sighand_struct是线程组共享的:同一进程的所有线程共享信号处理方式,fork时通过引用计数共享,exec时重新分配sigpending是每个线程独立的:每个线程有自己的待决信号队列,发送信号时可以选择发送给整个进程(kill)还是特定线程(tgkill)blocked是每个线程独立的:每个线程可以独立阻塞不同的信号
2.3 pending 位图机制
内核使用位图(sigset_t)高效管理信号状态。Linux 支持两种信号集:
- 标准信号(1~31):使用一个
unsigned long(32位)即可表示 - 实时信号(32~63):需要额外的位图空间
// sigset_t 的简化表示// 实际实现可能是一个 long 数组typedef struct { unsigned long sig[1]; // 标准信号位图} sigset_t;
// 位图操作sigset_t pending; // 待决信号位图sigaddset(&pending, 2); // 第2位置1 → SIGINT 待决sigismember(&pending, 2); // 检查 SIGINT 是否待决位图的优势在于:检查”是否有待决信号”只需一次整数比较(pending != 0),设置/清除信号只需位操作,极其高效。
2.4 延迟信号与实时信号
这是 Linux 信号机制中一个至关重要的区分:
| 特性 | 标准信号(不可靠信号) | 实时信号(可靠信号) |
|---|---|---|
| 编号范围 | 1~31 | 32 |
| 排队方式 | 不排队,同一信号只记录一次 | 排队,每个信号实例都记录 |
| 投递顺序 | 不保证顺序 | 按 FIFO 顺序投递 |
| 携带数据 | 不携带 | 可携带 siginfo_t 中的 int 或 void* |
| 典型用途 | 系统预定义事件 | 用户自定义事件 |
不可靠信号丢失场景:
// 假设进程阻塞了 SIGINT// 发送 3 次 SIGINTkill(pid, SIGINT); // 第1次:pending 位图置位kill(pid, SIGINT); // 第2次:位图已置位,无操作 → 信号丢失!kill(pid, SIGINT); // 第3次:位图已置位,无操作 → 信号丢失!// 解除阻塞后,进程只会收到 1 次 SIGINT实时信号不丢失:
// 发送 3 次 SIGRTMINsigqueue(pid, SIGRTMIN, value1); // 排入队列sigqueue(pid, SIGRTMIN, value2); // 排入队列sigqueue(pid, SIGRTMIN, value3); // 排入队列// 解除阻塞后,进程会收到 3 次 SIGRTMIN,按 FIFO 顺序在需要精确计数或不能丢失通知的场景下(如实时应用、精确事件通知),务必使用实时信号(sigqueue)而非标准信号(kill)。
四、信号的投递时机
3.1 TIF_SIGPENDING 检查点
信号不是在产生的瞬间就被处理的——内核在从内核态返回用户态时才检查并投递信号。这是信号机制最核心的时序设计。
关键步骤详解:
- 设置标志:当信号产生时,内核在目标进程的
thread_info.flags中设置TIF_SIGPENDING标志 - 检查时机:内核在以下路径检查该标志:
- 系统调用返回用户态前(
syscall_exit_work) - 中断返回用户态前(
prepare_exit_to_usermode) - 从中断/异常返回前
- 系统调用返回用户态前(
- 投递信号:若
TIF_SIGPENDING置位,调用do_signal()→get_signal()逐个处理待决信号 - 设置栈帧:若信号被捕获,内核在用户态栈上构建一个
sigframe结构,包含:- 信号编号
- 指向
siginfo_t的指针 - 被中断的上下文(寄存器状态)
- 返回地址指向
__restore_rt(信号处理函数返回后执行rt_sigreturn)
- 修改返回地址:将用户态的指令指针修改为信号处理函数的入口
- sigreturn:信号处理函数返回后,通过
rt_sigreturn系统调用恢复原始上下文
3.2 信号投递的内核源码路径
// 极简化的信号投递调用链// 源码: arch/x86/kernel/signal.c, kernel/signal.c
// 从内核返回用户态时exit_to_usermode_loop(regs) { if (ti_work & _TIF_SIGPENDING) // 检查标志 do_signal(regs); // 处理信号}
do_signal(struct pt_regs *regs) { struct ksignal ksig;
if (get_signal(&ksig)) { // 获取待决信号 handle_signal(&ksig, regs); // 处理信号 }}
handle_signal(struct ksignal *ksig, struct pt_regs *regs) { // 1. 在用户态栈上设置信号帧 setup_rt_frame(ksig, regs);
// 2. 修改返回地址为信号处理函数 regs->ip = (unsigned long)ksig->ka.sa.sa_handler;
// 3. 信号处理函数返回后执行 sigreturn}3.3 信号与系统调用的交互
信号投递时机对系统调用有重要影响:
可中断的系统调用:当进程在内核态执行一个慢速系统调用(如 read 等待输入、sleep 等待超时)时,若信号到来,系统调用会被中断并返回 EINTR 错误。
// 典型的被信号中断的系统调用处理模式ssize_t ret;do { ret = read(fd, buf, sizeof(buf));} while (ret == -1 && errno == EINTR); // 被信号中断则重试
// 或者使用 sigaction 的 SA_RESTART 标志自动重启struct sigaction sa;sa.sa_flags = SA_RESTART; // 内核自动重启被中断的系统调用在编写信号处理程序时,务必考虑系统调用被中断的情况。要么使用 SA_RESTART 自动重启,要么在代码中手动检查 EINTR 并重试。忽略 EINTR 是许多隐蔽 bug 的根源。
五、管道:最简单的数据传输 IPC
4.1 匿名管道(pipe)
管道是 Unix 最古老的 IPC 机制,由 pipe() 系统调用创建:
#include <unistd.h>#include <stdio.h>#include <string.h>
int main(void) { int pipefd[2]; pid_t pid; char buf[128];
// 创建管道:pipefd[0] 为读端,pipefd[1] 为写端 if (pipe(pipefd) == -1) { perror("pipe"); return 1; }
pid = fork(); if (pid == 0) { // 子进程:关闭写端,从管道读取 close(pipefd[1]); ssize_t n = read(pipefd[0], buf, sizeof(buf)); printf("子进程收到: %.*s\n", (int)n, buf); close(pipefd[0]); } else { // 父进程:关闭读端,向管道写入 close(pipefd[0]); const char *msg = "Hello from parent!"; write(pipefd[1], msg, strlen(msg)); close(pipefd[1]); // 关闭写端,子进程的 read 才会返回 0 }
return 0;}管道的核心特性:
- 半双工:数据只能单向流动(从写端到读端)
- 亲缘关系:只能在有亲缘关系的进程间使用(fork 后共享文件描述符)
- 字节流:无消息边界,读取端无法区分写入端的写入次数
- 阻塞 I/O:管道空时
read阻塞,管道满时write阻塞 - SIGPIPE:读端全部关闭后写入,写进程收到 SIGPIPE 信号
- 缓冲区大小:自 Linux 2.6.11 起,管道缓冲区为一页(4KB),可通过
fcntl调整
4.2 管道在内核中的实现
在内核中,管道被实现为一个特殊的 inode,通过 VFS 层管理:
// 源码: fs/pipe.c, include/linux/pipe_fs_i.h
struct pipe_inode_info { struct mutex mutex; // 互斥锁保护管道操作 wait_queue_head_t rd_wait; // 读等待队列 wait_queue_head_t wr_wait; // 写等待队列 unsigned int head; // 环形缓冲区头指针 unsigned int tail; // 环形缓冲区尾指针 unsigned int ring_size; // 环形缓冲区大小 struct pipe_buffer *bufs; // 环形缓冲区数组 // ...};
struct pipe_buffer { struct page *page; // 指向实际数据页 unsigned int offset; // 页内偏移 unsigned int len; // 数据长度 const struct pipe_buf_operations *ops; // 操作函数表 // ...};管道的内核实现要点:
- 环形缓冲区:
pipe_buffer数组构成环形缓冲区,默认 16 个槽位,每个槽位对应一个page - 零拷贝优化:
splice()和tee()系统调用可以直接在管道和文件/套接字之间传递数据,无需经过用户态 - 等待队列:读端在
rd_wait上等待数据,写端在wr_wait上等待空间 - VFS 集成:管道的读端和写端分别对应一个
struct file,共享同一个pipe_inode_info
4.3 命名管道(FIFO)
匿名管道的局限在于只能用于亲缘进程间通信。FIFO(命名管道)通过文件系统中的特殊文件打破了这一限制:
# 创建 FIFOmkfifo /tmp/myfifo
# 或者通过 C 函数# mkfifo("/tmp/myfifo", 0666);#include <fcntl.h>#include <sys/stat.h>#include <unistd.h>#include <stdio.h>
// 进程 A:写入端int writer(void) { int fd = open("/tmp/myfifo", O_WRONLY); const char *msg = "Hello via FIFO!"; write(fd, msg, strlen(msg)); close(fd); return 0;}
// 进程 B:读取端int reader(void) { char buf[128]; int fd = open("/tmp/myfifo", O_RDONLY); ssize_t n = read(fd, buf, sizeof(buf)); printf("收到: %.*s\n", (int)n, buf); close(fd); return 0;}FIFO 与匿名管道的区别:
| 特性 | 匿名管道 | FIFO |
|---|---|---|
| 存在形式 | 内存中的 inode | 文件系统中的特殊文件 |
| 通信范围 | 亲缘进程 | 任意进程 |
| 生命周期 | 随进程 | 随文件(需手动删除) |
| 打开方式 | pipe() 返回 fd | open() 打开路径 |
| 内核实现 | 相同(pipe_inode_info) | 相同 |
FIFO 的 open() 默认是阻塞的——以只读方式打开会阻塞直到有进程以写方式打开,反之亦然。可以使用 O_NONBLOCK 标志改变此行为。
六、消息队列
消息队列是消息的链表,存放在内核中,由消息队列标识符标识。与管道相比,消息队列保留了消息边界,并且可以按类型选择性接收。
5.1 System V 消息队列
System V 消息队列是早期 Unix 的 IPC 机制,API 以 msgget/msgsnd/msgrcv 为核心:
#include <sys/msg.h>#include <stdio.h>#include <string.h>
// 消息结构:第一个成员必须是 long 类型的 mtypestruct my_msg { long mtype; // 消息类型(必须 > 0) char mtext[256]; // 消息正文};
int main(void) { key_t key = ftok("/tmp", 'A'); // 生成唯一 key int msqid = msgget(key, 0666 | IPC_CREAT);
// 发送消息 struct my_msg snd_msg = { .mtype = 1, // 类型 1 }; strncpy(snd_msg.mtext, "Hello, msgq!", sizeof(snd_msg.mtext)); msgsnd(msqid, &snd_msg, strlen(snd_msg.mtext) + 1, 0);
// 按类型接收消息 struct my_msg rcv_msg; // 最后一个参数: 0=接收第一个, >0=接收指定类型, <0=接收类型≤|mtype|的最小值 msgrcv(msqid, &rcv_msg, sizeof(rcv_msg.mtext), 1, 0); printf("收到: %s\n", rcv_msg.mtext);
// 删除消息队列 msgctl(msqid, IPC_RMID, NULL); return 0;}内核实现(源码: ipc/msg.c):
// 简化版struct msg_queue { struct kern_ipc_perm q_perm; // 权限与标识 struct list_head q_messages; // 消息链表 struct list_head q_senders; // 等待发送的进程 struct list_head q_receivers; // 等待接收的进程 unsigned long q_cbytes; // 当前字节数 unsigned long q_qnum; // 当前消息数 unsigned long q_qbytes; // 最大字节数限制 // ...};5.2 POSIX 消息队列
POSIX 消息队列比 System V 更现代,API 更清晰:
#include <mqueue.h>#include <stdio.h>#include <string.h>
int main(void) { struct mq_attr attr = { .mq_flags = 0, .mq_maxmsg = 10, // 最大消息数 .mq_msgsize = 256, // 每条消息最大大小 .mq_curmsgs = 0, };
// 创建/打开消息队列 mqd_t mqd = mq_open("/my_queue", O_CREAT | O_RDWR, 0666, &attr);
// 发送消息(可指定优先级) unsigned int prio = 1; mq_send(mqd, "Hello, POSIX mq!", 16, prio);
// 接收消息(按优先级降序接收) char buf[256]; unsigned int recv_prio; ssize_t n = mq_receive(mqd, buf, sizeof(buf), &recv_prio); printf("收到(优先级 %u): %.*s\n", recv_prio, (int)n, buf);
mq_close(mqd); mq_unlink("/my_queue"); return 0;}System V vs POSIX 消息队列对比:
| 特性 | System V | POSIX |
|---|---|---|
| 标识方式 | 整数 ID | 名称字符串(以 / 开头) |
| 消息优先级 | 按 mtype 选择性接收 | 按优先级排序投递 |
| 异步通知 | 无 | 支持 mq_notify() |
| 非阻塞模式 | IPC_NOWAIT 标志 | O_NONBLOCK 标志 |
| 查看工具 | ipcs -q | 查看 /dev/mqueue/ |
| 可移植性 | 较广 | 较新系统 |
POSIX 消息队列的 mq_notify() 支持异步通知——当队列从空变为非空时,内核会发送信号或启动线程通知接收者,避免了轮询的开销。这是 System V 消息队列不具备的重要特性。
七、共享内存:最快的 IPC
6.1 为什么共享内存最快?
所有其他 IPC 机制都需要数据拷贝:发送方从用户态拷贝到内核态,接收方再从内核态拷贝到用户态。而共享内存让两个进程映射同一块物理内存,数据零拷贝——写入方直接写入共享区域,读取方立即可见。
普通 IPC(两次拷贝):进程A 用户空间 ──拷贝──▶ 内核缓冲区 ──拷贝──▶ 进程B 用户空间
共享内存(零拷贝):进程A 用户空间 ──映射──▶ 物理内存 ◀──映射── 进程B 用户空间6.2 System V 共享内存
#include <sys/shm.h>#include <sys/ipc.h>#include <stdio.h>#include <string.h>
int main(void) { key_t key = ftok("/tmp", 'S');
// 1. 创建共享内存段 int shmid = shmget(key, 4096, IPC_CREAT | 0666);
// 2. 连接到进程地址空间 void *addr = shmat(shmid, NULL, 0);
// 3. 使用共享内存 strncpy((char *)addr, "Hello, shared memory!", 4096); printf("共享内存内容: %s\n", (char *)addr);
// 4. 断开连接 shmdt(addr);
// 5. 删除共享内存 shmctl(shmid, IPC_RMID, NULL); return 0;}内核实现(源码: ipc/shm.c):
// 简化版struct shmid_kernel { struct kern_ipc_perm shm_perm; // 权限 struct file *shm_file; // 底层文件(tmpfs) unsigned long shm_nattch; // 连接计数 unsigned long shm_segsz; // 段大小 // ...};System V 共享内存的底层实现依赖于 tmpfs(内存文件系统)。shmget 实际上在 tmpfs 中创建一个文件,shmat 通过 mmap 将该文件映射到进程地址空间。这种设计让共享内存天然受益于页缓存机制。
6.3 POSIX 共享内存(mmap)
POSIX 共享内存通过 shm_open + mmap 实现,更符合文件操作的直觉:
#include <sys/mman.h>#include <sys/stat.h>#include <fcntl.h>#include <stdio.h>#include <string.h>#include <unistd.h>
int main(void) { const size_t SIZE = 4096;
// 1. 创建共享内存对象 int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
// 2. 设置大小 ftruncate(fd, SIZE);
// 3. 映射到地址空间 void *addr = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 4. 使用 strncpy((char *)addr, "Hello, POSIX shm!", SIZE); printf("内容: %s\n", (char *)addr);
// 5. 清理 munmap(addr, SIZE); close(fd); shm_unlink("/my_shm"); return 0;}6.4 共享内存的同步问题
共享内存本身不提供任何同步机制。如果两个进程同时写入同一块共享内存,会产生数据竞争。因此,共享内存必须配合信号量或其他同步原语使用:
// 典型模式:共享内存 + 信号量struct shared_data { sem_t mutex; // POSIX 命名信号量或进程间信号量 int count; char message[256];};
// 写入方sem_wait(&data->mutex); // 加锁data->count++;strncpy(data->message, "updated", sizeof(data->message));sem_post(&data->mutex); // 解锁
// 读取方sem_wait(&data->mutex); // 加锁printf("count=%d, msg=%s\n", data->count, data->message);sem_post(&data->mutex); // 解锁共享内存是”最快的 IPC”这一说法有一个前提:必须配合高效的同步机制。如果同步开销过大(如频繁的信号量操作),共享内存的性能优势可能被抵消。在高并发场景下,通常使用无锁数据结构(如环形缓冲区 + 原子操作)来减少同步开销。
八、信号量:IPC 中的同步原语
信号量(Semaphore)是一种用于控制多个进程对共享资源访问的同步机制。与前面介绍的数据传输 IPC 不同,信号量本身不传输数据,只负责协调访问顺序。
7.1 System V 信号量
System V 信号量以信号量集(Semaphore Set)为单位操作,一个集合可包含多个信号量:
#include <sys/sem.h>#include <stdio.h>#include <unistd.h>
// 联合体,用于 semctl 的 SETVAL/IPC_RMID 操作union semun { int val; struct semid_ds *buf; unsigned short *array;};
int main(void) { key_t key = ftok("/tmp", 'E');
// 1. 创建信号量集(包含1个信号量) int semid = semget(key, 1, IPC_CREAT | 0666);
// 2. 初始化信号量值为 1(二值信号量/互斥锁) union semun arg = { .val = 1 }; semctl(semid, 0, SETVAL, arg);
// 3. P 操作(等待/减1) struct sembuf sop_p = { .sem_num = 0, // 信号量索引 .sem_op = -1, // P 操作:-1 .sem_flg = 0, // 阻塞等待 }; semop(semid, &sop_p, 1); printf("获取锁,进入临界区\n");
// ... 临界区操作 ... sleep(1);
// 4. V 操作(释放/加1) struct sembuf sop_v = { .sem_num = 0, .sem_op = 1, // V 操作:+1 .sem_flg = 0, }; semop(semid, &sop_v, 1); printf("释放锁,退出临界区\n");
// 5. 删除信号量 semctl(semid, 0, IPC_RMID); return 0;}内核实现(源码: ipc/sem.c):
struct sem_array { struct kern_ipc_perm sem_perm; // 权限 int sem_nsems; // 信号量数量 struct list_head pending_alter; // 待决的修改操作 struct list_head pending_const; // 待决的恒等操作 struct sem *sem_base; // 信号量数组 // ...};
struct sem { int semval; // 信号量当前值 int sempid; // 最后一次操作的 PID // ...};System V 信号量的一个强大特性是原子多信号量操作——一次 semop 可以同时操作多个信号量,要么全部成功,要么全部不执行。这可以用来实现复杂的同步模式(如同时获取多个资源)。
7.2 POSIX 信号量
POSIX 信号量 API 更简洁,分为命名信号量和无名信号量:
// ===== 命名信号量 =====#include <fcntl.h>#include <sys/stat.h>#include <semaphore.h>
sem_t *sem = sem_open("/my_sem", O_CREAT, 0666, 1); // 初始值1sem_wait(sem); // P 操作// ... 临界区 ...sem_post(sem); // V 操作sem_close(sem);sem_unlink("/my_sem");
// ===== 无名信号量(可放在共享内存中) =====sem_t sem;sem_init(&sem, 1, 1); // 第二个参数1=进程间共享,初始值1sem_wait(&sem);// ... 临界区 ...sem_post(&sem);sem_destroy(&sem);System V vs POSIX 信号量对比:
| 特性 | System V | POSIX |
|---|---|---|
| 操作单位 | 信号量集(多个) | 单个信号量 |
| 原子操作 | 支持多信号量原子操作 | 单个 P/V |
| 创建方式 | semget + semctl | sem_open 或 sem_init |
| 删除方式 | semctl(IPC_RMID) | sem_unlink / sem_destroy |
| 超时等待 | 不支持 | sem_timedwait() |
| 查看工具 | ipcs -s | 无标准工具 |
九、Unix 域套接字:本地 IPC 的”瑞士军刀”
8.1 概述
Unix 域套接字(Unix Domain Socket, UDS)是套接字 API 在本地进程间通信的特化版本。它使用文件系统路径作为地址,但不经过网络协议栈,因此比 TCP/UDP 套接字高效得多。
#include <sys/socket.h>#include <sys/un.h>#include <stdio.h>#include <string.h>#include <unistd.h>
#define SOCK_PATH "/tmp/unix_sock"
int main(void) { int server_fd, client_fd; struct sockaddr_un addr; char buf[128];
// ===== 服务端 ===== server_fd = socket(AF_UNIX, SOCK_STREAM, 0); memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1); bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(server_fd, 5);
// ===== 客户端 ===== client_fd = socket(AF_UNIX, SOCK_STREAM, 0); connect(client_fd, (struct sockaddr *)&addr, sizeof(addr)); write(client_fd, "Hello, UDS!", 11);
// ===== 服务端接收 ===== int conn_fd = accept(server_fd, NULL, NULL); ssize_t n = read(conn_fd, buf, sizeof(buf)); printf("收到: %.*s\n", (int)n, buf);
close(conn_fd); close(client_fd); close(server_fd); unlink(SOCK_PATH); return 0;}8.2 Unix 域套接字的独特优势
- 支持传递文件描述符:通过
sendmsg/recvmsg的辅助数据(ancillary data)传递打开的文件描述符,这是其他 IPC 机制做不到的
// 发送文件描述符struct msghdr msg = {0};struct cmsghdr *cmsg;int fd_to_send = open("/tmp/data.txt", O_RDONLY);char cmsgbuf[CMSG_SPACE(sizeof(int))];
msg.msg_control = cmsgbuf;msg.msg_controllen = sizeof(cmsgbuf);cmsg = CMSG_FIRSTHDR(&msg);cmsg->cmsg_level = SOL_SOCKET;cmsg->cmsg_type = SCM_RIGHTS;cmsg->cmsg_len = CMSG_LEN(sizeof(int));*(int *)CMSG_DATA(cmsg) = fd_to_send;
sendmsg(sock_fd, &msg, 0);- 支持传递安全上下文(SCM_CREDENTIALS):可以传递发送进程的 PID、UID、GID
- 双全工通信:支持 SOCK_STREAM(字节流)和 SOCK_DGRAM(数据报)两种模式
- 无需序列化:对于本地通信,不需要考虑字节序等网络问题
8.3 内核实现
Unix 域套接字的内核实现位于 net/unix/ 目录。SOCK_STREAM 模式底层使用类似管道的环形缓冲区,SOCK_DGRAM 模式使用消息队列。由于不经过网络协议栈,数据路径极短:
TCP 套接字数据路径:用户空间 → 系统调用 → TCP → IP → 链路层 → ... → 对端
Unix 域套接字数据路径:用户空间 → 系统调用 → unix_stream_sendmsg() → 对端缓冲区十、IPC 机制选择指南
面对如此多的 IPC 机制,如何选择?以下是综合对比:
9.1 延迟对比
| IPC 机制 | 典型延迟 | 说明 |
|---|---|---|
| 信号 | ~1-2 μs | 仅通知,不传数据 |
| 管道/FIFO | ~5-10 μs | 两次数据拷贝 |
| 消息队列 | ~5-15 μs | 两次拷贝 + 消息管理 |
| Unix 域套接字 | ~5-10 μs | 类似管道 |
| 共享内存 | ~0.1-0.5 μs | 零拷贝(不含同步开销) |
以上延迟为大致参考值,实际性能受 CPU 架构、内核版本、数据大小等因素影响。共享内存的延迟优势在数据量大时尤为明显。
9.2 综合对比表
| 特性 | 信号 | 管道 | FIFO | 消息队列 | 共享内存 | 信号量 | Unix域套接字 |
|---|---|---|---|---|---|---|---|
| 传输数据 | 字节流 | 字节流 | 消息 | 任意 | 字节流/数据报 | ||
| 方向 | 单向 | 单向 | 单向 | 双向 | 双向 | - | 双向 |
| 亲缘要求 | 无 | 必须 | 无 | 无 | 无 | 无 | 无 |
| 同步需求 | 无 | 内核同步 | 内核同步 | 内核同步 | 需自备 | 本身即同步 | 内核同步 |
| 持久性 | 无 | 随进程 | 随文件 | 随内核 | 随内核 | 随内核 | 随进程 |
| 传文件描述符 | |||||||
| 复杂度 | 低 | 低 | 低 | 中 | 高 | 中 | 中 |
9.3 选择建议
- 只需要通知,不传数据 → 信号
- 父子进程间传数据 → 匿名管道
- 无亲缘关系,简单数据流 → FIFO 或 Unix 域套接字
- 需要消息边界或选择性接收 → 消息队列
- 大量数据,追求极致性能 → 共享内存 + 信号量
- 需要传文件描述符 → Unix 域套接字
- 需要通用、可扩展的通信 → Unix 域套接字(大多数场景的最佳选择)
十一、动手实践
实践 1:查看系统支持的信号
# 列出所有信号kill -l
# 查看特定信号的默认行为man 7 signal
# 查看进程的信号掩码cat /proc/self/status | grep -i sig
# 使用 strace 观察信号投递strace -e signal ./your_program实践 2:编写信号处理程序
// signal_demo.c — 信号处理综合演示#include <signal.h>#include <stdio.h>#include <unistd.h>#include <string.h>#include <stdlib.h>
volatile sig_atomic_t got_sigusr1 = 0;volatile sig_atomic_t got_sigusr2 = 0;
void handler(int signo, siginfo_t *info, void *context) { if (signo == SIGUSR1) { got_sigusr1 = 1; write(STDOUT_FILENO, "SIGUSR1 received\n", 17); } else if (signo == SIGUSR2) { got_sigusr2 = 1; write(STDOUT_FILENO, "SIGUSR2 received\n", 17); }}
int main(void) { struct sigaction sa; sa.sa_sigaction = handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigaction(SIGUSR1, &sa, NULL); sigaction(SIGUSR2, &sa, NULL);
printf("PID: %d\n", getpid()); printf("发送信号测试: kill -USR1 %d\n", getpid()); printf(" kill -USR2 %d\n", getpid());
while (!got_sigusr1 || !got_sigusr2) { pause(); }
printf("两个信号均已收到,程序退出\n"); return 0;}# 编译运行gcc -o signal_demo signal_demo.c./signal_demo &# 在另一个终端kill -USR1 <PID>kill -USR2 <PID>实践 3:创建和使用 FIFO
# 创建 FIFOmkfifo /tmp/test_fifo
# 终端1:读取端cat /tmp/test_fifo
# 终端2:写入端echo "Hello through FIFO" > /tmp/test_fifo
# 清理rm /tmp/test_fifo实践 4:查看系统 IPC 资源
# 查看所有 System V IPC 资源ipcs # 综合查看ipcs -q # 消息队列ipcs -m # 共享内存ipcs -s # 信号量
# 查看 POSIX 消息队列ls -la /dev/mqueue/
# 查看进程的映射(包含共享内存段)cat /proc/self/maps | grep -i shm
# 手动删除残留的 IPC 资源ipcrm -q <msqid> # 删除消息队列ipcrm -m <shmid> # 删除共享内存ipcrm -s <semid> # 删除信号量实践 5:共享内存 + 信号量生产者-消费者
// prodcons.c — 共享内存 + 信号量实现生产者-消费者模型#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <sys/wait.h>#include <sys/mman.h>#include <sys/stat.h>#include <fcntl.h>#include <semaphore.h>
#define SHM_NAME "/pc_shm"#define SEM_MUTEX "/pc_mutex"#define SEM_EMPTY "/pc_empty"#define SEM_FULL "/pc_full"#define BUF_SIZE 10#define ITEM_COUNT 20
typedef struct { int buffer[BUF_SIZE]; int in; int out;} shared_buffer_t;
int main(void) { // 创建共享内存 int shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666); ftruncate(shm_fd, sizeof(shared_buffer_t)); shared_buffer_t *shm = mmap(NULL, sizeof(shared_buffer_t), PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0); memset(shm, 0, sizeof(shared_buffer_t));
// 创建信号量 sem_t *mutex = sem_open(SEM_MUTEX, O_CREAT, 0666, 1); sem_t *empty = sem_open(SEM_EMPTY, O_CREAT, 0666, BUF_SIZE); sem_t *full = sem_open(SEM_FULL, O_CREAT, 0666, 0);
pid_t pid = fork(); if (pid == 0) { // ===== 消费者子进程 ===== for (int i = 0; i < ITEM_COUNT; i++) { sem_wait(full); sem_wait(mutex);
int item = shm->buffer[shm->out]; shm->out = (shm->out + 1) % BUF_SIZE; printf(" 消费: %d (槽位 %d)\n", item, shm->out);
sem_post(mutex); sem_post(empty); usleep(100000); // 模拟消费耗时 } exit(0); } else { // ===== 生产者父进程 ===== for (int i = 0; i < ITEM_COUNT; i++) { sem_wait(empty); sem_wait(mutex);
shm->buffer[shm->in] = i + 1; shm->in = (shm->in + 1) % BUF_SIZE; printf("生产: %d (槽位 %d)\n", i + 1, shm->in);
sem_post(mutex); sem_post(full); usleep(50000); // 模拟生产耗时 } wait(NULL); }
// 清理 munmap(shm, sizeof(shared_buffer_t)); close(shm_fd); shm_unlink(SHM_NAME); sem_close(mutex); sem_unlink(SEM_MUTEX); sem_close(empty); sem_unlink(SEM_EMPTY); sem_close(full); sem_unlink(SEM_FULL); return 0;}# 编译运行(需要链接 rt 和 pthread 库)gcc -o prodcons prodcons.c -lrt -lpthread./prodcons十二、内核源码导航
本章涉及的主要内核源码文件:
| 子系统 | 源码路径 | 核心内容 |
|---|---|---|
| 信号 | kernel/signal.c | 信号发送、投递、处理的核心逻辑 |
| 信号 | include/linux/signal_types.h | sigpending、sighand_struct 定义 |
| 信号 | arch/x86/kernel/signal.c | 架构相关的信号栈帧设置 |
| 管道 | fs/pipe.c | 管道读写、环形缓冲区实现 |
| 管道 | include/linux/pipe_fs_i.h | pipe_inode_info、pipe_buffer 定义 |
| FIFO | fs/fifo.c | FIFO 的 open 操作 |
| System V 消息队列 | ipc/msg.c | 消息队列操作实现 |
| System V 共享内存 | ipc/shm.c | 共享内存操作实现 |
| System V 信号量 | ipc/sem.c | 信号量操作实现 |
| System V IPC 通用 | ipc/util.c | IPC 权限检查、通用操作 |
| POSIX 消息队列 | ipc/mqueue.c | POSIX 消息队列实现 |
| Unix 域套接字 | net/unix/af_unix.c | UDS 核心实现 |
| Unix 域套接字 | net/unix/unix_bsd.c | UDS 数据传输 |
阅读内核源码时,建议从系统调用入口开始跟踪。例如,kill 系统调用的入口在 kernel/signal.c 的 __do_kill_pg_info → kill_pid_info → group_send_sig_info → do_send_sig_info。使用 Bootlin Elixir 可以方便地在线浏览和交叉引用内核源码。
小结
本章系统性地介绍了 Linux 的信号与 IPC 机制:
-
信号是内核向进程发送的异步通知,通过
sighand_struct、sigpending位图和TIF_SIGPENDING标志实现高效的产生与投递机制。标准信号不可靠(可能丢失),实时信号可靠(排队投递)。 -
管道是最简单的数据传输 IPC,匿名管道限于亲缘进程,FIFO 通过文件系统路径扩展到任意进程。内核通过
pipe_inode_info环形缓冲区实现。 -
消息队列保留消息边界,支持按类型/优先级选择性接收。POSIX 消息队列还支持异步通知。
-
共享内存是零拷贝的最快 IPC,但必须配合同步机制使用。底层依赖 tmpfs 实现。
-
信号量是 IPC 中的同步原语,System V 支持信号量集的原子操作,POSIX API 更简洁。
-
Unix 域套接字是最通用的本地 IPC,支持双全工通信和文件描述符传递,是大多数本地进程间通信的首选。
这些机制并非孤立存在——信号可以驱动 I/O 通知,共享内存需要信号量同步,Unix 域套接字可以替代大部分其他 IPC。理解每种机制的设计权衡和适用场景,才能在实际系统编程中做出正确的选择。
参考资料
经典教材
- 《Linux 内核设计与实现》(Robert Love)第 5 章”系统调用”与第 11 章”信号”——信号机制的最佳入门参考
- 《Advanced Programming in the UNIX Environment》(W. Richard Stevens & Stephen A. Rago)第 10 章”Signals”与第 15 章”Interprocess Communication”——UNIX 信号与 IPC 的权威指南
- 《深入理解 Linux 内核》(Daniel P. Bovet 等)第 11 章”信号”——内核视角的信号实现详解
- 《UNIX 环境高级编程》(尤晋元译)——APUE 的中文译本
手册页
man 7 signal— 信号概述与信号列表man 2 sigaction— 信号处理函数注册man 7 pipe— 管道机制详解man 7 svipc— System V IPC 概述man 7 mq_overview— POSIX 消息队列概述man 7 shm_overview— POSIX 共享内存概述man 7 sem_overview— POSIX 信号量概述man 7 unix— Unix 域套接字概述man 7 signal-safety— 异步信号安全函数列表
内核源码
- kernel/signal.c — 信号核心实现
- fs/pipe.c — 管道实现
- ipc/shm.c — System V 共享内存
- ipc/msg.c — System V 消息队列
- ipc/sem.c — System V 信号量
- ipc/mqueue.c — POSIX 消息队列
- net/unix/ — Unix 域套接字
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






