从零开始学习嵌入式P30----进程间的通信
上一篇我们学会了用互斥锁和信号量协调同一进程内的多个线程,最后留下一个问题:线程共享同一份内存,通信零成本;可进程之间彼此隔离 ------每个进程都有自己独立的虚拟地址空间,进程 A 的全局变量,进程 B 根本看不见。那进程之间怎么交换数据?这就是本篇的主题:进程间通信(IPC) 。我们将依次学会五种机制:管道 (无名管道和有名管道)、信号 、消息队列 、共享内存 和信号量集。它们各有分工:管道像一根水管,信号像一声门铃,消息队列像邮箱,共享内存像一块公共黑板,信号量则是给黑板上锁的钥匙。
本篇目标
学完这一篇,我们应该能够:
- 说清进程间通信的五种常用方式,以及各自适合什么场景;
- 用
pipe在父子进程之间传递数据,说清无名管道的使用限制; - 用
mkfifo+open/read/write让两个毫不相干的进程互相通信; - 用
signal注册信号处理函数,用kill/alarm发信号,用pause挂起等待; - 说清 IPC 对象的 key 是怎么来的(
ftok),会用ipcs/ipcrm查看和删除; - 用
msgget/msgsnd/msgrcv/msgctl收发带类型的消息; - 用
shmget/shmat/shmdt/shmctl共享一块内存,并说清它为什么最快; - 用
semget/semctl/semop在进程间做互斥与同步。
一、进程间通信的方式
进程和线程最大的区别在于:进程拥有独立的地址空间。线程间写一个全局变量另一个立刻就能看到,进程间却不行------变量名相同也没用,它们住在各自的房子里。所以操作系统专门提供了一批"跨进程搬运数据"的机制:
| 方式 | 一句话理解 | 特点 |
|---|---|---|
| 无名管道 | 一根单向水管 | 只能用于有亲缘关系的进程(如父子进程) |
| 有名管道 | 有名字的水管 | 任意两个进程都能用 |
| 信号 | 门铃 | 只传"事件发生了",不传数据 |
| 消息队列 | 邮箱 | 按类型收发消息,有格式 |
| 共享内存 | 公共黑板 | 最快,但需要自己配合同步 |
| 信号量集 | 黑板旁的钥匙 | 本身不传数据,负责给共享资源立规矩 |
下面挨个拆开看。
二、无名管道
1. 什么是管道
管道是内核里的一块缓冲区,行为像一根单向的水管:一端进水(写),另一端出水(读)。无名管道没有文件名,看不见摸不着,只能在创建它的进程和它的后代之间使用。
2. 函数接口
c
int pipe(int pipefd[2]);
- 功能:创建无名管道,通过参数带出两个文件描述符;
- 参数 :
pipefd[0]:读端;pipefd[1]:写端;
- 返回值 :成功返回
0,失败返回-1。
记忆口诀:0 读 1 写------刚好和 stdin(0)、stdout(1)的"0 入 1 出"同构。
3. 使用套路与限制
无名管道的经典用法是配合 fork:先 pipe 再 fork,管道随文件描述符表一起被复制到子进程,父子各留一端、关掉另一端,就搭好了一条单向通道:
c
#include "head.h"
char tmpbuff[4096] = {0};
int main(void)
{
pid_t pid;
int pipefd[2];
ssize_t nret = 0;
if (-1 == pipe(pipefd))
{
perror("fail to pipe");
return -1;
}
pid = fork();
if (-1 == pid)
{
perror("fail to fork");
return -1;
}
if (0 == pid)
{
close(pipefd[0]); // 子进程只写,关掉读端
strcpy(tmpbuff, "hello world");
write(pipefd[1], tmpbuff, strlen(tmpbuff) + 1);
close(pipefd[1]);
}
else
{
close(pipefd[1]); // 父进程只读,关掉写端
nret = read(pipefd[0], tmpbuff, sizeof(tmpbuff));
printf("接收到 %ld 字节数据, tmpbuff = %s\n", nret, tmpbuff);
close(pipefd[0]);
}
return 0;
}
无名管道的几个硬限制,必须记牢:
- 只能用于有亲缘关系的进程(父子、兄弟),因为它没有名字,外人拿不到它的文件描述符;
- 半双工 :数据只能朝一个方向流,
pipefd[1]进、pipefd[0]出,不能反着来; - 内核缓冲区大小有限(默认 64K),写满后
write会阻塞; - 读端没有数据时
read阻塞 ;写端全部关闭后read返回 0(相当于读到 EOF)。
如果两个进程八竿子打不着(比如两个独立启动的程序),无名管道就无能为力了------这时要用有名管道。
三、有名管道
1. 函数接口
有名管道(FIFO)在文件系统里有一个路径名 ,任何进程只要知道这个名字就能打开它,因此可以用于任意两个进程之间。
c
int mkfifo(const char *pathname, mode_t mode);
- 功能 :创建一个有名管道文件(文件类型为
p); - 参数 :
pathname:管道文件路径;mode:权限,如0664;
- 返回值 :成功返回
0,失败返回-1(管道已存在时也会返回失败)。
创建之后,对它的操作和普通文件完全一样:open 打开、read / write 读写、close 关闭。要注意一个特性:以只读方式 open 会阻塞,直到有进程以写方式打开它 ;反之亦然。也就是说 open 本身就承担了"等对方上线"的握手功能。
2. 两个独立进程互相喊话
读端程序负责创建管道并读数据:
c
#include "head.h"
int main(void)
{
int fd = 0;
char tmpbuff[4096] = {0};
mkfifo("/tmp/myfifo", 0664);
fd = open("/tmp/myfifo", O_RDONLY);
if (-1 == fd)
{
perror("fail to open");
return -1;
}
printf("open fifo for reading success\n");
read(fd, tmpbuff, sizeof(tmpbuff));
printf("tmpbuff = %s\n", tmpbuff);
close(fd);
return 0;
}
写端程序打开同一个管道写数据:
c
#include "head.h"
int main(void)
{
int fd = 0;
char tmpbuff[4096] = {0};
mkfifo("/tmp/myfifo", 0664);
fd = open("/tmp/myfifo", O_WRONLY);
if (-1 == fd)
{
perror("fail to open");
return -1;
}
printf("open fifo for writing success\n");
gets(tmpbuff);
write(fd, tmpbuff, strlen(tmpbuff));
close(fd);
return 0;
}
两边都调用 mkfifo 是因为无法保证谁先运行------已存在时 mkfifo 失败无妨,open 照样能用。先运行的一方会阻塞在 open 上,直到另一方也打开管道,双方"握手"成功后数据才流动。
3. 练习:用两条管道实现双向聊天
一条管道是单向的,要实现"你一句我一句"的双向聊天,就用两条管道 :ATOB 负责 A 发给 B,BTOA 负责 B 发给 A。
A 端:先写后读------
c
#include "head.h"
int main(void)
{
int fatob = 0;
int fbtoa = 0;
char tmpbuff[1024] = {0};
mkfifo("/tmp/ATOB", 0664);
mkfifo("/tmp/BTOA", 0664);
fatob = open("/tmp/ATOB", O_WRONLY);
fbtoa = open("/tmp/BTOA", O_RDONLY);
while (1)
{
memset(tmpbuff, 0, sizeof(tmpbuff));
gets(tmpbuff);
write(fatob, tmpbuff, strlen(tmpbuff));
memset(tmpbuff, 0, sizeof(tmpbuff));
read(fbtoa, tmpbuff, sizeof(tmpbuff));
printf("RECV:%s\n", tmpbuff);
}
close(fatob);
close(fbtoa);
return 0;
}
B 端把读写方向整个反过来:先读后写------
c
#include "head.h"
int main(void)
{
int fatob = 0;
int fbtoa = 0;
char tmpbuff[1024] = {0};
mkfifo("/tmp/ATOB", 0664);
mkfifo("/tmp/BTOA", 0664);
fatob = open("/tmp/ATOB", O_RDONLY);
fbtoa = open("/tmp/BTOA", O_WRONLY);
while (1)
{
memset(tmpbuff, 0, sizeof(tmpbuff));
read(fatob, tmpbuff, sizeof(tmpbuff));
printf("RECV:%s\n", tmpbuff);
memset(tmpbuff, 0, sizeof(tmpbuff));
gets(tmpbuff);
write(fbtoa, tmpbuff, strlen(tmpbuff));
}
close(fatob);
close(fbtoa);
return 0;
}
注意两端 open 的顺序也是镜像的:A 先开 ATOB 写端(会阻塞),B 先开 ATOB 读端------两边正好解开彼此的阻塞。如果两端 open 顺序写成一样,就会互相等对方先开,卡死在 open 上,这是有名管道最容易踩的坑。
四、信号
1. 什么是信号
管道能传数据,信号则只负责"通知":信号是软件层的异步事件,就像门铃------它只告诉你"有人来了",不带包裹。进程收到信号后,会打断当前的执行流,转去执行预设的处理动作,处理完再回到原来的地方继续。
常见的信号(kill -l 可以看到全部):
| 信号 | 触发方式 | 默认动作 |
|---|---|---|
SIGINT |
终端按 Ctrl + C |
终止进程 |
SIGQUIT |
终端按 Ctrl + \ |
终止进程 |
SIGTSTP |
终端按 Ctrl + Z |
暂停进程 |
SIGKILL |
kill -9 PID |
终止进程(不可捕获、不可忽略) |
SIGSTOP |
--- | 暂停进程(不可捕获、不可忽略) |
SIGSEGV |
段错误(非法内存访问) | 终止进程 |
SIGPIPE |
往读端已关闭的管道写数据 | 终止进程 |
SIGALRM |
alarm 定时器到期 |
终止进程 |
SIGCHLD |
子进程状态改变 | 忽略 |
SIGUSR1 / SIGUSR2 |
用户自定义 | 终止进程 |
每个信号有三种处理方式:执行默认动作 、忽略 、捕获 (执行自己写的处理函数)。SIGKILL 和 SIGSTOP 是特权信号,只能执行默认动作------系统必须留一条能强行干掉任何进程的后路。
2. 注册信号处理函数:signal
c
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 功能:为某个信号注册处理方式;
- 参数 :
signum:信号编号(如SIGINT);handler:处理方式,可以是自定义函数、SIG_IGN(忽略)或SIG_DFL(恢复默认);
- 返回值 :成功返回之前的处理方式,失败返回
SIG_ERR。
下面这个程序注册了自己的 SIGINT 处理函数,从此按 Ctrl + C 杀不掉它,只会打印一句话(想退出可以按 Ctrl + \ 发 SIGQUIT):
c
#include "head.h"
void handler(int signo)
{
printf("信号处理函数被执行\n");
return;
}
int main(void)
{
signal(SIGINT, handler);
while (1)
{
printf("正在执行\n");
sleep(1);
}
return 0;
}
一个处理函数也可以同时服务多个信号,靠参数 signo 区分来的是谁:
c
#include "head.h"
void handler(int signo)
{
if (SIGINT == signo)
{
printf("SIGINT信号来了!\n");
}
else if (SIGQUIT == signo)
{
printf("SIGQUIT信号来了!\n");
}
else if (SIGTSTP == signo)
{
printf("SIGTSTP信号来了!\n");
}
return;
}
int main(void)
{
signal(SIGINT, handler);
signal(SIGQUIT, handler);
signal(SIGTSTP, handler);
while (1)
{
}
return 0;
}
3. pause:挂起进程等信号
c
int pause(void);
- 功能 :让进程挂起(睡眠) ,直到收到任意一个信号才会返回。进程挂起时不占用 CPU,这是它比
while(1)空转高明的地方。
c
#include "head.h"
void handler(int signo)
{
return;
}
int main(void)
{
signal(SIGINT, handler);
printf("pause上面\n");
pause();
printf("pause下面\n");
return 0;
}
运行后停在 pause 上一动不动,按一次 Ctrl + C,处理函数执行完,pause 返回,才打印出"pause下面"。注意:如果信号的处理方式是默认动作(终止进程),进程直接就死了,根本没机会从 pause 返回------所以 pause 常和自定义处理函数搭配使用。
4. alarm:给自己定闹钟
c
unsigned int alarm(unsigned int seconds);
- 功能 :
seconds秒之后,内核向本进程发送一个SIGALRM信号。alarm(0)表示取消之前定的闹钟。
利用它可以在处理函数里再定一次闹钟,实现"每 5 秒干一次活":
c
#include "head.h"
void handler(int signo)
{
printf("SIGALRM come\n");
alarm(5); // 再定下一次
return;
}
int main(void)
{
signal(SIGALRM, handler);
alarm(5);
while (1)
{
}
return 0;
}
5. kill:向指定进程发信号
c
int kill(pid_t pid, int sig);
- 功能 :向进程号为
pid的进程发送sig信号(命令行里的kill命令就是它的外壳)。
下面这个程序用 fork 造出父子进程,让它们互相发信号通信:子进程收到 Ctrl + \(SIGQUIT)时给父进程发 SIGUSR1;父进程收到 Ctrl + C(SIGINT)时给子进程发 SIGUSR2。两边各自把"发给自己用的按键信号"忽略掉,只响应对方发来的自定义信号:
c
#include "head.h"
pid_t pid;
void handler_child(int signo)
{
if (SIGQUIT == signo)
{
kill(getppid(), SIGUSR1);
}
else if (SIGUSR2 == signo)
{
printf("收到父进程信号了!\n");
}
return;
}
void handler_parent(int signo)
{
if (SIGINT == signo)
{
kill(pid, SIGUSR2);
}
else if (SIGUSR1 == signo)
{
printf("收到子进程信号了!\n");
}
return;
}
int main(void)
{
pid = fork();
if (-1 == pid)
{
perror("fail to fork");
return -1;
}
if (0 == pid)
{
signal(SIGUSR2, handler_child);
signal(SIGQUIT, handler_child);
signal(SIGINT, SIG_IGN);
}
else if (pid > 0)
{
signal(SIGUSR1, handler_parent);
signal(SIGINT, handler_parent);
signal(SIGQUIT, SIG_IGN);
}
while (1)
{
}
return 0;
}
fork 之后 pid 在父进程里是子进程的 PID,子进程里则用 getppid() 拿父进程 PID------两个方向的路都通,信号就能一来一回。
6. 练习:用 alarm + pause 手写一个 MySleep
把上面几件兵器组合起来,就能实现一个自己的 sleep:定个闹钟,然后挂起等它响,响完把信号处理方式恢复原样:
c
#include "head.h"
void handler(int signo)
{
return;
}
void MySleep(int seconds)
{
signal(SIGALRM, handler);
alarm(seconds);
pause();
signal(SIGALRM, SIG_DFL);
return;
}
int main(void)
{
printf("sleep上面\n");
MySleep(5);
printf("sleep下面\n");
return 0;
}
逻辑非常直白:alarm(5) 让内核 5 秒后送一个 SIGALRM;pause() 挂起等它;信号到了,处理函数空跑一圈,pause 返回;最后把 SIGALRM 恢复成默认处理,避免影响调用者。这个小程序是理解"信号 + 挂起等待"配合的最佳标本。
五、System V IPC 对象:key 与管理命令
从消息队列开始,后面的三种机制(消息队列、共享内存、信号量集)有一个共同的身份:System V IPC 对象 。它们由内核统一管理,不属于任何进程------创建它的进程退出了,对象还在,直到显式删除。
1. 查看与删除:ipcs / ipcrm
ipcs -q/-m/-s:分别查看消息队列、共享内存、信号量;ipcrm -Q key/-M key/-S key:按 key 删除对应 IPC 对象;ipcrm -q id/-m id/-s id:按 ID 删除。
程序崩溃没来得及清理时,就靠这两条命令"擦屁股"。
2. ftok:两个进程怎么找到同一个对象
无名管道靠继承,有名管道靠路径名,IPC 对象靠 key ------两个进程必须算出同一个 key ,才能拿到同一个消息队列/共享内存/信号量。ftok 就是约定的"暗号生成器":
c
key_t ftok(const char *pathname, int proj_id);
- 功能:由一个已存在的文件路径和一个 8 位的项目 ID,算出一个 IPC key;
- 参数 :
pathname:一个真实存在的文件路径(两边必须写同一个);proj_id:项目 ID(低 8 位有效,两边必须写同一个);
- 返回值 :成功返回 key,失败返回
-1。
只要两个进程用同样的 (pathname,proj_id) 去 ftok,算出的 key 必然相同------这就是它们接头的方式。
六、消息队列
1. 什么是消息队列
消息队列是内核里的一条消息链表 ,像邮箱:发送方把消息打包、贴上"类型标签"扔进去,接收方按标签取自己关心的消息,不必按先来后到的顺序。相比管道,它有类型、有边界、可以双向,也不用管打开关闭那一套。
使用套路固定四步半:ftok 生成 key → msgget 创建/获取队列 → msgsnd 发 / msgrcv 收 → msgctl 销毁。
2. 函数接口
c
int msgget(key_t key, int msgflg);
- 功能:按 key 创建(或获取已存在的)消息队列,返回队列 ID;
- 参数 :
msgflg传IPC_CREAT | 0664表示"不存在就创建,权限 0664";加上IPC_EXCL则表示"已存在就报错"; - 返回值 :成功返回队列 ID,失败返回
-1。
c
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
- 功能:向队列发送一条消息;
- 参数 :
msgp:消息结构体地址,第一个成员必须是long mtype(消息类型,必须 > 0);msgsz:消息正文 的字节数(不含mtype);msgflg:0表示队列满时阻塞;
- 返回值 :成功返回
0,失败返回-1。
c
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
- 功能:从队列接收消息;
- 参数 :
msgtyp指定收哪种类型:0收队列中第一条,> 0收该类型的第一条; - 返回值 :成功返回收到的正文字节数 ,失败返回
-1。
c
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
- 功能 :控制消息队列,
cmd传IPC_RMID表示删除队列,buf传NULL。
3. 发一条再收回来
c
#include "head.h"
typedef struct msgbuf {
long mtype; // 消息类型,必须 > 0
char mtext[256]; // 消息正文
} msgbuf_t;
int main(void)
{
key_t key;
int msgid = 0;
msgbuf_t sendmsg;
msgbuf_t recvmsg;
ssize_t nret = 0;
key = ftok("/tmp", 'a');
if (-1 == key)
{
perror("fail to ftok");
return -1;
}
msgid = msgget(key, IPC_CREAT | 0664);
if (-1 == msgid)
{
perror("fail to msgget");
return -1;
}
sendmsg.mtype = 100L;
strcpy(sendmsg.mtext, "hello world");
if (-1 == msgsnd(msgid, &sendmsg, sizeof(sendmsg.mtext), 0))
{
perror("fail to msgsnd");
return -1;
}
nret = msgrcv(msgid, &recvmsg, sizeof(recvmsg.mtext), 100L, 0);
if (-1 == nret)
{
perror("fail to msgrcv");
return -1;
}
printf("接收%ld字节, 内容:%s\n", nret, recvmsg.mtext);
msgctl(msgid, IPC_RMID, NULL);
return 0;
}
同一个程序里先发后收,只是为了看清流程;实际使用中发送和接收在两个进程里。注意 msgsnd 的第三个参数是 sizeof(sendmsg.mtext)------只算正文,不能把 mtype 也算进去。
4. 练习:两个进程一收一发
把收发拆成两个独立程序。发送端从键盘读一行,打包成 100 号类型发出:
c
#include "head.h"
typedef struct msg {
long mtype;
char mtext[256];
} msg_t;
int main(void)
{
key_t key;
int msgid = 0;
msg_t sendmsg;
key = ftok("/tmp", 'a');
if (-1 == key)
{
perror("fail to ftok");
return -1;
}
msgid = msgget(key, IPC_CREAT | 0664);
if (-1 == msgid)
{
perror("fail to msgget");
return -1;
}
sendmsg.mtype = 100L;
gets(sendmsg.mtext);
if (-1 == msgsnd(msgid, &sendmsg, sizeof(sendmsg.mtext), 0))
{
perror("fail to msgsnd");
return -1;
}
return 0;
}
接收端按 100 号类型取消息,取完顺手销毁队列:
c
#include "head.h"
typedef struct msg {
long mtype;
char mtext[256];
} msg_t;
int main(void)
{
key_t key;
int msgid = 0;
msg_t recvmsg;
ssize_t nret = 0;
key = ftok("/tmp", 'a');
if (-1 == key)
{
perror("fail to ftok");
return -1;
}
msgid = msgget(key, IPC_CREAT | 0664);
if (-1 == msgid)
{
perror("fail to msgget");
return -1;
}
nret = msgrcv(msgid, &recvmsg, sizeof(recvmsg.mtext), 100L, 0);
if (-1 == nret)
{
perror("fail to msgrcv");
return -1;
}
printf("RECV:%s\n", recvmsg.mtext);
msgctl(msgid, IPC_RMID, NULL);
return 0;
}
先跑接收端再跑发送端:msgrcv 会阻塞在队列前等消息,发送端一扔进来,接收端立刻打印。两边 ftok 参数一致、mtype 一致,就接上了头。
七、共享内存
1. 什么是共享内存
消息队列每次收发都要在用户态和内核态之间拷贝数据。共享内存则干脆得多:内核划出一块物理内存,同时映射到多个进程的地址空间 ,进程像访问自己的数组一样直接读写它------数据不需要拷贝,因此它是最快的 IPC 方式。
但快是有代价的:共享内存本身没有任何同步机制,一个进程在写、另一个进程同时在读,就会读到写了一半的数据。所以共享内存常和信号量搭配使用(下一节的主角)。
使用套路:ftok → shmget 创建 → shmat 映射 → 读写 → shmdt 解除映射 → shmctl 销毁。
2. 函数接口
c
int shmget(key_t key, size_t size, int shmflg);
- 功能 :按 key 创建(或获取)共享内存,大小为
size字节; - 参数 :
shmflg传IPC_CREAT | 0664; - 返回值 :成功返回共享内存 ID,失败返回
-1。
c
void *shmat(int shmid, const void *shmaddr, int shmflg);
- 功能 :把共享内存映射到本进程的地址空间(attach);
- 参数 :
shmaddr传NULL让内核挑地址,shmflg传0(读写); - 返回值 :成功返回映射后的首地址,失败返回
(void *)-1。
c
int shmdt(const void *shmaddr);
- 功能 :解除映射(detach)。注意:解除映射不等于删除,内存块还在。
c
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 功能 :控制共享内存,
IPC_RMID表示删除,buf传NULL。
3. 进程间直接读写同一块内存
写端程序:创建、映射、不断把键盘输入写到共享内存里,输入 .quit 退出:
c
#include "head.h"
int main(void)
{
key_t key;
int shmid = 0;
char *pshmaddr = NULL;
key = ftok("/tmp", 'a');
if (-1 == key)
{
perror("fail to ftok");
return -1;
}
shmid = shmget(key, 4096, IPC_CREAT | 0664);
if (-1 == shmid)
{
perror("fail to shmget");
return -1;
}
pshmaddr = shmat(shmid, NULL, 0);
if (NULL == pshmaddr)
{
perror("fail to shmat");
return -1;
}
while (1)
{
gets(pshmaddr);
if (0 == strcmp(pshmaddr, ".quit"))
{
break;
}
}
shmdt(pshmaddr);
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
读端程序:映射同一块内存,循环打印里面的内容,看到 .quit 退出:
c
#include "head.h"
int main(void)
{
key_t key;
int shmid = 0;
char *pshmaddr = NULL;
key = ftok("/tmp", 'a');
if (-1 == key)
{
perror("fail to ftok");
return -1;
}
shmid = shmget(key, 4096, IPC_CREAT | 0664);
if (-1 == shmid)
{
perror("fail to shmget");
return -1;
}
pshmaddr = shmat(shmid, NULL, 0);
if (NULL == pshmaddr)
{
perror("fail to shmat");
return -1;
}
while (1)
{
if (0 == strcmp(pshmaddr, ".quit"))
{
break;
}
printf("SHMADDR:%s\n", pshmaddr);
}
shmdt(pshmaddr);
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
两个进程没有任何收/发调用,写端一改 pshmaddr 指向的内容,读端立刻就能读到------零拷贝,这就是共享内存快的原因。同时也能直观看到它的缺点:读端靠死循环反复打印,内容没变化时也在刷屏,完全没有"对方写完了吗"的协调机制------这正是信号量要补的位。
八、信号量集
1. 从线程信号量到进程信号量
上一篇学的 sem_init / sem_wait / sem_post 是 POSIX 信号量,服务于同一进程内的线程。System V 也提供了一套进程间可用的信号量集 :一次可以创建一组信号量(信号灯),申请和释放通过"操作结构体"描述。
先记住三个接口的分工:
semget:创建信号量集;semctl:控制------给某个信号量赋初值、删除整个集合;semop:申请(-1)和释放(+1) ------相当于sem_wait和sem_post的合体。
2. 函数接口
c
int semget(key_t key, int nsems, int semflg);
- 功能 :按 key 创建一个包含
nsems个信号量的集合; - 返回值 :成功返回信号量集 ID,失败返回
-1。
c
int semctl(int semid, int semnum, int cmd, ...);
- 功能 :控制信号量集中编号为
semnum(从 0 开始)的信号量; - 参数 :
cmd常用SETVAL(设置初值,通过第四个参数传入)和IPC_RMID(删除集合); - 返回值 :失败返回
-1。
第四个参数的类型 union semun 需要自己定义(内核头文件不提供):
c
union semun {
int val; /* SETVAL 用的值 */
struct semid_ds *buf; /* IPC_STAT / IPC_SET 用 */
unsigned short *array; /* GETALL / SETALL 用 */
};
c
int semop(int semid, struct sembuf *sops, size_t nsops);
- 功能 :对信号量集做一组操作,
nsops是操作个数; - 参数 :
sops指向struct sembuf数组,每个元素描述一次操作:
c
struct sembuf {
unsigned short sem_num; /* 操作第几个信号量 */
short sem_op; /* -1 申请(P 操作),+1 释放(V 操作) */
short sem_flg; /* 通常用 SEM_UNDO:进程退出时自动撤销操作 */
};
sem_op 为正/负的含义和上一篇信号量的 +1/-1 完全对应:-1 是申请,申请不到就阻塞;+1 是释放,唤醒等待者。
3. 走一遍完整的申请释放流程
创建一个包含 2 个信号量的集合:0 号当"读信号量"(初值 0),1 号当"写信号量"(初值 1),然后演示申请写、释放读、申请读的过程:
c
#include "head.h"
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
int main(void)
{
key_t key;
int semid = 0;
union semun myun;
struct sembuf mybuf;
key = ftok("/tmp", 'a');
if (-1 == key)
{
perror("fail to ftok");
return -1;
}
semid = semget(key, 2, IPC_CREAT | 0664);
if (-1 == semid)
{
perror("fail to semget");
return -1;
}
myun.val = 0;
semctl(semid, 0, SETVAL, myun); // 读信号量初值 0
myun.val = 1;
semctl(semid, 1, SETVAL, myun); // 写信号量初值 1
// 申请写信号量:1 号,-1
mybuf.sem_num = 1;
mybuf.sem_op = -1;
mybuf.sem_flg = SEM_UNDO;
if (-1 == semop(semid, &mybuf, 1))
{
perror("fail to semop");
return -1;
}
printf("申请到写信号量了!\n");
// 释放读信号量:0 号,+1
mybuf.sem_num = 0;
mybuf.sem_op = +1;
mybuf.sem_flg = SEM_UNDO;
if (-1 == semop(semid, &mybuf, 1))
{
perror("fail to semop");
return -1;
}
printf("释放读信号量了!\n");
// 申请读信号量:0 号,-1(刚释放过,能申请到)
mybuf.sem_num = 0;
mybuf.sem_op = -1;
mybuf.sem_flg = SEM_UNDO;
if (-1 == semop(semid, &mybuf, 1))
{
perror("fail to semop");
return -1;
}
printf("申请到读信号量了!\n");
return 0;
}
如果把第二次"申请读信号量"再复制一份接着调用,进程就会阻塞 在那里------读信号量的计数已经归 0,没有资源可申请。把"申请读"想象成上一篇 sem_wait,把"释放读"想象成 sem_post,整套语义完全平移,只是换了一套更啰嗦的接口。
实战中,信号量集最常见的岗位就是站在共享内存旁边:写进程"申请写 → 写共享内存 → 释放读",读进程"申请读 → 读共享内存 → 释放写",初值一 1 一 0,两个进程严格交替------这和上一篇双信号量收发练习的骨架一模一样,只是从线程搬到了进程。
九、常见错误
1. 无名管道父子两端方向搞反
c
// 父进程想读,却关掉了读端
close(pipefd[0]);
read(pipefd[0], buf, sizeof(buf)); // 永远读不到
pipefd[0] 是读端、pipefd[1] 是写端,各留一端、关掉另一端之前先想清楚"我这个进程是读还是写"。两端都关成同一个方向,管道就成了摆设。
2. 有名管道两端 open 顺序写成一样
c
// A: open(ATOB, O_WRONLY); open(BTOA, O_RDONLY);
// B: open(ATOB, O_WRONLY); open(BTOA, O_RDONLY); // 双方都等对方先开,死锁
双向通信时,两端的 open 顺序必须是镜像的:一个先开 1 号管道的写、另一个就先开 1 号管道的读,互相解开对方的阻塞。
3. 以为 signal 注册后能拦截 SIGKILL
SIGKILL 和 SIGSTOP 不可捕获、不可忽略 ,signal(SIGKILL, handler) 不会报错但也绝不会生效。想"优雅退出",应该捕获 SIGTERM 或 SIGINT,在处理函数里做清理。
4. msgsnd 把 mtype 也算进了消息长度
c
msgsnd(msgid, &msg, sizeof(msg), 0); // 错:多算了一个 long
msgsnd(msgid, &msg, sizeof(msg.mtext), 0); // 对:只算正文
msgsz 只描述正文 长度,且消息结构体的第一个成员必须是大于 0 的 long mtype。
5. 两个进程 ftok 参数不一致,永远接不上头
ftok("/tmp", 'a') 和 ftok("/tmp", 'b') 算出的是两个不同的 key,双方各自创建各自的队列,互相看不见。路径和项目 ID 是"接头暗号",两端必须一字不差。
6. 只 shmdt 不 shmctl(IPC_RMID),共享内存泄漏
shmdt 只是"我不再访问了",内存块还在内核里。进程都退出了它也不会自动消失,得用 shmctl(..., IPC_RMID, NULL) 显式删除,或者事后用 ipcrm -m 清理。消息队列、信号量集同理。
7. 共享内存不配合同步机制,读到"半句话"
共享内存没有阻塞、没有通知,写进程写到一半时读进程就能读到中间状态。只要读写可能并发,就必须配信号量(或互斥锁)保护。
十、进程间通信速查
| 机制 | 接口 | 要点 |
|---|---|---|
| 无名管道 | pipe(pipefd) |
pipefd[0] 读、pipefd[1] 写;仅限亲缘进程;先 pipe 后 fork |
| 有名管道 | mkfifo(path, 0664) + open/read/write |
任意进程可用;open 会阻塞到对端打开;双工需两条管道 |
| 注册信号 | signal(SIGINT, handler) |
handler 可换 SIG_IGN / SIG_DFL;SIGKILL、SIGSTOP 不可捕获 |
| 挂起等信号 | pause() |
睡到任意信号到来,不占 CPU |
| 定时信号 | alarm(秒) |
到时向自己发 SIGALRM |
| 发信号 | kill(pid, sig) |
需要知道对方 PID |
| 生成 key | ftok("/tmp", 'a') |
两端参数一致才算出同一个 key |
| 消息队列 | msgget / msgsnd / msgrcv / msgctl |
首成员必须 long mtype > 0;长度只算正文;按类型收发 |
| 共享内存 | shmget / shmat / shmdt / shmctl |
最快、零拷贝;无同步机制;shmdt 只是解除映射 |
| 信号量集 | semget / semctl(SETVAL) / semop |
sem_op 为 -1 申请、+1 释放;SEM_UNDO 防进程暴毙残留 |
| IPC 管理 | ipcs -q/m/s、ipcrm -q/m/s id |
程序崩溃后的清理手段 |
| 易混淆点 | 结论 |
|---|---|
| 无名管道 vs 有名管道 | 无名管道靠 fork 继承,限亲缘进程;有名管道靠路径名,任意进程可用 |
| 信号 vs 其他 IPC | 信号只通知"事件",不携带数据;要传数据用管道/消息队列/共享内存 |
shmdt vs IPC_RMID |
前者解除本进程映射,后者真正删除内核对象 |
| 共享内存为什么快 | 直接映射到进程地址空间,读写不经过内核拷贝 |
| POSIX 信号量 vs 信号量集 | sem_* 服务线程;semget/semop 服务进程,一次管理一组 |
小结
这一篇我们走出了进程的院墙。进程地址空间相互隔离,所以操作系统提供了一整套 IPC 机制:无名管道 用 pipe 创建、pipefd[0] 读 pipefd[1] 写,但只能服务有亲缘关系的进程;有名管道 用 mkfifo 在文件系统里挂个名字,任意两个进程 open 之后就能通信,双向聊天就开两条管道。信号 是异步通知:signal 注册处理函数、kill 发信号、pause 挂起等待、alarm 定时,组合起来还能手写 MySleep。随后登场的三种 System V IPC 对象 由内核统一管理,两个进程靠 ftok 算出同一个 key 来接头:消息队列 按类型收发带格式的消息,共享内存 把同一块物理内存映射进多个进程、速度最快但没有同步,信号量集 用 semop 的 -1/+1 做进程间的申请与释放,恰好补上共享内存的同步短板。还要记住:这些内核对象不会随进程退出而消失,用 IPC_RMID 或 ipcrm 清理干净是每一段的收尾动作。
掌握了五种进程间通信方式,本地多进程协作的工具箱就齐了。下一步如果两台机器上的程序要通信------比如开发板把采集到的数据传给上位机------就要走出单机,进入**网络编程(socket)**的世界。