进程间通信的目的
-
数据传输:一个进程需要将它的数据发送给另一个进程 -
资源共享:多个进程之间共享同样的资源。 -
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。 -
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
进程间通信发展
1、使用文件的方式
管道:特点会比较独立,是使用文件实现的,
2、使用特定的标准
System V进程间通信 POSIX进程间通信
这是两套特定的标准,博主后面会使用System V作为通信的标准,而POSIX会在多线程节将他加入使用起来。站在OS的角度,所谓的标准就是他的接口不能被轻易改变,(其中包括参数个数、接口名、返回值),所以为了保持他们不变我们定义了一些标准,System V、POSIX进程间通信。 , 标准的本意就是为了让大家达成共识,降低程序员之间沟通的成本。
管道
什么是管道
- 管道是Unix中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道“,而它其实是以文件的方式完成的进程通信,
管道的工作原理
管道又分为匿名管道和命名管道,比如以下的使用who | wc -l就是使用匿名管道的方式,who进程会将他本身的数据写入到管道当中,而wc -l进程会去管道中读取,这个过程两个进程是会共享同一块资源的,而完成了进程的通信。
假设我们新创建了文件是被分配到的fd是3,默认的0、1、2(stdout、stdin、stderr)已经被打开了,他是会被bash打开的,而后继创建的子进程都会继承父进程的file__strucrt、task_struct那么子进程和父进程都能指向同一块内存空间,他们是共享的
1、父进程创建管道
我们在创建管道的时候父进程为了需要读写,会把管道的读和写两个权限打开,这样父进程就可以做到能去管道读取数据和去管道写入数据。
2、父进程fork子进程
当父进程创建完子进程后,子进程会以模板的方式拷贝一份父进程的file_strucrt,而file_struct这个结构体中会有fd_arr,所以fd_arr这个数组中的内容是一样的,那么父进程以读写的方式创建了管道,并且指向它,子进程就也会指向这个管道,并且也是以读写的形式。
3、父进程关闭读权限,子进程关闭写权限
进程间通信是一个单向通信方式,也就是父进程只拥有写入权限,而子进程只拥有读入权限,那么就需要在fork创建子进程后,将父进程的fd_arr[0]关闭, 将子进程的fd_arr[1]关闭,那么父进程就只剩下写入的权限,而父进程还剩下读取的权限。也就完成了管道的单向通信,如果想要完成双向通信那么就需要创建两个管道, 那么两个进程就都拥有了读写权限。
匿名管道
文档
#include <unistd.h>
功能:创建一无名管道
原型
int pipe(int fd[2]);
参数
fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码
父进程创建管道和子进程,那么父子进程都可以使用管道完成进程通信,那么匿名管道只会让拥有亲缘关系的两个进程完成通信,常用于父子关系
结合代码理解管道的工作原理
在实际写代码的过程中,父进程创建完管道之后再来创建子进程,从而让父子进程都指向同一块内存空间,但是能不能用缓存区让父子进程指向同一块空间完成进程通信呢? 使用缓冲区,站在进程的角度去思考这个问题时,我们都知道子进程是以父进程为模板创建的,子进程拥有的程序地址空间、页表都是父进程的一份拷贝,而他们也会映射出同一块物理内存,但是父子之间有任何的一方需要对共享的内存进行修改了,那么就会发生写时拷贝,他们会拥有自己独立的数据并各自私有化,那么就不存在完全共享了,而进程通信的目的就是为了让两个进程共享同一份资源,所以使用缓冲区是不可的。
为了完成匿名管道的单向通信,我们需要只保留父进程的写入权限和子进程的读入权限,所以需要将父进程的读入权限关闭,子进程的写入权限关闭,原理如同下面的关系模型。
这里我们使用子进程去往管道写入数据,而让父进程从管道中读取数据
#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <string.h>
int main()
{
int pipefd[2] = {0};
int t = pipe(pipefd);
assert(t == 0);
pid_t id = fork();
if(id < 0) perror("fork 失败");
else if(id > 0){
close(pipefd[1]);
char buff[666];
while(1){
ssize_t ret = read(pipefd[0] ,buff,sizeof(buff) - 1);
if(ret > 0){
buff[ret] = 0;
printf("父进程读取的数据:%s", buff);
}
sleep(1);
}
printf(buff);
}else{
close(pipefd[0]);
const char *str = "child write\n";
while(1) {
write(pipefd[1], str,strlen(str));
sleep(1);
}
}
return 0;
}
程序运行结果:
以上就是使用管道完成父子进程间通信
实验1
如果我们让子进程每隔5秒后向子进程写入数据后会发生什么呢? 当子进程每隔5秒写入一次child write,那么父进程会以阻塞的方式等待子进程,因为父进程啥事也没干,sleep执行期间子进程并没有往管道中写入数据,父进程其实等待的是管道中的数据,只有管道有数据了,父进程才能读取到,并输出,在这个期间OS会将父进程PCB的R状态设置为S状态,并将父进程由运行时队列放入进等待队列。 结论1:当我们实际在读入时,如果读入条件不满足(子进程写入慢,管道为空 ),就需要对读入端进行阻塞,这里阻塞的是父进程
实验2
前面是由于子进程写入数据太慢,让父进程一直等待,我们能不能让子进程写入快一点呢?而让父进程读慢一点 程序运行结果:
结论2:当我们实际在写入时,如果写入条件不满足(子进程输入快,父进程读取慢,管道对应的文件缓冲区被占满 ),就需要对写入端进行阻塞,这里阻塞的是子进程
总结:
在管道当中,不管是读入还是写入,只要读入/写入 条件不满足 那么读入方或者写入方都需要被阻塞
补充:
管道他是一个缓冲区,管道是有水位线这样的概念的,当管道的水位线低于低水位线(也就是管道为空的情况 ),那么在实际读取的时候,就没办法获取数据,那么就需要对读入方进行阻塞直到管道的水位等于或者大于低水位线,那么就可以读取,而写入的原理就是相反了,当管道的水位大于最高水位线(管道满了 ),那么就不再写入,而写入方就需要被阻塞。
问题:
问题?不管是父进程读入需要被阻塞(管道空 )还是子进程写入需要被阻塞(管道满 ),但是他们之间是怎么知道对方已经需要被阻塞呢?
其实一个进程的影响会导致宁外一个进程受到影响这叫做进程间同步 ,比如父进程在实际读取的时候,管道为空就需要以阻塞的方式等待子进程将数据写入到管道,管道里有数据了,继续向管道读取数据,从而完成读取数据的操作。
结论3:当写端关闭文件描述符后,读端将管道的数据读取完毕之后, 会读到文件结尾,那么read的返回值就是是0,
结论4:如果读数据方将读权限关闭了pipefd[0],那么就算子进程一直往管道写入数据,也并不会一直运行,因为一旦写满后,子进程再写就会造成系统资源的浪费,而操作系统为了节省资源会把子进程杀掉,注意:子进程被操作系统杀掉后,需要被父进程回收,否则子进程会进入僵尸 这里父进程需要以waitpid的方式等待子进程
管道读写规则
当没有数据可读时
- O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
- O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
当管道满的时候
- O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
- O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
其他情况
- 如果所有管道写端对应的文件描述符被关闭,则read返回0
- 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
- 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
- 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性
管道特点
- 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
- 管道提供流式服务
- 一般而言,进程退出,管道释放,所以管道的生命周期随进程
- 一般而言,内核会对管道操作进行同步与互斥
- 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
站在内核角度-管道本质 所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了“Linux一切皆文件思想”
命名管道
- 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
- 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
- 命名管道是一种特殊类型的文件
实验,我们想用一个没有任何关系的进程完成进程间通信,可以使用命名管道,
创建一个管道
命名管道可以从命令行上创建,命令行方法是使用下面这个命令 mkfifo filename
mkfifo函数的作用是在文件系统中创建一个文件,该文件用于提供FIFO功能,即命名管道。前边讲的那些管道都没有名字,因此它们被称为匿名管道,或简称管道。对文件系统来说,匿名管道是不可见的,它的作用仅限于在父进程和子进程两个进程间进行 通信。而命名管道是一个可见的文件,因此,它可以用于任何两个进程之间的通信,不管这两个进程是不是父子进程,也不管这两个进程之间有没有关系。Mkfifo函数的原型如下所示:
#include <sys/types.h>
#include <sys /stat.h>
int mkfifo(const char *pathname, mode_t mode );
mkfifo函数需要两个参数,第一个参数(pathname)是将要在文件系统中创建的一个专用文件。第二个参数(mode)用来规定FIFO的读写权限 。Mkfifo函数如果调用成功的话,返回值为0;如果调用失败返回值为-1 。下面我们以一个实例来说明如何使用mkfifo函数建一个fifo,具体代码如下所示:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <assert.h>
#include <stdlib.h>
int main()
{
umask(0);
int ret = mkfifo("./fifo", 0666);
assert(ret >= 0);
return 0;
}
执行a.out后会为我们生成一个可执行程序,注意:凡是管道的文件,类型都是以p开头的,文件的类型是由开头的第一个字母决定的,并且我们可以看到整个命名管道的文件大小是0值
实验:借助命名管道完成两个进程之间的通信,使用命名管道完成进程的通信是需要让两个进程共享同一个管道文件的资源 创建客户端进程和服务器进程 编写makefile
severse.c文件
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <assert.h>
#include <fcntl.h>
#define FIFO_PATH "./fifo"
int main()
{
umask(0);
int ret = mkfifo("./fifo", 0666);
int fd = open(FIFO_PATH, O_RDONLY);
if(fd > 0) {
char buf[64];
while(1){
int ret = read(fd, buf, sizeof(buf) - 1);
buf[ret] = 0;
if(ret > 0){
printf(buf);
}
else if(ret == 0){
printf("client quit");
break;
}
else{
perror("read filed\n");
break;
}
}
}
return 0;
}
client.c文件
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <assert.h>
#include <stdlib.h>
#define FIFO_PATH "./fifo"
int main()
{
int fd = open(FIFO_PATH, O_WRONLY);
char buf[64] = {0};
while(1)
{
printf("请输入信息:");
ssize_t ret = read(0, buf, sizeof(buf) - 1);
if(ret > 0){
buf[ret] = 0;
write(fd, buf, sizeof(buf) - 1);
}
else {
perror("read");
}
}
return 0;
}
make
./server
./client
程序运行结果: 解释上述行为,当我们将两个进程执行起来后,可以看到窗口中他们之间是能相互通信的,clien进程可以将数据写入到管道文件fifo中,severse进程可以从管道中读取数据再向屏幕上输出,通信环境是在内存中完成通信的,数据并不会刷新到硬盘上。
system v共享内存
页表中保存的每一项是一组虚拟地址和物理地址 ,假设前提是32平台,而每一个物理地址他是占用4个字节的,虚拟地址同样是占用4 字节,页表最大能存放2^32 项,那么 2^32 * 8肯定会超出我们的内存资源,所以在进程的页表中是不可能会存放下这么多的地址的,那么他究竟是以什么技术支持的呢?
我们实际查找虚拟地址的时候可以将一个页表分为 10 、 10 、 12,这样的组合就是32位了, 页表也是有分级管理的,可以用第一项去维护2^10个页表项 第二项去维护2^10 个页表项 ,第三项去维护2^12 个页表项
站在进程的角度理解什么是共享内存
OS的地址空间是借助进程的地址空间来完成的,而进程可以拥有多个但是内核区只有一个, 页表可以映射出虚拟地址和物理地址的关联关系,可以产生多个(进程的独立性),他是用户级别的,而我们的内核也需要通过页表建立映射关系从而访问操作系统的代码,而内核的页表是属于内核级别的页表
而如果想要完成两个独立的进程之间完成进程通信就需要让这两个进程共享着同一份资源。 而在进程地址空间上会申请出一份内存这个共享区,共享区存放一个虚拟地址,通过页表的映射找到对应的物理内存,宁外一个进程也是如此,从而两个进程就可以通过共享区找到对应的物理内存了。 问题?两个进程都能看到物理内存上的共享数据吗?
可以,因为页表中已经存在了物理地址和虚拟地址的映射关系,而进程地址空间的共享区可以通过页表的映射关系找到对应的物理内存,所以可以直接访问,
共享内存示意图:
system V共享内存的好处
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
共享内存需要的4个基本操作
1、创建共享内存 2、删除共享内存 3、关联共享内存 4、取消关联共享内存
共享内存需要的四个操作的原因: 1、进程间如果需要通信就需要向操作系统申请资源,而操作系统为了让进程之间共享的是同一块资源急需要对进行进程通信的资源管理起来,也是需要先描述后组织的,而OS需要的考虑的是什么时候申请内存、如果分配、当前的共享内存挂接了多少个进程、记录和哪个进程相关联,和什么进程不关联,而定义这些行为需要使用数据结构管理起来,所以共享内存也需要先描述后组织
管理共享内存的数据结构是以下定义。
在Linux内核中,每个共享内存都由一个名为 struct shmid_kernel的结构体来管理,而且Linux限制了系统最大能创建的共享内存为128个。通过类型为 struct shmid_kernel结构的数组来管理,如下: 系统为每一个IPC对象保存一个ipc_perm结构体,该结构说明了IPC对象的权限和所有者,每一个版本的内核各有不用的ipc_perm结构成员。key值的作用是标识一块ipc资源,可以让多个进程关联到同一块内存资源 shm_npages 字段表示共享内存使用了多少个内存页。而 shm_pages 字段指向了共享内存映射的虚拟内存页表项数组等。另外 struct shmid_ds 结构体用于管理共享内存的信息,而 shm_segs数组 用于管理系统中所有的共享内存。
管理共享内存的系统调用
ftok函数
#include<sys/ipc.h>
#include<sys/types.h>
key_t ftok(const char *pathname, int proj_id);
#pathname: 指定的文件名,该文件必须是存在而且可以访问
#proj_id:子序号,只有8个比特被使用(0-255)范围值
#当成功执行时,返回一个key_t值,失败返回-1
ftok实现原理
ftok返回的key_t在Linux中是一个32位的值,它通过取proj_id参数的最低8个有效位、包含pathname指定文件所属的文件系统的设备的次要设备号的最低8个有效位以及pathname所指定文件的i-node号的最低16个有效位组成。
注意:ftok只是在用户层进行申请,并不会涉及系统内核
shmget函数
#include<sys/ipc.h>
#include<sys/shm.h>
shmget
int shmget(key_t key, size_t size, int flag);
key: 标识符的规则
size:共享存储段的字节数
flag:读写的权限,由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
IPC_CREAT | IPC_EXCL 这两个权限组合表示的含义是:
如果共享内存没有创建那么就创建并返回,否则已经存在就直接出错。
返回值:成功返回共享存储的id,失败返回-1
shmget函数会创建一个共享内存,并将key值填入进程ipc_perm对象中的成员变量key上,相当于人为的给这快ipc_perm资源赋予了一个key值
shmget函数使用IPC_CREAT | IPC_EXCL 权限创建共享内存细节问题,对于已经存在共享内存,再次创建就会出错,解释: 共享内存的创建之后的资源不随进程直接结束,从而结束,ipc资源的生命周期而是伴随着系统内核的,需要被操作系统释放,(他不同于管道,管道是通过文件的方式创建资源,属于文件系统的,而文件会随着进程的结束而结束)后面博主会讲申请出来的ipc资源该如何释放 使用 ipcs -m 命令可以直接查看创建的共享内存资源, nattch表示当前共享内存的挂接数目前是0个进程挂接了共享内存。 共享内存的大小 和挂接数 可以在shmid_ds这个结构体变量中可以查看 使用该命令ipcrm -m xxx(shmid编号) 可以删除刚刚创建的共享内存 注意:共享内存的生命周期是随着系统内核的,所以我们在创建的时候必须要将共享内存给释放完毕,也可以使用下面的系统调用接口删除共享内存。 shmctl函数
功能 | 用于控制共享内存 |
---|
原型 | int shmctl(int shmid, int cmd, struct shmid_ds *buf); | shmid: | 由shmget返回的共享内存标识码,它可以定位共享内存 | cmd: | 将要采取的动作(有三个可取值) | buf | 指向一个保存着共享内存的模式状态和访问权限的数据结构 | 返回值: | 成功返回0;失败返回-1 |
cmd参数的三个取值:
命令 | 说明 |
---|
IPC_STAT | 把shmid_ds结构中的数据设置为共享内存的当前关联值 | IPC_SET | 在进程有足够权限的前提下,把共享内存的当前关联值设置为shmid_ds数据结构中给出的值 | IPC_RMID | 删除共享内存段 |
shmctl(ret, IPC_RMID, NULL);
ret是ipc的shmid
IPC_RMID选项表示的是删除共享内存段
第三个参数是一个输出型参数,如果我们不需要获取共享内存的模式状态和访问权限可以传入NULL
程序执行7秒后将创建出来的共享内存段直接释放 man手册更新: yum install man-pages
shmat函数
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
相关说明:
shmaddr为NULL,核心自动选择一个地址
shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存
功能: | 将共享内存段与当前进程脱离 |
---|
原型 | void *shmat(int shmid, const void *shmaddr, int shmflg); | shmid: | 共享内存标识id | shmaddr: | 指定连接的地址 | shmflg: | 它的两个可能取值是SHM_RND和SHM_RDONLY | 返回值: | 成功返回一个指针,指向共享内存第一个节;失败返回 -1 |
与shmat相反的一个函数是shmdt,它可以取消当前进程和共享内存的链接状态让当前进程脱离
shmdt函数
功能: | 将共享内存段与当前进程脱离 |
---|
原型 | int shmdt(const void *shmaddr); | shmaddr: | 由shmat所返回的指针 | 返回值: | 成功返回0;失败返回-1 |
注意:将共享内存段与当前进程脱离不等于删除共享内存段,只是取消了共享内存的引用计数
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include "comm.h"
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
printf("key val is %p\n", key);
int ret = shmget(key, SIZE, IPC_CREAT |
IPC_EXCL | 0666);
printf("shmid : %d\n", ret);
if(ret < 0){
perror("shmet filed\n");
return 1;
}
sleep(7);
char * str = shmat(ret, NULL, 0);
sleep(7);
shmdt(str);
sleep(7);
shmctl(ret, IPC_RMID, NULL);
sleep(7);
return 0;
}
以下是程序运行结果,读者可以添加下面这段运行脚本观察现象 while :; do ipcs -m; sleep 1; echo "###########"; done
使用共享内存完成进程间通信
client.c 文件
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include "comm.h"
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
printf("key val is %p\n", key);
int ret = shmget(key, SIZE, IPC_CREAT |
IPC_EXCL | 0666);
printf("shmid : %d\n", ret);
if(ret < 0){
perror("shmet filed\n");
return 1;
}
char * str = shmat(ret, NULL, 0);
sleep(7);
int count = 16;
while(count--){
printf("%s\n", str);
fflush(stdout);
sleep(1);
}
shmdt(str);
sleep(7);
shmctl(ret, IPC_RMID, NULL);
sleep(7);
return 0;
}
server.c文件
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include "comm.h"
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
printf("key val is %p\n", key);
int ret = shmget(key, SIZE, 0);
printf("shmid : %d\n", ret);
if(ret < 0){
perror("shmet filed\n");
return 1;
}
char * str = shmat(ret, NULL, 0);
sleep(7);
char ch = 'a';
for(; ch <= 'z'; ch++){
str[ch - 'a'] = ch;
sleep(5);
}
shmdt(str);
return 0;
}
makefile文件
.PHONY:all
all:server client
server:server.c
gcc server.c -o server
client:client.c
gcc client.c -o client
.PHONY:clean
clean:
rm -rf server client
程序运行结果如下: 通过实验现象发现即使共享内存中没有数据,client端并没有写入数据,但是sever端却也不会以阻塞的方式等待client(通过一开始输出空格就能发现 ),结论:共享内存底层不支持任何同步与互斥机制!
了解
消息队列
- 消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法
- 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值
- 特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
system V信号量
信号量主要用于同步和互斥的,下面先来看看什么是同步和互斥 。
需要互斥的前提条件是: 多个进程共享同一份资源,这样就可以使用共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,从而极大的提高了效率,但是进而引发的问题是进程间数据不一致的问题(谁先读,谁先写,如果数据写入出错怎么办的问题),而解决他的方法比较常见的做法就是互斥,任何一方操作临界资源的时候,另外一方不能操作临界资源,
1、临界资源:多个进程在访问同一份资源的时候,这个资源就是临界资源,比如共享内存和管道。 2、通常我们把访问临界资源的代码叫做临界区。 3、为了保护临界资源我们需要对临界区进行加锁的操作,而锁就是信号量。
进程互斥
- 由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥
- 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
- 在进程中涉及到互斥资源的程序段叫临界区
- 特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核。
|