mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5194 字
14 分钟
信号与进程间通信
2025-01-28

一、引言#

在前两章中,我们深入了解了进程的创建与调度——每个进程在各自的地址空间中独立运行,互不干扰。然而,现实世界中的程序绝非孤岛:Shell 需要通过管道串联命令,数据库需要用共享内存加速数据传递,Ctrl+C 需要瞬间终止前台进程……这些场景都依赖于同一套基础设施——进程间通信(Inter-Process Communication, IPC)

Linux 提供了丰富的 IPC 机制,从最轻量的信号到最通用的 Unix 域套接字,每种机制都有其适用场景和设计权衡。本章将从信号这一最古老的异步通知机制出发,逐步深入管道、消息队列、共享内存、信号量,最终以 Unix 域套接字收尾,并在最后给出 IPC 机制的选择指南。

flowchart TB subgraph 信号机制 S1[信号产生] --> S2[内核投递] S2 --> S3[信号处理] end subgraph 数据传输IPC P[管道/FIFO] --> MQ[消息队列] MQ --> SM[共享内存] end subgraph 同步机制 SEM[信号量] --> SYNC[配合共享内存] end subgraph 通用通信 UX[Unix域套接字] end S3 -.->|信号驱动I/O| P SM --> SYNC UX -.->|可替代大部分IPC| P UX -.->|可替代大部分IPC| MQ style S1 fill:#ff9999,stroke:#333 style SM fill:#99ff99,stroke:#333 style UX fill:#9999ff,stroke:#333

二、信号:异步通知机制的本质#

1.1 信号是什么?#

信号是 Linux 中最古老的 IPC 机制,本质上是内核向进程发送的一个小整数异步通知。它不携带数据(实时信号除外),仅表示”某件事发生了”。你可以把信号类比为现实生活中的门铃——门铃响了,你知道有人来了,但门铃本身不会告诉你来的是谁。

Linux 定义了多种标准信号,常见的包括:

信号编号信号名含义默认行为
1SIGHUP终端挂断终止
2SIGINT中断(Ctrl+C)终止
3SIGQUIT退出(Ctrl+\)终止 + Core
9SIGKILL强制终止终止(不可捕获)
11SIGSEGV段错误终止 + Core
13SIGPIPE管道破裂终止
14SIGALRM定时器到期终止
15SIGTERM请求终止终止
17SIGCHLD子进程状态变化忽略
18SIGCONT继续执行继续
19SIGSTOP停止执行停止(不可捕获)
28SIGWINCH终端窗口大小变化忽略
Note

SIGKILL(9)和 SIGSTOP(19)是两个不可捕获、不可阻塞、不可忽略的信号。这是内核的最终手段——当进程失控时,系统仍然能够强制终止或暂停它。

1.2 信号的产生#

信号可以从多个来源产生:

1. 用户操作

# 键盘组合键产生信号
Ctrl+C SIGINT (2) # 中断前台进程
Ctrl+\ SIGQUIT (3) # 退出并生成 core dump
Ctrl+Z SIGTSTP (20) # 暂停前台进程
# 通过 kill 命令发送信号
kill -SIGTERM 1234 # 向 PID 1234 发送 SIGTERM
kill -9 1234 # 向 PID 1234 发送 SIGKILL
kill -SIGCHLD 1234 # 向 PID 1234 发送 SIGCHLD

2. 内核事件

当进程执行了非法操作时,内核的异常处理程序会产生信号:

// 以下操作会触发内核产生信号
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
// 子进程退出 → 父进程收到 SIGCHLD

1.3 信号的处理方式#

进程对信号有三种处理方式:

  1. 执行默认行为:如终止、终止+core、忽略、停止等
  2. 忽略信号:将信号处理设置为 SIG_IGN(但 SIGKILL/SIGSTOP 不可忽略)
  3. 捕获信号:注册自定义信号处理函数

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;
}

sigactionsa_flags 支持多个重要标志:

标志作用
SA_RESTART被信号中断的系统调用自动重启
SA_SIGINFO使用 sa_sigaction 三参数处理函数,获取发送者信息
SA_NOCLDSTOP子进程停止时不产生 SIGCHLD
SA_NOCLDWAIT子进程终止时不产生僵尸进程
SA_NODEFER处理信号时不自动阻塞该信号
Warning

信号处理函数中只能调用异步信号安全的函数。许多标准库函数(如 mallocprintfexit)都不在安全列表中,因为它们可能持有内部锁,若被信号中断则可能死锁。完整的安全函数列表见 man 7 signal-safety

三、信号在内核中的表示#

2.1 信号的三种状态#

信号从产生到处理,经历三种状态:

  1. 产生(Generate):内核或进程发送信号
  2. 待决(Pending):信号已产生但尚未投递给进程
  3. 投递(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~313263(SIGRTMINSIGRTMAX)
排队方式不排队,同一信号只记录一次排队,每个信号实例都记录
投递顺序不保证顺序按 FIFO 顺序投递
携带数据不携带可携带 siginfo_t 中的 intvoid*
典型用途系统预定义事件用户自定义事件

不可靠信号丢失场景

// 假设进程阻塞了 SIGINT
// 发送 3 次 SIGINT
kill(pid, SIGINT); // 第1次:pending 位图置位
kill(pid, SIGINT); // 第2次:位图已置位,无操作 → 信号丢失!
kill(pid, SIGINT); // 第3次:位图已置位,无操作 → 信号丢失!
// 解除阻塞后,进程只会收到 1 次 SIGINT

实时信号不丢失

// 发送 3 次 SIGRTMIN
sigqueue(pid, SIGRTMIN, value1); // 排入队列
sigqueue(pid, SIGRTMIN, value2); // 排入队列
sigqueue(pid, SIGRTMIN, value3); // 排入队列
// 解除阻塞后,进程会收到 3 次 SIGRTMIN,按 FIFO 顺序
Important

在需要精确计数或不能丢失通知的场景下(如实时应用、精确事件通知),务必使用实时信号(sigqueue)而非标准信号(kill)。

四、信号的投递时机#

3.1 TIF_SIGPENDING 检查点#

信号不是在产生的瞬间就被处理的——内核在从内核态返回用户态时才检查并投递信号。这是信号机制最核心的时序设计。

flowchart TB A["进程在用户态执行"] --> B["发生系统调用/中断"] B --> C["进入内核态"] C --> D["执行内核逻辑"] D --> E{"检查 TIF_SIGPENDING"} E -->|无待决信号| F["返回用户态继续执行"] E -->|有待决信号| G["do_signal()"] G --> H{"信号被阻塞?"} H -->|是| F H -->|否| I{"处理方式?"} I -->|默认行为| J["执行默认动作"] I -->|忽略| F I -->|捕获| K["设置信号栈帧<br/>修改返回地址"] K --> L["返回用户态<br/>跳转到信号处理函数"] L --> M["信号处理函数执行完毕"] M --> N["sigreturn() 系统调用"] N --> O["恢复原始上下文"] O --> F style E fill:#ffcc00,stroke:#333 style K fill:#ff9999,stroke:#333 style N fill:#99ff99,stroke:#333

关键步骤详解

  1. 设置标志:当信号产生时,内核在目标进程的 thread_info.flags 中设置 TIF_SIGPENDING 标志
  2. 检查时机:内核在以下路径检查该标志:
    • 系统调用返回用户态前(syscall_exit_work
    • 中断返回用户态前(prepare_exit_to_usermode
    • 从中断/异常返回前
  3. 投递信号:若 TIF_SIGPENDING 置位,调用 do_signal()get_signal() 逐个处理待决信号
  4. 设置栈帧:若信号被捕获,内核在用户态栈上构建一个 sigframe 结构,包含:
    • 信号编号
    • 指向 siginfo_t 的指针
    • 被中断的上下文(寄存器状态)
    • 返回地址指向 __restore_rt(信号处理函数返回后执行 rt_sigreturn
  5. 修改返回地址:将用户态的指令指针修改为信号处理函数的入口
  6. 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; // 内核自动重启被中断的系统调用
Tip

在编写信号处理程序时,务必考虑系统调用被中断的情况。要么使用 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;
}

管道的核心特性

  1. 半双工:数据只能单向流动(从写端到读端)
  2. 亲缘关系:只能在有亲缘关系的进程间使用(fork 后共享文件描述符)
  3. 字节流:无消息边界,读取端无法区分写入端的写入次数
  4. 阻塞 I/O:管道空时 read 阻塞,管道满时 write 阻塞
  5. SIGPIPE:读端全部关闭后写入,写进程收到 SIGPIPE 信号
  6. 缓冲区大小:自 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(命名管道)通过文件系统中的特殊文件打破了这一限制:

# 创建 FIFO
mkfifo /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() 返回 fdopen() 打开路径
内核实现相同(pipe_inode_info相同
Note

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 类型的 mtype
struct 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 VPOSIX
标识方式整数 ID名称字符串(以 / 开头)
消息优先级按 mtype 选择性接收按优先级排序投递
异步通知支持 mq_notify()
非阻塞模式IPC_NOWAIT 标志O_NONBLOCK 标志
查看工具ipcs -q查看 /dev/mqueue/
可移植性较广较新系统
Tip

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); // 解锁
Warning

共享内存是”最快的 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); // 初始值1
sem_wait(sem); // P 操作
// ... 临界区 ...
sem_post(sem); // V 操作
sem_close(sem);
sem_unlink("/my_sem");
// ===== 无名信号量(可放在共享内存中) =====
sem_t sem;
sem_init(&sem, 1, 1); // 第二个参数1=进程间共享,初始值1
sem_wait(&sem);
// ... 临界区 ...
sem_post(&sem);
sem_destroy(&sem);

System V vs POSIX 信号量对比

特性System VPOSIX
操作单位信号量集(多个)单个信号量
原子操作支持多信号量原子操作单个 P/V
创建方式semget + semctlsem_opensem_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 域套接字的独特优势#

  1. 支持传递文件描述符:通过 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);
  1. 支持传递安全上下文(SCM_CREDENTIALS):可以传递发送进程的 PID、UID、GID
  2. 双全工通信:支持 SOCK_STREAM(字节流)和 SOCK_DGRAM(数据报)两种模式
  3. 无需序列化:对于本地通信,不需要考虑字节序等网络问题

8.3 内核实现#

Unix 域套接字的内核实现位于 net/unix/ 目录。SOCK_STREAM 模式底层使用类似管道的环形缓冲区,SOCK_DGRAM 模式使用消息队列。由于不经过网络协议栈,数据路径极短:

TCP 套接字数据路径:
用户空间 → 系统调用 → TCP → IP → 链路层 → ... → 对端
Unix 域套接字数据路径:
用户空间 → 系统调用 → unix_stream_sendmsg() → 对端缓冲区

十、IPC 机制选择指南#

面对如此多的 IPC 机制,如何选择?以下是综合对比:

flowchart LR Q1{"需要传输数据?"} Q1 -->|否,只需通知| SIG["信号<br/>(异步通知)"] Q1 -->|是| Q2{"通信双方关系?"} Q2 -->|亲缘进程| Q3{"数据特征?"} Q2 -->|任意进程| Q4{"性能要求?"} Q3 -->|字节流| PIPE["匿名管道<br/>(pipe)"] Q3 -->|需要消息边界| MQ1["消息队列"] Q4 -->|极致性能| Q5{"数据量?"} Q4 -->|通用性优先| UDS["Unix域套接字"] Q5 -->|大量数据| SHM["共享内存<br/>+ 信号量同步"] Q5 -->|少量数据| FIFO["命名管道/FIFO<br/>或 消息队列"] style SIG fill:#ff9999,stroke:#333 style SHM fill:#99ff99,stroke:#333 style UDS fill:#9999ff,stroke:#333 style PIPE fill:#ffcc99,stroke:#333

9.1 延迟对比#

IPC 机制典型延迟说明
信号~1-2 μs仅通知,不传数据
管道/FIFO~5-10 μs两次数据拷贝
消息队列~5-15 μs两次拷贝 + 消息管理
Unix 域套接字~5-10 μs类似管道
共享内存~0.1-0.5 μs零拷贝(不含同步开销)
Note

以上延迟为大致参考值,实际性能受 CPU 架构、内核版本、数据大小等因素影响。共享内存的延迟优势在数据量大时尤为明显。

9.2 综合对比表#

特性信号管道FIFO消息队列共享内存信号量Unix域套接字
传输数据字节流字节流消息任意字节流/数据报
方向单向单向单向双向双向-双向
亲缘要求必须
同步需求内核同步内核同步内核同步需自备本身即同步内核同步
持久性随进程随文件随内核随内核随内核随进程
传文件描述符
复杂度

9.3 选择建议#

  1. 只需要通知,不传数据 → 信号
  2. 父子进程间传数据 → 匿名管道
  3. 无亲缘关系,简单数据流 → FIFO 或 Unix 域套接字
  4. 需要消息边界或选择性接收 → 消息队列
  5. 大量数据,追求极致性能 → 共享内存 + 信号量
  6. 需要传文件描述符 → Unix 域套接字
  7. 需要通用、可扩展的通信 → 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#

# 创建 FIFO
mkfifo /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.hsigpendingsighand_struct 定义
信号arch/x86/kernel/signal.c架构相关的信号栈帧设置
管道fs/pipe.c管道读写、环形缓冲区实现
管道include/linux/pipe_fs_i.hpipe_inode_infopipe_buffer 定义
FIFOfs/fifo.cFIFO 的 open 操作
System V 消息队列ipc/msg.c消息队列操作实现
System V 共享内存ipc/shm.c共享内存操作实现
System V 信号量ipc/sem.c信号量操作实现
System V IPC 通用ipc/util.cIPC 权限检查、通用操作
POSIX 消息队列ipc/mqueue.cPOSIX 消息队列实现
Unix 域套接字net/unix/af_unix.cUDS 核心实现
Unix 域套接字net/unix/unix_bsd.cUDS 数据传输
Tip

阅读内核源码时,建议从系统调用入口开始跟踪。例如,kill 系统调用的入口在 kernel/signal.c__do_kill_pg_infokill_pid_infogroup_send_sig_infodo_send_sig_info。使用 Bootlin Elixir 可以方便地在线浏览和交叉引用内核源码。

小结#

本章系统性地介绍了 Linux 的信号与 IPC 机制:

  1. 信号是内核向进程发送的异步通知,通过 sighand_structsigpending 位图和 TIF_SIGPENDING 标志实现高效的产生与投递机制。标准信号不可靠(可能丢失),实时信号可靠(排队投递)。

  2. 管道是最简单的数据传输 IPC,匿名管道限于亲缘进程,FIFO 通过文件系统路径扩展到任意进程。内核通过 pipe_inode_info 环形缓冲区实现。

  3. 消息队列保留消息边界,支持按类型/优先级选择性接收。POSIX 消息队列还支持异步通知。

  4. 共享内存是零拷贝的最快 IPC,但必须配合同步机制使用。底层依赖 tmpfs 实现。

  5. 信号量是 IPC 中的同步原语,System V 支持信号量集的原子操作,POSIX API 更简洁。

  6. 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 — 异步信号安全函数列表

内核源码#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

信号与进程间通信
https://blog.souloss.com/posts/linux-internals/signals-and-ipc/
作者
Souloss
发布于
2025-01-28
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时