赞
踩
目录
进程间通信是为了达成多进程协作,具体场景如下:
- 数据传输,进程将自己的数据发送给另一个进程
- 资源共享,多个进程共享同样的资源
- 通知事件,如子进程终止时要通知父进程
- 进程控制,如用gdb调试进程
进程间通信的本质:让不同的进程先看到同一份资源,这份资源不能是进程独有的,通常由操作系统提供。
进程间通信有以下几种方式:
- 管道
- System V进程间通信
- POSIX进程间通信
尽管有多种方案,但它们原理都是先要让不同进程看到同一份资源。
匿名管道的原理:
一个进程创建子进程,子进程会浅拷贝父进程的fd_array,因此父子进程能访问相同的struct file,能够看到文件缓冲区中相同的数据
匿名管道是一个特殊的文件,没有名字,不需要向磁盘写入。管道要求单向通信,如果想要双向通信,需要建立两个管道
单向通信,即一端向文件写,另一端从文件读,如何实现这一要求呢?
- 在父进程创建一个管道文件:即一个文件以读和写两种方式打开。注意:系统会创建两个struct file(都有自己的操作位置pos),但会指向相同的inode,方法集和缓冲区
- fork创建子进程,由于文件描述符表的浅拷贝,父子进程指向相同的struct file。注意:系统不会新创建struct file,struct file中有引用计数count,记录有多少个指针指向它。进程close一个文件,实质上是将文件描述表相应位置清空,并将struct file的引用计数减1,当count=0时,操作系统才会释放struct file。
至此,管道已经基本建立,但不满足单向通信要求。例如我想要让父进程read,子进程write,就应该在父进程关闭write端,子进程关闭read端。
- 根据自己的需求,关闭父子进程相应读写端,形成单向通信管道
功能:创建管道文件,如果成功返回0,失败返回-1。pipefd是输出型参数,返回读写端struct file的fd,且pipifd[0]是读端fd,pipefd[1]是写端fd
- using namespace std;
- int main()
- {
- int pipefd[2] = {0};
- //创建管道文件
- int n = pipe(pipefd);
- assert(n == 0);
- (void)n; //防止编译器发出未使用变量的警告
- cout << "pipefd[0]:" << pipefd[0] << endl << "pipefd[1]:" << pipefd[1] << endl;
- return 0;
- }
管道使用的基本步骤:
- 使用pipe创建管道
- fork创建子进程
- 分别关闭父子进程的读写端,形成单向管道
- 一端写,一端读
- #include <iostream>
- #include <cstring>
- #include <cassert>
- #include <unistd.h>
- #include <sys/types.h>
- #include <sys/wait.h>
- #define MAX 1024
-
- using namespace std;
- int main()
- {
- int pipefd[2] = {0};
- // 1.创建管道文件
- int n = pipe(pipefd);
- assert(n == 0);
- (void)n; // 防止编译器发出未使用变量的警告
-
- // 2.创建子进程
- pid_t id = fork();
- if (id < 0)
- {
- perror("fork fail");
- return 1;
- }
-
- // 3.关闭不需要的fd,形成单向通信的管道
- if (id == 0)
- {
- // child
- close(pipefd[0]);
- // 4.1子进程写
- //************write**************
- int count = 10;
- while (count)
- {
- char message[MAX];
- snprintf(message, sizeof(message),
- "hello father, I am child, pid: %d, cnt: %d", getpid(), count);//最多只能存储n-1个字符,末尾肯定是‘\0’
- count--;
- write(pipefd[1], message, strlen(message));
- sleep(1);
- }
- //*******************************
- exit(0);
- }
- // father
- close(pipefd[1]);
- // 4.2父进程读
- //***************read**************
- char buffer[MAX];
- while (true)
- {
- ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1); // 最多读取sizeof(buffer)-1个字符
- if (n > 0)
- {
- buffer[n] = '\0';
- }
- cout << "pid: " << getpid() << ", child say: " << buffer << " to me" << endl;
- }
- //*********************************
- pid_t rid = waitpid(id, nullptr, 0); // 阻塞式等待子进程
- if (id == rid)
- {
- cout << "wait success" << endl;
- }
- return 0;
- }

4种情况:
- 正常情况,如果管道没有数据了,读端必须阻塞等待,直到有数据为止(写端写入数据)
- 正常情况,如果管道被写满了,写端必须阻塞等待,直到有空间为止(读端读走数据)
- 写端关闭(close文件fd或者直接退出进程),读端一直读取,如果管道没有数据了,读端的write会返回0,表示读到文件结尾
- 读端关闭,写端还一直写入,操作系统会杀掉写端的进程。通过向目标进程发送SIGPIPE[13]信号,终止进程
- if (id == 0)
- {
- // child
- close(pipefd[0]);
- // 4.1子进程写
- //************write**************
- int count = 1;
- while (count)
- {
- char c = 'a';
- write(pipefd[1], &c, 1);
- cout << "write---" << "count=" << count << endl;
- count++;
- }
- //*******************************
- exit(0);
- }
- // father
- close(pipefd[1]);
- // 4.2父进程读
- //***************read**************
- sleep(100);
-

上述代码测试管道有多大,答案是65536字节=64KB,不同系统会有所差别
5种特性:
- 匿名管道,允许具有血缘关系的进程之间进行进程间通信,常用与父子(本质是文件描述符表的继承)
- 匿名管道,默认要给读写端提供同步机制(情况1,2体现)(多线程部分详谈)
- 匿名管道面向字节流(暂时不解释,网络部分详谈)
管道的生命周期是随进程的,进程退出会清空文件描述符表
管道是单向通信的,是半双工通信的一种特殊情况
tips:半双工即在同一时刻,只能有一端输出,另一端输入,例如人与人交流;全双工即同一时刻既可以输出又可以输入,例如吵架。
shell首先解析字符串,发现有两个管道符号分成了三个区间,建立两个管道pipea和pipeb,再创建三个子进程a ,b, c。关闭三个子进程相应的读写端形成单向通信管道,然后将a进程输出重定向到pipea,将b进程的输入重定向到pipea,输出重定向到pipeb,将c进程输入重定向到pipeb。最后将三个子进程程序替换,a执行cmd1,b执行cmd2,c执行cmd3
创建一个进程是有成本的,需要花费时间和空间。提前创建一批进程,当有任务时,直接将任务分配给子进程,而不用现场创建子进程,这样可以提高效率。
对于匿名管道的读写两端,当管道为空时,读端read时会阻塞等待,直到写端写入数据。利用匿名管道的这一特点,可以令主进程作为写端,fork出来的多个子进程作为读端,当有任务时,主进程向管道发送数据,子进程接收数据,执行相应的任务;当没有任务时,子进程阻塞等待。
这些提前创建出来,等待主进程分配任务的子进程就叫做进程池。
匿名管道只能使得有血缘关系的进程进行通信,因为子进程会继承父进程的文件描述符表,大家可以访问到相同的文件。
命名管道可以使毫不相关的进程进行通信,这种管道是有文件名,有路径的。因为路径具有唯一性,所以不同的进程可以根据这唯一路径找到相同的文件,看到同一份资源。
首先命名管道在文件系统中以文件的形式存在,计算机重启后它依然存在,但是它在磁盘上并不存储数据,不占用磁盘空间。文件=内容+属性,命名管道在磁盘上既没有inode属性,也没有数据块。所以命名管道既不需要向磁盘刷新数据,也不需要从磁盘加载数据。
A进程根据具体的路径打开特定的命名管道,操作系统创建struct file,inode和文件缓冲区。B进程根据这个唯一路径,打开相同的匿名管道,操作系统检测到该匿名管道已经存在inode和文件缓冲区了,所以只会创建struct file,令其指向之前的缓冲区,AB进程看到同一份资源,进而进行进程间通信
命名管道也遵守匿名管道的四种情况,五种特性,只不过第一条特性应更换为任意进程都能通信。另外,还要加上一种情况
命名管道的一端在open时,会阻塞等待,直到另一端也open,这也是管道的一种同步机制。
管道并不是为了进程间通信而专门设计的一套方案,它复用了文件部分的内核代码。实际上,在内核中还有一个独立的模块来实现进程间通信。总的来说,操作系统内核分为进程管理,内存管理,文件管理和驱动管理四大模块,此外,还可以加上进程间通信模块。
而System V IPC(进程间通信)就是一套用于进程间通信的标准方案,它包括共享内存,消息队列和信号量三种方式
操作系统先在物理内存开辟一段空间,然后在进程的地址空间中,堆和栈之间寻找一块区域,将开辟出来的物理内存,通过页表映射到地址空间中堆栈之间的区域,映射的过程也叫挂接(attach)。
将物理内存挂接到地址空间后,进程就得到了一个虚拟地址,进程可以使用该地址访问到物理内存。因为堆栈之间的区域属于用户空间,进程可以直接访问而不用借助操作系统。(多线程部分详谈用户空间和内核空间)。
操作系统只需将这块物理内存挂接到多个进程的地址空间,多个进程就可以访问同一块物理内存,能够看到同一份资源。
进程如果想要将共享内存从地址空间中移除,也即删除页表中的映射项。这个过程叫做去关联(detach)
malloc我们知道是在虚拟地址中申请空间,其实实际上是在页表中申请的,只要在页表中将虚拟地址初始化,物理先不用填,返回起始的虚拟地址给进程。free实际上也是删除页表中的相关映射关系,映射关系没有了,地址空间自然也就无效了。
- 操作系统可以创建多个共享内存,为了管理,必须“先描述,再组织”:为每一个共享内存创建内核数据结构来描述属性,然后以链表等数据结构组织起来。
- 操作系统如何保证参与通信的进程看到同一个共享内存?通信双方约定一个数字,创建共享内存时将该数字写入到共享内存的属性中,该数字就能唯一标识共享内存,这个数字是用户给出的,也即shmget中的参数key。
- key VS shmid : key会在创建或获取共享内存时用到,在内核中标识共享内存的唯一性,不在应用层使用;shmid在使用共享内存时使用,即指令或者代码
key要我们用户自己给出,设置到共享内存的属性中,为了降低重复性,有一个专门的函数ftok来产生key。我们需要传入一个字符串(一般是文件路径)和项目编号(一个数字),其本质就是一个散列函数。只要传入同样的pathname和proj_id就能产生同样的key。
共享内存的大小,单位为字节。最好设置成4096的整数倍,因为操作系统在底层上是以4096为单位进行开辟共享内存的。
shmget既可以创建,也可以获取共享内存,功能就是通过该参数控制的,常有以下两种传参方式:
IPC_CREAT:shm不存在就创建,存在就获取返回。常用于获取shm
IPC_CREAT | IPC_EXCL:shm不存在就创建,存在就出错返回-1,保证创建的共享内存是全新的。常用于创建shm
此外,共享内存还要设置权限,三位八进制数,跟在后面
shmaddr是指定挂接到地址空间的何处,由于我们不了解地址空间内部,所以设置成nullptr使操作系统自己决定。shmflg和读写权限相关,我们在创建共享内存时已经设置了权限,通常不用管,传入0。
返回值是共享内存挂接到进程地址空间的起始地址,进程使用该地址就能访问共享内存。
shmaddr是共享内存挂接到地址空间的起始地址,去关联本质上就是删除页表中的映射关系
shmid是控制的共享内存
cmd是控制的方式,系统提供了多个宏供我们选择
shmid_ds是共享内存的若干属性,是操作系统中描述共享内存的内核数据结构的一个子集,专门提供给我们用户的。我们传递这样一个结构体,搭配上cmd控制,可以查询或者修改共享内存的内核数据结构
ipcs -m 查看操作系统内核中的共享内存
ipcrm -m [shmid] 删除指定的共享内存(共享内存的声明周期是随操作系统内核的)
操作系统没有为共享内存默认提供同步机制,例如命名管道,必须读写两端都打开文件时才能进行访问,但是共享内存就是一块公共的空间,具体怎么访问由用户自己控制。
没有同步机制就会产生多执行流并发访问公共资源时的数据不一致问题。例如写端想写入“hello”, 刚写入“hel”,读端就把数据读走了,得到的数据残缺不全。
解决方案:使用管道的同步机制。在使用共享内存通信之前,先用管道连接读写两端,令读端先从管道读数据。由于管道为空,读端会阻塞在read中。写端向共享内存写数据,写完后向管道发送数据,通知读端。读端读到数据后从阻塞状态恢复,向下执行读取共享内存的代码。
- 共享内存的通信方式,操作系统没有提供同步机制,用户使用时应注意
- 共享内存可以提供较大的空间
- 共享内存是所有进程间通信,速度最快的。因为共享内存挂接到进程地址空间的用户区,进程可以直接访问。相较于管道,进程要先将数据从内核缓冲区拷贝到用户区,才能访问。
消息队列,提供一个进程给另一个进程发送数据块的能力,每个数据块就是一个结点
消息队列和共享内存高度类似,接口也差不多。只不过共享内存是一块空间,在用户区(共享内存的属性还是在内核区),消息队列是若干个结点组成的队列,在内核区。
所以消息队列不需要挂接到地址空间,访问消息队列的数据需要借助系统调用。
由于消息队列可以双向通信,所以数据块需要标识是哪个进程发送的,mytype就是用户自己约定的数据类型。
ipcs -q 查看内核中的消息队列
ipcrm -q [msqid] 删除内核中指定的消息队列
信号量就是计数器,用于保护公共资源。
信号量一次可以创建多个,类似与创建整型数组int counts[N],如果创建一个N则为1,多个则N>1,nsems就是创建信号量的个数
由于一次性可以创建多个信号量,semnum指定要控制第几个信号量(从0开始计数),cmd是操作指令,后面的省略号是可变参数列表,是否要传参取决于cmd,例如cmd=IPC_RM时就不需要传。
ipcs -s 查看内核中的信号量
ipcrm -s [semid] 删除内核中指定的信号量
多个执行流并发访问公共资源会产生数据不一致的问题,例如一个执行流还没有写完,另一个执行流就来读或者写,这样会导致数据残缺或者混乱,所以需要对公共资源进行保护。
常用的保护策略有两种,一是同步,二是互斥。公共资源的保护问题,要么操作系统完成,例如管道,消息队列,要么用户自己提供,例如之前使用管道让共享内存同步。
- 互斥:任何一个时刻,只允许一个执行流(进程)访问公共资源 (加锁完成)
- 同步:多个执行流执行时,按照一定顺序访问公共资源
- 临界资源:被保护起来的公共资源
- 临界区:访问临界资源的代码
- 原子性:一件事情只有两态,要么不做,要么做完
访问公共是通过代码完成的,所以维护临界资源就是在维护临界区
电影院和内部的座位就是多人共享的资源。
对于我们顾客来说,如果想要去看电影,必须要先买票,买到票了,其中的一个座位至少在电影放映期间是属于我的,但我可以选择去与不去。买票的本质就是对资源的预定
对于电影院的管理人员来说,他们要做的是将这些座位分配出去。首先要统计座位的数量,例如有100个座位,做一个计数器int count = 100,每卖出一张票就将计数器-1,当计数器减到0时就不能继续卖票了。计数器的本质就是公共资源的个数。
整个电影院是一份大的公共资源,每个座位就是被拆分成的一个个小资源。
信号量就是表示资源数目的计数器,每一个执行流想访问公共资源内的某一份资源,不应该让执行流直接访问,而是先申请信号量资源,即对信号量计数器--操作,只要--成功,就对资源预定成功。如果申请失败,执行流就会被挂起阻塞。如果执行流将资源使用完毕,就要释放信号量资源,即对计数器++操作。
将信号量计数器设为1,这个信号量就叫二元信号量,它是一个互斥锁,完成互斥功能。如果想整体使用一份公共资源,就可以利用二元信号量加锁解锁。
- 进程访问公共资源必须先申请信号量资源,意味着每个进程都要先看到同一个信号量资源,所以信号量只能由操作系统提供,它也是IPC体系中的一员
- 信号量本身也是公共资源,也会有多执行流并发导致数据不一致问题,所以访问申请信号量的P操作,释放信号量V操作,都是原子的!要么不申请,要么一次性申请成功!
- 单个信号量:
struct sem
{
int count; //计数器
tast_struct* wait_queue; //等待队列,进程申请信号量要在这里排队
}
- System V IPC通信包括共享内存,消息队列和信号量三种方式,它们都是通过用户提供的key让不同进程看到同一份资源,接口也很类似。
- 它是独立于操作系统四大模块的一个小模块
shmctl,msgctl,semctl接口都会用到各自的数据结构shmid_ds,msqid_ds,semid_ds,它们的第一个成员ipc_perm是相同的。实际上,xxxid_ds和ipc_perm都是用户级别的结构体,是操作系统暴露出来给用户看的,内核中维护的数据结构并不是它们,它们是内核数据结构的子集
kern_ipc_perm是所有ipc资源属性的公共部分,将它单独提取出来,各种ipc资源的内核数据结构都包含它。内核要想维护所有ipc内核数据结构,只需维护ipc_perm数组,如果想要访问具体的ipc对象,只需进行相应的强制类型转换,因为内核数据结构的地址和第一个成员的地址在数值上是相等的,指针类型不同决定解引用能够访问的空间大小。
这不就是C++的多态吗!!!父类的成员放在子类的开头,父类指针可以指向不同子类对象。ipc_perm就是父类,各种ipc内核结构体就是子类。对各种ipc资源的管理就转换成了对一个数组的增删查改。
重要性:管道=共享内存>信号量>消息队列
其中的有些内容并不完善,例如同步与互斥,进程的内核空间与用户空间,多线程部分详谈
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。