秋招之路-深刻理解 Linux 进程间七大通信(IPC)

时间:2023-02-24 14:54:42

秋招之路-深刻理解 Linux 进程间七大通信(IPC)


这是 herongwei 的第 71 篇原创

阅读本文大概需要 20 分钟


前言

网络编程是 Linux C/C++的面试重点,今天我就来聊一聊进程间通信的问题,文章末尾列出了参考资料,希望帮助到大家。


篇幅有点长,希望大家耐心阅读。


Linux 下的进程通信手段基本上是从 Unix 平台上的进程通信手段继承而来的。


如图所示:


秋招之路-深刻理解 Linux 进程间七大通信(IPC)


其中,最初 Unix IPC 包括:管道、FIFO、信号;


System V IPC 包括:System V 消息队列、System V 信号灯、System V 共享内存区;


Posix IPC 包括: Posix 消息队列、Posix 信号灯、Posix 共享内存区。


简单说明一下,现有大部分 Unix 和流行版本都是遵循 POSIX 标准的,而 Linux 从一开始就遵循 POSIX 标准。


图一给出了 Linux 所支持的各种 IPC 手段,在本文接下来的讨论中,


为了避免概念上的混淆,在尽可能少提及 Unix 的各个版本的情况下,所有问题的讨论最终都会归结到 Linux 环境下的进程间通信上来。


进程间通信


进程间的七大通信方式


signal、file、pipe、shm、sem、msg、socket。


下面分别介绍。


1、信号(Signal)


信号本质


信号是在软件层次上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。


信号是进程间通信机制中唯一的异步通信机制,可以看作是异步通知,通知接收信号的进程有哪些事情发生了。

信号机制经过 POSIX 实时扩展后,功能更加强大,除了基本通知功能外,还可以传递附加信息。


信号来源


信号事件的发生有两个来源:硬件来源(比如我们按下了键盘或者其它硬件故障);软件来源

最常用发送信号的系统函数是 kill, raise, alarm 和 setitimer 以及 sigqueue 函数,软件来源还包括一些非法运算等操作。


进程对信号的响应


进程可以通过三种方式来响应一个信号:

(1)忽略信号,即对信号不做任何处理,其中,有两个信号不能忽略:SIGKILL 及 SIGSTOP;

(2)捕捉信号。定义信号处理函数,当信号发生时,执行相应的处理函数;

(3)执行缺省操作,Linux 对每种信号都规定了默认操作。


信号的发送


发送信号的主要函数有:

kill()、raise()、 sigqueue()、alarm()、setitimer() 以及abort()。


1、 kill 函数

对指定的进程发送什么信息。

pid>0 进程 ID 为 pid 的进程;

pid=0 同一个进程组的进程;

pid<0 pid!=-1进程组 ID 为 -pid 的所有进程;

pid=-1 除发送进程自身外,所有进程 ID 大于1的进程。

#include <sys/types.h> 
#include <signal.h>
int kill(pid_t pid,int signo)


2、raise 函数

向进程本身发送信号,参数为即将发送的信号值。

调用成功返回 0;否则,返回 -1。

#include <signal.h> 
int raise(int signo)


3、sigqueue 函数

调用成功返回 0;否则,返回 -1。

第一个参数是指定接收信号的进程 ID,第二个参数确定即将发送的信号,第三个参数是一个联合数据结构 union sigval,指定了信号传递的参数,sigqueue() 比 kill() 传递了更多的附加信息,但 sigqueue() 只能向一个进程发送信号,而不能发送信号给一个进程组。

#include <sys/types.h> 
#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval val)


4、 alarm 函数

专门为 SIGALRM 信号而设,在指定的时间 seconds 秒后,将向进程本身发送 SIGALRM 信号,又称为闹钟时间。

进程调用 alarm 后,任何以前的 alarm() 调用都将无效。

如果参数 seconds 为零,那么进程内将不再包含任何闹钟时间。 返回值,如果调用 alarm 前,进程中已经设置了闹钟时间,则返回上一个闹钟时间的剩余时间,否则返回 0。

#include <unistd.h> 
unsigned int alarm(unsigned int seconds)


5、setitimer 函数

比 alarm功能强大,支持3种类型的定时器:

ITIMER_REAL:  设定绝对时间;经过指定的时间后,内核将发送SIGALRM信号给本进程;
ITIMER_VIRTUAL 设定程序执行时间;经过指定的时间后,内核将发送SIGVTALRM信号给本进程;
ITIMER_PROF 设定进程执行以及内核因本进程而消耗的时间和,经过指定的时间后,内核将发送ITIMER_VIRTUAL信号给本进程;
#include <sys/time.h> 
int setitimer(int which, const struct itimerval *value, struct itimerval *ovalue));


6、abort 函数

#include <stdlib.h> 
void abort(void);

向进程发送 SIGABORT 信号,默认情况下进程会异常退出,当然可定义自己的信号处理函数。

即使 SIGABORT 被进程设置为阻塞信号,调用 abort() 后,SIGABORT 仍然能被进程接收。该函数无返回值。


信号处理


如果进程要处理某一信号,那么就要在进程中安装该信号。


安装信号主要用来确定信号值及进程针对该信号值的动作之间的映射关系,即进程将要处理哪个信号;该信号被传递给进程时,将执行何种操作。


1、signal()


#include <signal.h> 
void (signal(int signum, void (handler))(int)))(int);


如果该函数原型不容易理解的话,可以参考下面的分解方式来理解:


#include <signal.h> 
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler));


第一个参数指定信号的值,第二个参数指定针对前面信号值的处理,可以忽略该信号(参数设为 SIG_IGN);


可以采用系统默认方式处理信号(参数设为 SIG_DFL);


也可以自己实现处理方式(参数指定一个函数地址)。


如果 signal() 调用成功,返回最后一次为安装信号 signum 而调用signal() 时的 handler值;失败则返回 SIG_ERR。


2、sigaction()


#include <signal.h>
int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));

sigaction函数 用于改变进程接收到特定信号后的行为。

该函数的第一个参数为信号的值,可以为除SIGKILL及SIGSTOP外的任何一个特定有效的信号(为这两个信号定义自己的处理函数,将导致信号安装错误)。


第二个参数是指向结构 sigaction 的一个实例的指针,在结构sigaction 的实例中,指定了对特定信号的处理,可以为空,进程会以缺省方式对信号处理;


第三个参数 oldact 指向的对象用来保存原来对相应信号的处理,可指定 oldact 为 NULL。如果把第二、第三个参数都设为NULL,那么该函数可用于检查信号的有效性。


第二个参数最为重要,其中包含了对指定信号的处理、信号所传递的信息、信号处理函数执行过程中应屏蔽掉哪些函数等等。


信号通信方式的局限性

不能够传递复杂的、有效的、具体的数据。


2、文件(file)


使用文件进行进程间通信应该是最先学会的一种 IPC 方式。任何编程语言中,文件 IO 都是很重要的知识。


在 Linux 中,每打开一个文件,就会产生一个文件控制块,而文件控制块与文件描述符是一一对应的,因此可以通过对文件描述符的操作进而对文件进行操作。


文件描述符的分配原则:编号的连续性(节省编号的资源)。


文件系统对文件描述符的读/写控制,进程间一方对文件写,一方对文件读,达到文件之间的通信,可以是不相关进程间的通信。


使用的 API:read() 和 write() 


为了能够实现两个进程通过文件进行有序的数据交流,还得借助于信号的处理机制。


  • 通过 pause() 等待对方发起一个信号,已确认可以开始执行下一次读/写操作;pause():只要接受到任何的信号,立马就可以往下执行。
  • 通过kill() 方法向对方发出明确的信号:可以开始下一步执行(读、写)。


缺点:

  • 文件通信没有访问规则。
  • 访问速度慢。


3、管道(Pipe)及有名管道(named pipe)


相关概念


管道是 Linux 支持的最初 Unix IPC 形式之一,具有以下特点:


(1)管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道;


(2)只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程);


(3)单独构成一种独立的文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,它不属于某种文件系统,而是自立门户,单独构成一种文件系统,并且只存在与内存中。


(4)数据的读出和写入:一个进程向管道中写的内容被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据。


创建无名管道 API

#include <unistd.h>
int pipe(int fd[2])

管道两端可分别用描述字 fd[0] 以及 fd[1] 来描述,需要注意的是,管道的两端是固定了任务的。


即一端只能用于读,由描述字 fd[0] 表示,称其为管道读端;

另一端则只能用于写,由描述字 fd[1] 来表示,称其为管道写端。

如果试图从管道写端读取数据,或者向管道读端写入数据都将导致错误发生。一般文件的 I/O 函数都可以用于管道,如close、read、write 等等。


创建有名管道 API

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char * pathname, mode_t mode)


该函数的第一个参数是一个普通的路径名,也就是创建后 FIFO 的名字。


第二个参数与打开普通文件的 open() 函数中的mode 参数相同。


如果 mkfifo 的第一个参数是一个已经存在的路径名时,会返回EEXIST 错误,所以一般典型的调用代码首先会检查是否返回该错误,如果确实返回该错误,那么只要调用打开 FIFO 的函数就可以了。一般文件的 I/O 函数都可以用于 FIFO,如 close、read、write 等等。


mkfifo 会在文件系统中创建一个管道文件,然后使其映射内存的一个特殊区域,凡是能够打开 mkfifo 创建的管道文件进程(通过这个文件描述符),都可以使用该文件实现 FIFO 的数据流动。


4、共享内存(shm)


概念


使得多个进程可以访问同一块内存空间,是最快的可用 IPC 形式。


各个进程都能够共同访问的共享的内存区域;是独立于所有的进程空间之外的地址区域;  


进程对于共享内存的操作与管理主要是:


(1)申请创建一个共享内存区域(操作系统内核是不可能主动为进程创建共享内存),操作系统内核得到申请然后创建。

(2)申请使用一个已存在的共享内存区域。

(3)申请释放共享内存区域(操作系统内核也是不可能主动释放共享内存区域),操作系统内核得到申请然后释放。


共享内存允许两个或多个进程共享一给定的存储区,因为数据不需要来回复制,所以是最快的一种进程间通信机制。


共享内存可以通过mmap()映射普通文件(特殊情况下还可以采用匿名映射)机制实现,也可以通过系统V共享内存机制实现。


应用接口和原理很简单,内部机制复杂。为了实现更安全通信,往往还与信号灯等同步机制共同使用。


系统调用 mmap() 通过映射一个普通文件实现共享内存。

系统 V 则是通过映射特殊文件系统 shm 中的文件实现进程间的共享内存通信。


也就是说,每个共享内存区域对应特殊文件系统 shm 中的一个文件(这是通过 shmid_kernel 结构联系起来的)。


对于系统 V 共享内存,主要有以下几个 API:shmget()、shmat()、shmdt() 及 shmctl()。


int shmget(key_t key, size_t size, int shmflg); //返回值是共享内存的标号shmid
int shmid = shmget(key, 256, IPC_CREAT | IPC_EXCL | 0755);


说明 


key_t 是一个 long 类型,是 IPC 资源外部约定的 key (关键)值,通过 key 值映射对应的唯一存在的某一个 IPC 资源


通过 key_t 的值就能够判断某一个对应的共享内存区域在哪,是否已经创建等等。


一个 key 值只能映射一个共享内存区域,但同时还可以映射一个信号量,一个消息队列资源,于是就可以使用一个 key 值管理三种不同的资源。


共享内存的控制


共享内存的控制信息可以通过 shmctl() 方法获取,会保存在struct_shmid_ds 结构体中。


共享内存的控制主要是 shmid_ds,即就是共享内存的控制信息


int shmctl(int shmid, int cmd, struct shmid_ds *buf)

cmd:看执行什么操作(1、获取共享内存信息;2、设置共享内存信息;3、删除共享内存)。


总结


共享内存涉及到了存储管理以及文件系统等方面的知识,深入理解其内部机制有一定的难度,关键还要紧紧抓住内核使用的重要数据结构。


系统 V 共享内存是以文件的形式组织在特殊文件系统 shm 中的。 通过 shmget 可以创建或获得共享内存的标识符。取得共享内存标识符后,要通过 shmat 将这个内存区映射到本进程的虚拟地址空间。



5、信号量(semaphore)


概念

主要作为进程间以及同一进程不同线程之间的同步手段。


解决:进程在访问共享资源的时候存在冲突的问题,必须有一种强制手段说明这些共享资源的访问规则。


sem:表示的是一种共享资源的个数,对共享资源的访问规则。


访问规则


(1)用一种数量单位去标识某一种共享资源的个数。

(2)当有进程需要访问对应的共享资源的时候,则需要先查看申请,根据当前资源对应的可用数量进行申请。

(3)资源的管理者(也就是操作系统内核)就使用当前的资源个数减去要申请的资源的个数。如果结果 >=0 表示有可用资源,允许该进程的继续访问;否则表示资源不可用,通知进程(暂停或立即返回)。

(4)资源数量的变化就表示资源的占用和释放。占用:使得可用资源减少;释放:使得可用资源增加。


相关 API

//创建信号量集
int semid = semget(key_t key, int nsems, int semflg)

初始化信号量:


信号量 ID 事实上是信号量集合的 ID,一个 ID 对应的是一组信号量,此时就使用信号量 ID 设置整个信号量集合。


这个时候操作分两种:

(1)针对信号量集合中的一个信号量进行设置;信号量集合中的信号量是按照数组的方式被管理起来的,从而可以直接使用信号的数组下标来进行访问。

(2)针对整个信号量集和进行统一的设置。

int semctl(int semid, int semnum, int cmd, ...)

初始化信号量:


如果 cmd 是 GETALL、SETALL、GETVAL、SETVAL...的话,则需要提供第四个参数。第四个参数是一个共用体,这个共用体在程序中必须的自己定义(作用:初始化资源个数),定义格式如下:

union semun{
int val; /* Value for SETVAL */
struct semid_ds *buf; /* Buffer for IPC_STAT, IPC_SET */
unsigned short *array; /* Array for GETALL, SETALL */
struct seminfo *__buf; /* Buffer for IPC_INFO
(Linux-specific) */
};


信号量的操作 API

//semop()方法。(op:operator操作)
int semop(int semid, struct sembuf *sops, unsigned nsops);


第二个参数需要借助结构体 struct sembuf:


struct sembuf{
unsigned short sem_num; /* semaphore number 数组下标 */
short sem_op; /* semaphore operation */
short sem_flg; /* operation flags 默认0*/
};

通过下标直接对其信号量 sem_op 进行加减即可。


信号量特征


如果有进程通过信号量申请共享资源,而且此时资源个数已经小于0,则此时对于该进程有两种可能性:等待资源,不等待。


如果此时进程选择等待资源,则操作系统内核会针对该信号量构建进程等待队列,将等待的进程加入到该队列之中。


如果此时有进程释放资源则会:

(1)、先将资源个数增加;

(2)、从等待队列中抽取第一个进程;

(3)、根据此时资源个数和第一个进程需要申请的资源个数进行比较,结果大于 0,则唤醒该进程;结果小于 0,则让该进程继续等待。

所以一般结合信号量的操作和共享内存使用来达到进程间的通信。



6、消息队列(Message)


概念

消息队列就是一个消息的链表。可以把消息看作一个记录,具有特定的格式以及特定的优先级。


对消息队列有写权限的进程可以向中按照一定的规则添加新消息;对消息队列有读权限的进程则可以从消息队列中读走消息。


消息队列是随内核持续的;克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。


系统 V 消息队列是随内核持续的,只有在内核重起或者显示删除一个消息队列时,该消息队列才会真正被删除。因此系统中记录消息队列的数据结构(struct ipc_ids msg_ids)位于内核中,系统中的所有消息队列都可以在结构msg_ids中找到访问入口。


结构

消息队列就是一个消息的链表。每个消息队列都有一个队列头,用结构struct msg_queue来描述。队列头中包含了该消息队列的大量信息,包括消息队列键值、用户ID、组ID、消息队列中消息数目等等,甚至记录了最近对消息队列读写进程的ID。读者可以访问这些信息,也可以设置其中的某些信息。


下图说明了内核与消息队列是怎样建立起联系的: 


秋招之路-深刻理解 Linux 进程间七大通信(IPC)


其中:struct ipc_ids msg_ids是内核中记录消息队列的全局数据结构;struct msg_queue是每个消息队列的队列头。


从上图可以看出,全局数据结构 struct ipc_ids msg_ids 可以访问到每个消息队列头的第一个成员:struct kern_ipc_perm;


而每个 struct kern_ipc_perm 能够与具体的消息队列对应起来是因为在该结构中,有一个 key_t 类型成员 key,而 key 则唯一确定一个消息队列。kern_ipc_perm 结构如下:


struct kern_ipc_perm{   //内核中记录消息队列的全局数据结构msg_ids能够访问到该结构;
key_t key; //该键值则唯一对应一个消息队列
uid_t uid;
gid_t gid;
uid_t cuid;
gid_t cgid;
mode_t mode;
unsigned long seq;
}


API 

消息队列与管道不同的地方在于:管道中的数据并没有分割为一个一个的数据独立单位,在字节流上是连续的。然而,消息队列却将数据分成了一个一个独立的数据单位,每一个数据单位被称为消息体。每一个消息体都是固定大小的存储块儿,在字节流上是不连续的。


创建消息队列


int msgget(key_t key, int msgflg);

在发送消息的时候动态的创建消息队列;


int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);


(1)msgsnd() 方法在发送消息的时候,是在消息体结构体中指定,当前的消息发送到消息队列集合中的哪一个消息队列上。


(2)消息体结构体中就必须包含一个 type 值,type 值是long类型,而且还必须是结构体的第一个成员。而结构体中的其他成员都被认为是要发送的消息体数据。


(3)无论是 msgsnd() 发送还是 msgrcv()接收时,只要操作系统内核发现新提供的 type 值对应的消息队列集合中的消息队列不存在,则立即为其创建该消息队列。


总结

为了能够顺利的发送与接收,发送方与接收方需要约定规则


(1)同样的消息体结构体;

(2)发送方与接收方在发送和接收的数据块儿大小上要与消息结构体的具体数据部分保持一致, 否则将不会读出正确的数据。


重点注意:


消息结构体被发送的时候,只是发送了消息结构体中成员的值,如果结构体成员是指针,并不会将指针所指向的空间的值发送,而只是发送了指针变量所保存的地址值。数组作为消息体结构体成员是可以的。因为整个数组空间都在消息体结构体中。



struct msgbuf{
long mtype; //自己制定要传输的消息队列的编号(由自己任意指定);
char mtext[1]; //只能是数组,不能是指针,发送的数据块;
};

long mtype 指定的消息队列编号,后面的数组才是要发送的数据,计算大小,也是这个数组所申请的空间大小。


接收方倒数第二个参数为:mtype的值(指定的消息队列编号)。


在接收的时候,必须指明是哪个消息队列进行接收。



7、套接字(Socket)


更为一般的进程间通信机制,可用于不同机器之间的进程间通信。


一个套接口可以看作是进程间通信的端点(endpoint),每个套接口的名字都是唯一的(唯一的含义是不言而喻的),其他进程可以发现、连接并且与之通信。


通信域用来说明套接口通信的协议,不同的通信域有不同的通信协议以及套接口的地址结构等等,因此,创建一个套接口时,要指明它的通信域。比较常见的是unix域套接口(采用套接口机制实现单机内的进程间通信)及网际通信域。


限于篇幅,下篇文章再具体阐述这一块的知识点。


8、总结


如果你发现有些知识太过于枯燥,那么一般的情况,这个知识点,对于你来说,可能有点太高级了,你可能不知道能用在什么地方。那么解决这个问题呢?这里推荐几个方法。


(1)人的认知是从感性认识向理性认识转化的,所以,你可能要先去找一下应用场景,学点更实用的,再回来学理论。

(2)学习需要有反馈,有成就感,带着相关问题去学习会更好。

(3)当然,找一些写的不错的教程,或者身边厉害的人,来给你讲解,也是不错的手段。


耐心,耐心,再耐心,把枯燥的知识点一网打尽!,举一反三,融会贯通!

今天的知识点,你掌握了吗?


欢迎和我一起交流~


参考资源:

(1)UNIX 环境高级编程,作者:W.Richard Stevens,译者:尤晋元等,机械工业出版社。具有丰富的编程实例,以及关键函数伴随Unix的发展历程。

(2)Linux 内核源代码情景分析(上、下),毛德操、胡希明著,浙江大学出版社,提供了对 Linux 内核非常好的分析,同时,对一些关键概念的背景进行了详细的说明。

(3)UNIX 网络编程第二卷:进程间通信,作者:W.Richard Stevens,译者:杨继张,清华大学出版社。一本比较全面阐述 Unix 环境下进程间通信的书(没有信号和套接口,套接口在第一卷中)。

(4)公众号:【编程剑谱】。