进程间通信
基本概念
进程间通信(IPC,InterProcess Communication)指的是在不同进程之间传播信息的过程,常见的IPC方式有
- 管道
- 匿名管道
- 命名管道
- 消息队列
- 共享内存
- 信号量
- 信号
- 文件
- Socket
传输模式
- 单工 Simplex
只允许一方向另一方传输信息,而另一方不能向一方传输 - 全双工 Full Duplex
发送数据的同时也能够接受数据,二者可以同步进行。 - 半双工 Half Duplex
数据可以在一个信号载体的两个方向上传输,但是不能同时传输
管道
匿名管道 pipe
- 属于半双工通信,数据只能在一个方向上流动
- 尽管读端和写端可以同时打开,但每个描述符只能用于读或写中的一种操作,不能同时双向通信
- 从管道本身的角度看,信息只能从写端进入,从读端离开
- 只能在父子进程之间通信
- 是一种特殊的文件,对于它的读写也可以使用普通的
read、write等函数。 - 但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于进程的内核对象中,无法持久化
1 | |
上述的代码会一直输出
1 | |
pid_t id = fork();创建子进程时会返回两次- 在父进程中,返回子进程的 PID(>0)
- 在子进程中,返回 0
- 出错时返回 -1
高级管道 popen
popen的功能是打开一个进程,并创建一个管道与其通信,且这个打开的进程是当前进程的子进程。可以通过它来执行任何程序,包括 shell 命令。
- 它接受以下两个参数:
- 命令字符串: 可以是任何可执行文件的路径或者shell命令
- 模式:指定如何与该进程交互,常用的模式有:
"r":从进程读取输出(读模式),即进程的标准输出被重定向到管道。"w":向进程写入输入(写模式),即进程的标准输入被重定向到管道。
popen创建的管道必须由pclose关闭
下面的代码就是用于执行shell命令并返回命令执行的输出
1 | |
命名管道 FIFO
与匿名管道的区别在于
- 有名字
- 可以双向通信
- 可以在没有亲缘关系的进程间传输
- 会在文件系统中创建一个特殊文件,这个特殊的文件会一直以文件的形式存在,而不是仅存在于进程的内核对象中,是持久化的。
命名管道通过mkfifo创建用于通信的文件
1 | |
并通过读写这个文件的方式进行进程间的通信
1 | |
管道的优劣
- 方便,可以传输大量数据
- 效率相对于共享内存来说较低
消息队列
- 消息队列的信息传递并不遵循先进先出的规则,而是会对每一条消息都赋予类型,根据消息的类型进行读取。
- 可以同时存在多个线程对消息队列进行读写
msgget
用来打开一个现存的消息队列(当该消息队列存在时)或者创建一个消息队列。它的原型为:
1 | |
key- 标识一组消息队列的键。可以理解为队列名,不同的进程只要使用相同的
key,就能访问到同一个消息队列。 - 取值方式
IPC_PRIVATE:- 特殊常量,数值通常为 0。
- 每次调用
msgget(IPC_PRIVATE, …)总是创建一个新队列,且其他进程无法通过该值直接访问。
ftok()生成:- 常见做法是先调用
ftok(pathname, proj_id)生成一个key_t。 pathname通常指向一个已存在的文件,proj_id是一个小整数。pathname并不会在运行时被打开或读取,它的作用仅在于提供一组“文件元数据”来帮助生成一个系统范围内唯一(或至少冲突概率很低)的key_t值。
- 这样不同进程只要给出相同的
pathname和proj_id,就能获得相同的key。
- 常见做法是先调用
- 直接指定整数:
也可以直接写成常量(如 1234),但要保证不会与系统中其它队列冲突。
- 标识一组消息队列的键。可以理解为队列名,不同的进程只要使用相同的
msgflg- 用于指定消息队列的权限
- 常用标志位
IPC_CREAT
如果指定key的消息队列不存在,则创建一个新队列。IPC_EXCL
与IPC_CREAT一起使用时,如果队列已存在,则调用失败并返回-1,同时errno = EEXIST。- 权限掩码(如
0666)
用八进制表示所有者、组、其他用户的读写权限
共有4位,第1位一般为为0,第二位表示所有者权限,第三位表示同组用户的权限,第四位表示其他用户的权限。具体的权限通过4(读)+2(写)+1(执行)的形式表示。例如0674表示所有者、同组用户的权限分别为可读可写(4+2)、可读可写可执行(4+2+1)和可读(4)
- 返回一个标识符
msgid- 是非零整数
key只是用于查找或创建队列,并不是队列的内部编号,而msgid则是内核给队列分配的内部编号- 是后序对队列进行操作所用的句柄的操作句柄
- 失败时返回
-1
msgsend
用来把消息添加到消息队列中
1 | |
msgid即msgget返回的标识符msg_ptr- 指向准备发送的消息的指针
- 对消息的数据结构有要求,必须是以一个
long int成员开始的结构体,该成员用于确定消息类型1
2
3
4
5struct my_message{
long int message_type;
/* The data you wish to transfer*/
char mtext[512]; /*message data,of length msg_sz*/
};
msg_sz- 所要发送的消息的长度
- 消息的长度不包括类型标识符
msgflg0
默认行为:如果消息队列已满,调用该函数的进程将阻塞,直到有足够空间或发生错误。IPC_NOWAIT
不阻塞:如果此时队列已满,调用立即返回-1,并将errno设为EAGAIN(发送失败)。
- 函数调用成功时返回0,否则返回-1
msgrcv
用来从一个消息队列获取消息
1 | |
msg_ptr指向用于接收消息的缓存区msgtype可以用于实现简单的优先级队列msgtype == 0
接收队列中最先到达的那条消息(FIFO),不做类型匹配。msgtype > 0
接收队列中第一个类型匹配的消息(找到后就返回)msgtype < 0
接收队列中msg.message_type ≤ |msgtype|的消息,且总是返回msg.message_type最小的那条消息,如果有多条msg.message_type最小且相等的消息,则返回先到达的消息。
msgflg0
默认行为:如果没有匹配的消息,调用进程阻塞直到有消息到达或队列被删除IPC_NOWAIT
不阻塞:如果没有匹配的消息,立即返回-1,并将errno设为ENOMSG(接收失败)。MSG_NOERROR
如果队列头消息的长度大于用户提供的msg_sz,则截断多余部分并返回,不报错;否则(无此标志且消息过长)返回-1并将errno设为E2BIGMSG_EXCEPT
仅与正数msgtype一起使用,匹配 所有mtype != msgtype的消息(即取“类型不等于msgtype”的第一条消息)MSG_COPY
(Linux 特有,需内核开启CONFIG_CHECKPOINT_RESTORE)
不删除队列中的消息,仅将其内容 复制 到用户缓冲区,用于检查点/恢复机制。
- 调用成功时,该函数返回放到接收缓存区中的字节数,消息被复制到由
msg_ptr指向的用户分配的缓存区中,然后删除消息队列中的对应消息。失败时返回-1.
msgctl
用来控制消息队列
1 | |
command要执行的控制命令IPC_STAT
将内核中该消息队列的状态信息拷贝到用户提供的buf中。IPC_SET
将用户在buf中设置的新权限(uid/gid、mode)写入内核的消息队列数据结构中(只能修改权限和ctime)。IPC_RMID
删除该消息队列;内核会在消息队列被删除后立即释放其所有资源。MSG_STAT
(Linux 特有),类似于IPC_STAT,但如果msgid已被删除,则返回最新的msqid而不是失败。MSG_INFO
(Linux 特有),不需要msgid有效,将系统范围内所有消息队列使用情况写入buf,并返回系统中消息队列的最大索引。
buf
指向struct msqid_ds的指针,用于传递或接收消息队列的元数据信息;其结构体定义通常如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21struct ipc_perm {
key_t __key; /* 键值 */
uid_t uid; /* 所有者的用户ID */
gid_t gid; /* 所有者的组ID */
uid_t cuid; /* 创建者的用户ID */
gid_t cgid; /* 创建者的组ID */
mode_t mode; /* 访问权限 */
unsigned short __seq; /* 序列号 */
};
struct msqid_ds {
struct ipc_perm msg_perm; /* 操作权限和身份 */
time_t msg_stime; /* 最后一次发送时间 */
time_t msg_rtime; /* 最后一次接收时间 */
time_t msg_ctime; /* 最后一次变更时间 */
unsigned long __msg_cbytes; /* 当前队列中字节数(Linux 特有)*/
msgqnum_t msg_qnum; /* 队列中消息数 */
msglen_t msg_qbytes; /* 队列允许的最大字节数 */
pid_t msg_lspid; /* 最后发送者的PID */
pid_t msg_lrpid; /* 最后接收者的PID */
};返回值
成功时返回
0(除MSG_STAT/MSG_INFO可能返回其他非负值)。失败时返回
-1,并设置errno,常见错误原因:EINVAL:msgid或command无效,或mode越界。EACCES:对消息队列的权限不足。EFAULT:buf指针无效。
消息队列 vs 命名管道
- 消息队列可以独立于发送和接收的进程而存在,从而消除了在同步命名管道的打开和关闭时可能产生的困难
- 例如
open(FIFO_PATH, O_RDONLY)打开FIFO读端时,会阻塞直到有写端打开
- 例如
- 过发送消息还可以避免命名管道的同步和阻塞问题,不需要由进程自己来提供同步方法
- 接收程序可以通过消息类型有选择地接收数据,而不是像命名管道中那样,只能默认地接收
- 但是消息队列发送的每条消息都有最大长度限制
共享内存
通过多个进程共同使用同一块逻辑内存实现通信,实现时一般把由不同进程之间共享的内存安排为同一段物理内存。
我们执行的每一个程序,它看到的内存其实都是虚拟内存,虚拟内存需要进行页表的映射将进程地址映射到物理内存,那么让两个进程地址通过页表映射到同一片物理地址,就可以进行通信。
具体的实现方式包括
mmap系统调用Posix共享内存System V共享内存
共享内存是最快的IPC方式,但是没有同步机制,对开发者的能力要求也很高。
通过Posix实现共享内存
shm_open()
用于在内核中打开或创建一个具名的共享内存对象
1 | |
name
以/开头的共享内存对象名(如"/my_shm")。oflag
打开标志,需要#include <fcntl.h>,可按需组合O_CREAT:若不存在则创建O_RDWR:可读写O_RDONLY:只读O_EXCL:配合O_CREAT使用,若对象已存在则失败
mode
权限掩码(参照文件权限),如0666表示所有用户可读写- 打开成功时返回一个非负的文件描述符否则返回
-1并设置errno
ftruncate()
设置或调整共享内存对象的大小,确保后续 mmap 能映射到足够的空间。
1
2#include <unistd.h>
int ftruncate(int fd, off_t length);
fd
文件描述符(这里是共享内存对象的shm_open返回值)length
新的对象大小(字节数),不足则扩展并填零,超出则截断- 成功时返回
0,否则返回-1并设置errno
mmap()
将共享内存对象映射到当前进程的虚拟地址空间,读写这个指针就等同于访问共享内存。
1
2#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
addr
建议映射地址,通常传nullptr由内核选择length
映射区域长度(字节数)prot:访问权限PROT_READ:可读PROT_WRITE:可写
flags:映射特性MAP_SHARED:写入对其他进程可见MAP_PRIVATE:写入时复制,对外不可见
fd
要映射的文件描述符(共享内存描述符)offset
从文件的该偏移处开始映射,通常为0成功时返回指向映射区首地址的指针否则返回
MAP_FAILED并设置errno
munmap()
解除mmap()创建的内存映射,释放资源。
1
2#include <sys/mman.h>
int munmap(void *addr, size_t length);
addr:映射区域起始地址(mmap返回值)length:映射区域长度- 成功时返回
0,否则返回-1并设置errno
shm_unlink()
从内核中删除命名的共享内存对象,彻底清理。
1
2#include <sys/mman.h>
int shm_unlink(const char *name);
name:要删除的共享内存对象名,与shm_open时的相同- 成功时返回
0,否则返回-1并设置errno
示例
1 | |
会输出
1 | |
信号量 Semaphore
信号量是一种用于对多个进程访问共享资源进行控制的机制。共享资源通常可以分为两大类:
- 互斥共享资源,即任一时刻只允许一个进程访问该资源
- 同步共享资源,即同一时刻允许多个进程访问该资源
信号量是为了解决互斥共享资源的同步问题引入的机制,其本质是整数计数器,记录可供访问的共享资源的单元个数。
最常见的信号量是二值信号量,只能取0或1。对于二值信号量
- 当有进程要求使用某一资源时,系统首先要检测该资源的信号量
- 如果该资源的信号量的值大于 0,则进程可以使用这一资源,同时信号量的值减 1。进程对资源访问结束时,信号量的值加 1。
- 如果该资源信号量的值等于 0,则进程休眠,直至信号量的值大于 0 时进程被唤醒,访问该资源。
信号 Signal
- 信号是在软件上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。
- 它是IPC中的唯一一种异步通信方式。信号可以在任何时候发送给进程。
- 进程对于信号有三种响应方式
- 忽略,即不做任何处理(
SIGKILL和SIGSTOP这两个信号不能忽略) - 捕捉信号。定义信号处理函数,当信号发生时,执行相应的处理函数;
- 执行缺省操作,Linux 对每种信号都规定了默认操作。
- 忽略,即不做任何处理(
- 信号只能发挥通知的作用,无法传输数据
文件
进程之间可以通过操作同一个文件来进行通信
套接字 socket
套接字是网络编程和IPC中最常用的一种抽象,可以在同一台主机上的不同线程之间通信,也可以通过网络在不同的主机间进行通信。它本身并不和具体的协议绑定,按照协议类型可以分为
- 流套接字
SOCK_STREAM基于 TCP 协议,提供面向连接的、可靠的、字节流服务。 - 数据报套接字
SOCK_DGRAM基于 UDP 协议,提供无连接的、不可靠的数据报服务。
socket的基本操作
socket本身也是一种特殊的文件,是Unix“一切皆是文件”思想指导下“open—write/read—close”模式的一种实现。
socket()
用于创建一个socket并返回对应的描述符sockfd
1 | |
domain
即协议域,又称为协议族(family),决定了socket的地址类型,在通信中必须采用对应的地址。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。例如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。type
指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等protocol
指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。当protocol为0时,会根据type自动选择协议
bind()
当我们调用socket()创建一个socket时,返回的socket描述符sockfd存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数。
1 | |
sockfdaddr一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址对象,其根据协议族的不同有不同的结构addrlen
地址的长度- 如果没有调用
bind()为sockfd分配地址的话,当调用connect()、listen()时系统会自动随机分配一个端口 - 一般情况下,
socket服务器的地址和端口是通过bind()手动绑定的,而客户端则使用在调用listen()时分配的即可
listen()、connect()
服务器会在建立socket并绑定地址后调用listen()来监听这个socket,如果客户端调用connect(),服务器就能接收到客户端的连接请求
1 | |
backlog
这个socket可以排队的最大连接个数sockfd
需要注意的是,服务器和客户端各自都是一个单独的socket,listen()和connect()中的sockfd参数都是各自的sockfdsockaddrsockaddr.sin_addr是服务器的地址,sockaddr.sin_port是服务器的端口
accept()
服务器监听到客户端的连接请求后,就会调用accept()函数接受请求,从而建立连接
1 | |
sockfd
服务器的sockfdaddr
指向struct sockaddr *的指针,用于返回客户端的协议地址- 返回值为客户端的
sockfd
read()、write()等
客户端和服务器之间的网络I/O操作可以由下面的几组函数实现
1 | |
close()
用于在完成通信后关闭相应的socket
1 | |