目录
进程间通信就是不同进程之间传播或者交换信息,简称IPC(Interprocess communication)。
进程间通信的目的
- 数据传输: 一个进程需要将它的数据发送给另一个进程。
- 资源共享: 多个进程之间共享同样的资源。
- 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的本质
在我们的平时的使用过程中其实可以发现,进程之间有着协同工作的场景。
例如:
cat test.c | wc -l
wc -l的意思是显示行数,cat的意思是显示文件的内容。我们通过("|")管道,把cat test.c的信息存放到了管道中,然后此时另一个进程从管道中把数据读取出来,使用wc -l命令,得到了这个test.c文件里面的总行数。
这个过程中,就出现了进程之间协同工作的场景。
我们说过,进程之间具有独立性。不同的进程是看不到其他进程的信息的。我们进程之间交互的成本是很高的。但是在进程具有独立性的前提下,如何进行交互呢?
我们不同的进程应该要看到同一份内存,也就是同一份资源,只有当不同的进程能够获取到同一份内存,进程之间才可以进行通信。而内存是谁给我们的?操作系统!
因此进程间通信的本质是:
OS参与的,所有通信进程都可以看到的公共资源
我们的OS可以有很多种提供公共资源的方式:可以是一块内存,一个文件,一个消息队列等等。
但是本质都脱离不开上面那的一句话。
进程间通信的分类
管道
- 匿名管道
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
接下来我们详细介绍几种进程间通信的方式:
基于文件为公共资源的通信方式--管道
匿名管道
什么叫做基于文件为公共资源的通信方式呢?
下面来放一张图大家看看:
我们可以知道,我们的一个进程里面必定会有一个struct file_struct结构体里面有一个数组叫做struct file *fd_array[],里面的指针指向了我们打开的文件,我们在Linux操作系统之基础IO中讲到过,我们的OS内部有一个内核缓冲区,我们向文件中write数据是不会经过应用层的语言缓冲区的,而是会直接写入内核缓冲区。如果我们此时fork一个子进程,那么子进程也会继承我的父进程,拥有一个完全一样的指针指向。那么这个时候我们就可以发现,父子进程全部都可以指向内核缓冲区,内核缓冲区就是我们的公共资源!
为了解释的更通透一些,我们这里需要补充一些内容:
我们的write和read可以把数据直接写入内核缓冲区,但是从内核缓冲区读写数据到磁盘实际上还有一套驱动层的API,就是read_disk和write_disk。我们每一个文件都会有两个read和write的函数指针。分别指向驱动层的read_disk和write_disk。当我们调用write函数的时候,一方面会把数据从用户写入内核缓冲区,另一方面会触发底层的写入函数。我把数据write到内核缓冲区的时候,可以在文件结构体里面找到struct file_operations *f_op这个结构体指针,从而找到struct file_operations这个结构体,这个结构体里面有write的函数指针指向底层的write_disk函数,我们可以直接调用这个write函数指针指向的函数,从而写入磁盘。每一个文件都会指向对应的read_disk和write_disk,这里体现了解耦的思想,我们在Linux操作系统之基础IO中有详细解释过这种解耦思想。如果我们把数据写入磁盘了,那么数据就不在内存中了,进程间将无法进行交互。
因此,我们在进程间交互的时候,会把数据写入OS内核缓冲区,同时,不触发底层的写入函数,这样数据就会在缓冲区里面保存,从而让父子进程进行通信。
我们这里强调的是父子进程,原因是只有父进程fork出的子进程才会有父进程的指针指向,从而找到我们的内核缓冲区中的数据,如果不是父子进程,我们就无从得知我们的数据存放到了哪里。
以上论述就是匿名管道的原理。
我们知道上述过程十分复杂,所以OS给我们提供了系统调用接口,简化我们的使用。
pipe函数用于创建匿名管道,pip函数的函数原型如下:
int pipe(int pipefd[2]);
pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:
数组元素 | 含义 |
---|---|
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
pipe函数调用成功时返回0,调用失败时返回-1。
匿名管道的特点
- 管道是半双工的通信信道,只能单向通信
- 管道是面向字节流的,类似于TCP, FILE等待
- 仅限于父子通信等有血缘关系的进程进行通信
- 管道自带同步机制,原子性写入
- 管道的生命周期是随进程的
我们对这些特点进行一些讲解。首先针对第一点:半双工的意思就是单向通信的意思,我们的管道只能从一边读一边写。
父进程创建一个管道:
父进程fork一个子进程:
我们的父进程关掉写端,子进程关闭读端,这样就形成了一个可以进行通信的父子进程通道。
注意:
- 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
- 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。
例如我们看一下代码吧:
//child->write, father->read
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int fd[2] = { 0 };
if (pipe(fd) < 0){ //使用pipe创建匿名管道
perror("pipe");
return 1;
}
pid_t id = fork(); //使用fork创建子进程
if (id == 0){
//child
close(fd[0]); //子进程关闭读端
//子进程向管道写入数据
const char* msg = "hello father, I am child...";
int count = 10;
while (count--){
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]); //子进程写入完毕,关闭文件
exit(0);
}
//father
close(fd[1]); //父进程关闭写端
//父进程从管道读取数据
char buff[64];
while (1){
ssize_t s = read(fd[0], buff, sizeof(buff));
if (s > 0){
buff[s] = '\0';
printf("child send to father:%s\n", buff);
}
else if (s == 0){
printf("read file end\n");
break;
}
else{
printf("read error\n");
break;
}
}
close(fd[0]); //父进程读取完毕,关闭文件
waitpid(id, NULL, 0);
return 0;
}
从这个代码里面我们可以看到管道关闭读写端的过程。
如果我们想实现全双工的双向通信的话,我们就需要建立两个管道。
那么有一个问题:管道是文件吗?
Linux下一切都是文件!因此管道当然也是文件。是文件就有引用计数机制,我的匿名管道只是用于父子进程,所以引用计数最多也就是ref = 1,当进程退出的时候,引用计数就会减1,为0的时候管道就会自动销毁。
管道自带同步互斥机制,原子性写入。
这一句话里面出现了3个概念,同步,互斥,原子性。
我们一个一个的来解释这些概念。
同步,互斥,原子性
由于这篇文章质量过于高,导致我已经没有什么可以补充的了。
我们来看一下同步在匿名管道里面体现:
在使用管道时,可能出现以下四种特殊情况:
- 写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被挂起,直到管道里面有数据后,读端进程才会被唤醒。
- 读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
- 写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
- 读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会将写端进程杀掉。
其中前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。
第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。
第四种情况也不难理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。这个信号就是我们进程信号里面的SIGNPIPE。
匿名管道的大小是64kb,也就是说当数据量到达64kb的时候,匿名管道就会爆满了。
命名管道
我们知道,我们的匿名管道只能是父子进程之间进行通信,但是我们有的时候需要非父子进行之间进行痛惜,因此我们引入了命名管道。
mkfifo myfifo //建立了一个名字叫做myfifo的命名管道
这个命名管道是一个单独的文件有自己的inode,但是特殊的是,它的存放空间永远为0,因为命名管道和磁盘之间只是有一个简单的映像,命名管道和匿名管道的数据永远都不会被刷新到磁盘中去。
匿名管道和命名管道的区别
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,由open函数打开。
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。
我们在命令行中使用的 | ,实际上是匿名管道,因为我们没有给它起名字,它便直接可以进行使用了。
System V共享内存
System V共享内存是进程间通信的一种方式。共享内存区是最快的IPC形式。
System V共享内存原理
我们在物理内存上面开辟一块儿共享内存的空间。这样,不同的进程就可以看到同一份物理内存了。
当我们使用这份物理内存的时候,需要通过某种调用,在物理内存中创建一份共享内存空间,然后再将我们需要通信的进程和这份空间挂接,产生联系。当我们不使用这份物理内存的时候,我们需要先取消关联挂接,然后再释放掉这一份空间。
这个时候,我们需要思考一个问题,系统中有没有可能存在多份共享内存空间?
当然是有可能的了!多份共享内存空间就对应着我们的操作系统需要对这些共享内存空间进行维护,Linux当作维护是先组织再维护,也就是说我们使用一个结构体把这个共享内存空间的相关信息维护起来,再用某种数据结构把多个共享内存的结构体组织起来,完成操作系统的组织和维护。
因此我们猜想,这个结构体里面一定有一个变量,这个变量是共享内存空间的编号,就像是钥匙一样,我们的两个进程获取到了同一份钥匙,才能开门,享受到共享内存这一份共享资源。
这个钥匙是我们用户自己定义的!
我们简单看一下相关接口:
创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:
int shmget(key_t key, size_t size, int shmflg);
shmget函数的参数说明:
第一个参数key,表示待创建共享内存在系统当中的唯一标识。
第二个参数size,表示待创建共享内存的大小。
第三个参数shmflg,表示创建共享内存的方式。
shmget函数的返回值说明:
shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
shmget调用失败,返回-1。
当然,创建了共享内存之后,与共享内存产生联系还需要ftok接口,这里就不详细展开了。
当我们创建好共享内存之后,我们甚至不需要进行read和write,就直接直接的使用其他进程的变量。
管道和共享内存的区别
如果我们使用管道的话:可以看到有4次用户态和内核态的切换,开销非常巨大。
如果我们使用共享内存的话,只有两次用户态和内核态的切换,因此它的速度非常快,效率很高。
但是共享内存和管道相比也不是完全没有缺点:管道是自带同步和互斥机制的,而共享内存没有自带,因此,在存在多个执行流的时候,可能会出现不安全的情况。这一点我们后面会非常仔细的谈到。
System V消息队列
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
我们大概的讲述一下消息队列就可以了:
System V信号量
这个东西是一个很特殊的存在,它不像共享资源和消息队列一样,以传输数据为目的,它的目的是通过共享资源的方式来实现多个进程的同步和互斥。
大家可以先看一下这篇文章,这样才能对信号量有一个深刻的理解:
临界资源问题
什么是临界资源?
被多个执行流同时能够访问的资源就是临界资源。我好几个进程同时向显示器打印,显示器就是临界资源。进程间通信的时候,管道,共享内存等待,这些都是临界资源。
我们凡是进程间通信(管道除外,因为管道自带同步和互斥机制)。必定要引入多个进程看到的资源,同时,也就造就了临界资源的问题。类比于我们的线程不安全问题。我们后面会专门把这个专题写成一篇文章。
临界区:用来访问临界资源的代码就叫做临界区。
信号量的本质就是一个计数器,类似于int count,衡量临界资源中的资源数据。
例如,我的电影院就是一个临界资源,我进入电影院的时候要查看电影院里面还有多少个位置,如果count < 0的话,那么肯定是进不去了,而信号量就是这个count!而这个count我所有的人都必须看到,所以count也是临界资源,也就是说信号量也是临界资源,而临界资源就会涉及到同步和互斥相关的问题,因此我们也需要对信号量进行保护,也就是说我们的信号量本身也加锁了,是原子性的!大家现在可能看不懂,是因为没有储备知识,相关的知识我会陆续的把博客写出来。
System V数据结构
我们之前说过系统中如果有可能存在多份,操作系统就有必要把我们组织和维护起来,而维护就是使用结构体,所以信号量,共享内存和消息队列都是有对应的结构体的,我们来看一看。
共享内存数据结构
struct shmid_ds {
struct ipc_perm shm_perm; /* operation perms */
int shm_segsz; /* size of segment (bytes) */
__kernel_time_t shm_atime; /* last attach time */
__kernel_time_t shm_dtime; /* last detach time */
__kernel_time_t shm_ctime; /* last change time */
__kernel_ipc_pid_t shm_cpid; /* pid of creator */
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
unsigned short shm_nattch; /* no. of current attaches */
unsigned short shm_unused; /* compatibility */
void *shm_unused2; /* ditto - used by DIPC */
void *shm_unused3; /* unused */
};
当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个key值,这个key值用于标识系统中共享内存的唯一性。
可以看到上面共享内存数据结构的第一个成员是shm_perm,shm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:
struct ipc_perm{
__kernel_key_t key;
__kernel_uid_t uid;
__kernel_gid_t gid;
__kernel_uid_t cuid;
__kernel_gid_t cgid;
__kernel_mode_t mode;
unsigned short seq;
};
消息队列数据结构
struct msqid_ds {
struct ipc_perm msg_perm;
struct msg *msg_first; /* first message on queue,unused */
struct msg *msg_last; /* last message in queue,unused */
__kernel_time_t msg_stime; /* last msgsnd time */
__kernel_time_t msg_rtime; /* last msgrcv time */
__kernel_time_t msg_ctime; /* last change time */
unsigned long msg_lcbytes; /* Reuse junk fields for 32 bit */
unsigned long msg_lqbytes; /* ditto */
unsigned short msg_cbytes; /* current number of bytes on queue */
unsigned short msg_qnum; /* number of messages in queue */
unsigned short msg_qbytes; /* max number of bytes on queue */
__kernel_ipc_pid_t msg_lspid; /* pid of last msgsnd */
__kernel_ipc_pid_t msg_lrpid; /* last receive pid */
};
信号量数据结构
struct semid_ds {
struct ipc_perm sem_perm; /* permissions .. see ipc.h */
__kernel_time_t sem_otime; /* last semop time */
__kernel_time_t sem_ctime; /* last change time */
struct sem *sem_base; /* ptr to first semaphore in array */
struct sem_queue *sem_pending; /* pending operations to be processed */
struct sem_queue **sem_pending_last; /* last pending operation */
struct sem_undo *undo; /* undo requests on this array */
unsigned short sem_nsems; /* no. of semaphores in array */
};
可以看到每一个System V都有struct ipc_perm这个结构体。
因此这样的设计的好处是:
所有的ipc资源都是通过数组组织起来的。
就算System V的类型不一样,也是同一个数组。
我们在系统内部有一个这样的指针数组。
struct ipc_perm* ipc_id_ary[64]
这个指针数组的类型是struct ipc_perm。我们存数据的时候
ipc_id_arr[0] = (ipc_perm*)&shmid_ds;
ipc_id_arr[0] = (ipc_perm*)&msqid_ds;
ipc_id_arr[0] = (ipc_perm*)&semid_ds;
进行强制类型转换,把不同的结构体类型强转成ipc_perm。然后就可以存在数组里面了,当我们取数据的时候:
(shmid_ds*)ipc_id_arr[0] -> shmid_ds的其他属性
我们再次进行强制类型转换。