自己做资金盘网站/营销型网站建设目标
文章目录
- 进程间通信
- 进程间通信的目的
- 进程间通信如何做到
- 通信的发展
- 进程间通信分类
- 管道
- 什么是管道
- 匿名管道
- 匿名管道的使用
- 管道的读写规则
- 命名管道
- 管道的特点
- System V
- System V 共享内存
- 共享内存的基本原理
- 共享内存使用逻辑
- 共享内存的特点
- system V 消息队列
- system V 信号量
进程间通信
进程和进程之间的关系,即进程间通信IPC。
进程间通信的目的
Q:为什么要有进程间通信?进程间通信的目的?
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享相同的资源
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知进程发生事件
- 进程控制:有些进程希望完全控制另一个进程的执行,希望获得进程的状态
进程间通信如何做到
Q:进程间通信如何做到?
进程运行的时候具有独立性,在数据层面上。
进程间通信,一般需要通过第三方(OS)资源。
通信的本质就是“数据的拷贝”,让不同的进程看到同一份资源(内存,文件内核缓冲等)
进程A -> 进程A将数据“拷贝”给OS -> OS将数据“拷贝”给进程B
所以操作系统需要提供一段内存区域,并且这一块内存区域可以让两个进程都可以看到。
其中看到的同一份资源的不同种类(由操作系统中不同的模块提供)就是不同的通信方式。
通信的发展
- 管道
- System V进程间通信
- POSIX进程间通信
通信有标准(技术路径的发展方向,包括现存的技术)
进程间通信分类
管道
- 匿名管道pipe
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
管道
^pipe
什么是管道
管道是Unix中最古来的进程间通信的形式,将从一个进程连接到另一个进程的数据流称为一个“管道”。
![[Pasted image 20220203220818.png]]
使用文件的方式,来进行数据共享就是管道通信。管道虽然用的是文件的方案,其实os不会把数据刷新到磁盘上。(如果刷新到磁盘上的话就涉及到IO了,就降低速度)
who
用于查看当前Linux的登录用户。
who | wc -l
统计行数
匿名管道
匿名管道,即没有文件名
管道只能进行单向通信,
匿名管道的使用
原型:
int pipe(int fd[2])
参数:
fd:文件描述符数组,其中fd[0]
是读端,fd[1]
是写端。
返回值:
成功返回0,失败返回错误代码
其中int fd[2]
是输出型参数。
-
父进程创建管道
![[Pasted image 20220203221905.png]] -
父进程fork创建子进程
![[Pasted image 20220203221952.png]]
本质是为了让父子进程看到同一份资源,通常是父子进程进行通信的方式。
下面需要确认谁读谁写,关闭对应的读写端。
- 父进程关闭
fd[0]
,子进程关闭fd[1]
![[Pasted image 20220203222346.png]]
一开始父进程读写端都打开是为了让子进程继承,而因为管道只能单向通信所以必须要关闭进程对应的读写端。
一般情况下
fd[0]
:保存读文件描述符
fd[1]
:保存写文件描述符
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
#include <stdlib.h>// 子进程写入,父进程读取
int main()
{int fd[2] = {0};if (pipe(fd) < 0){perror("pipe");return 1;}pid_t id = fork();// 子进程if (id == 0){// 关闭读端close(fd[0]); int count = 10;const char* msg = "I am child process";while (count --){write(fd[1], msg, strlen(msg));sleep(1);}close(fd[1]);// 子进程退出exit(0);}// 父进程// 关闭写端close(fd[1]);char buff[64];while (1){// size_t是unsigned int// ssize_t是intssize_t s = read(fd[0], buff, sizeof(buff)); // s是从管道中实际读到的字节个数if (s > 0){buff[s] = '\0';printf("child process send to father process# %s\n", buff);}else if (s == 0){printf("read file end\n"); }else{perror("read"); }} waitpid(id, NULL, 0);return 0;
}
Q:父子进程通信可不可以通过创建全局缓冲区的方式完成通信?
A:不可以,因为一开始全局缓冲区父进程的,但是由于进程具有独立性,在fork之后的写入会发生写时拷贝,所以父子进程之间不能通信。
而上面通过使用操作系统提供的文件作为一个缓冲区,就不会在进程中发生写时拷贝。
![[Pasted image 20220204095352.png]]
其实父子进程不仅有自己的file_struct
,并且还有自己的file
结构,不过两个file
都指向同一个inode
,而inode
中的数据页才是内存空间,即内核中的文件缓冲区。
所以看待管道就如同看待文件一样,管道的使用和文件一样,符合了Linux中一切皆文件的思想。
管道的读写规则
命名管道
如果是由血缘关系的进程可以使用匿名管道,但是毫不相关的进程如何通信呢?
命名管道,一定是有名字的,不同的进程就可以打开同一个文件,这样这个有名字的文件就作为了一个临界资源。
注意:虽然是打开同一个文件,但是实际上这个文件只是一个内存文件,只不过在磁盘上有一个文件大小为0的文件作为映像。
创建命名管道的两种方式
- 命令创建
mkfifo 文件名
进程A使用
while : ; do echo "hello fifo"; sleep 1; done > fifo
进程B使用
cat < fifo
这样在A进程打印在显示屏上的文字会重定向到fifo
命名管道文件中。由于fifo
管道文件中的内容重定向到cat
中,所以就打印在了进程B所在的显示屏上。
- 代码创建
int mkfifo(const char* pathname, mode_t node);
.PHONY:all
all:client serverclient:client.cgcc -c $@ $^server:server.cgcc -c $@ $^.PHONY:clean
clean:rm -rf client server myfifo
// "server.c"
#include "common.h"int main()
{if (mkfifo(FILE_NAME, 0644) < 0){perror("mkfifo");return 1;}// FILE_NAME文件已经存在了,所以可以不同指定文件权限了int fd = open(FILE_NAME, O_RDONLY);if (fd < 0){perror("open");return 2;}while (true){msg[0] = 0;ssize_t s = read(fd, msg, sizeof(msg)-1);if (s > 0){msg[s] = '\0';printf("client# %s\n", msg);// fork一个子进程去解析执行命令pid_t id = fork();if (id == 0){execlp(msg, msg, NULL);exit(1);}waitpid(id, NULL, 0);}else if (s == 0){printf("client quit\n");break;}else{perror("read");break;}}close(fd); return 0;
}
// "client.c"
#include "common.h"int main()
{int fd = open(FILE_NAME, O_WRONLY);if (fd < 0){perror("open");return 1;}char msg[128];while (true){msg[0] = 0;printf("Please Enter# ");fflush(stdout);// 从标准输入中读取到msgssize_t s = read(0, msg, sizeof(msg));if (s > 0){// 将msg写入命名管道,并且最后一个\n设置成\0msg[s - 1] = 0;write(fd, msg, strlen(msg));}}close(fd);return 0;
}
// "common.h"
#pragma once
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <sys/wait.h>
#include <stdlib.h>#define FILE_NAME "myfifo"
- 当
client
端发送数据到server
端的时候,如果server
端不读取管道中的文件,client
却一直发送数据到管道中,可以观察到myfifo
命名管道的大小还是0,说明命名管道只是一个内存文件,其内存数据大小并没有刷新到磁盘中。 - 在
server
接收了client
的数据后,可以自己解析,上述的例子中将发来的数据可以当做一个命令而解析出来。这种功能就是体现出了进程间通信的价值,即“多进程之间的任务协同”,如发送命令,接收字符串,接收计算任务等等。
命名管道和匿名管道的联系与区别,如下
- 匿名管道是使用
pipe
打开的,命名管道是使用mkfifo
打开的。 - 命名管道可以使得两个不同的进程实现通信,而匿名管道只能实现血缘关系进程通信。
- 匿名管道是通过子进程继承文件描述符信息,然后父子进程看到同一份资源。
- 命名管道是然后不同的进程看到了同一个路径下的文件,从而使用同一文件作为临界资源。(注意:这里文件其实是内存文件,而不是磁盘上的文件)
Linux中的管道|是匿名管道还是命名管道?
当多个进程由|连接执行的时候,这多个进程的父进程号PPID是一样的,由此可见|是匿名管道。
管道的特点
在多执行流下,看到的同一份资源称之为“临界资源"。
这就涉及到同步与互斥原则,其中互斥就是任何时候只能由一个进程正在使用临界资源。
管道的五种特征:
- 管道内部已经自动提供了同步与互斥机制
子进程在写入的时候,不是一直到死循环执行代码而是进程程挂起等待,所以说管道是线程安全
如果是同时有2个进程向显示屏(显示屏也是一个文件)打印的话,此时显示屏也是一个临界资源,但是因为打印在显示屏上没有提供同步与互斥的保护机制,所以两个进程会同时向显示屏上打印,而不是一个进程打印完毕另一个进程猜打印。就会造成数据不一致的问题。
-
如果打开文件的进程退出了,那么文件也就会被释放掉。也就是文件的生命周期取决于进程的生命周期
-
管道提供的是流式服务(数据没有明确的分割,可以直接接收文件缓冲区中的数据。与之对应的概念是数据报服务,这种服务只能以数据报的形式发出去,以数据报的形式接收)
-
管道是半双工通信,数据只能向一个方向流动。(建立两个管道只能进行两个半双工,并不能做到全双工的状态,因为全双工要求通信双方可以同时通信,但是两个半双工只能做到双方通信并不能同时通信)。
-
匿名管道适合具有血缘关系的进程间通信,常用于父子进程。
如果管道中没有数据,则read()
进程挂起。
如果管道中没有空间,则wirte()
进程挂起。
进程挂起就是将进程的pcb中的r状态修改成非r状态,然后将pcb放入管道提供的等待队列(基于链表的队列)中。
管道的四种情况:
- 不写,一直读,读端进程被阻塞
- 不读,一直写,当写满管道文件的时候,写端进程被阻塞
- 写端写完后关闭,读端读完后
read
返回0 - 读端关闭后,写端直接被操作系统杀死(因为没有意义)
管道的大小
平台不同,管道的文件大小不同。在centos下管道文件大小为65536。
System V
管道通信的本质还是基于文件。而system V进程通信的过程中操作系统就做出了特殊的设计。
- 以传送数据为目的而设计
- 共享内存
- 消息队列
- 保证进程的同步与互斥而设计
- 信号量
System V 共享内存
共享内存的基本原理
将物理内存映射到进程的地址空间中。
- 如何实现映射
- 映射的本质就是修改页表,在虚拟地址空间中开辟空间。
- 谁有能力去做映射
- 使用系统接口去完成映射,所以映射的过程是由操作系统完成的。操作系统可以做到开辟空间,修改页表,开辟虚拟空间
共享内存使用逻辑
- 开始通信,申请资源,建立映射
- 申请共享内存
- 将共享内存挂接到进程地址空间中(修改页表,建立映射关系)
- 结束通信,释放资源,取消映射
- 进程地址空间与共享内存去关联(修改页表,取消映射关系)
- 释放共享内存
一、申请共享内存
系统中可能会存在多个共享内存,所以操作系统需要管理共享内存。因此操作系统中就需要为共享内存维护相关的内核数据结构去管理共享内存。
如何保证共享内存的唯一性?
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char* pathname, int proj_id);
- 作用
- 将
pathname
和proj_id
当做数据,用算法生成一个序号
- 将
- 参数
pathname
:一个任意指定的路径名proj_id
:一个任意指定的数据
- 返回值
key_t
:生成的序号
如果保证不同的进程,看到的是同一个共享内存?
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
- 作用
- 获得一个操作系统中唯一标识的有相关属性的共享内存
- 参数:
key
:保证共享内存是唯一标识的size
:申请共享内存的大小shmflg
:共享内存的属性。IPC_CREAT
:如果共享内存是存在的,直接返回该共享内存。如果不存在,将创建共享内存。所以一定会获得一个共享内存,但是无法保证shm是否全新。IPC_EXEL
:单独使用无意义,通常是IPC_CREAT|IPC_EXCL|对应的权限
组合使用。如果共享内存不存在,就创建共享内存。如果不存在,就报错。所以如果调用成功就一定会获得一个全新的共享内存。并且最后的权限就是创建的共享内存的访问权限。
- 返回值
int
:共享内存句柄,在用户层标识共享内存,使用接口访问共享内存的资源
补充说明:
- ![[Pasted image 20220207152548.png]]**
设置权限对应的就是shm
中perms
项。** - 共享内存的size是会和
PAGE_SIZE
上取整对齐的。而一页数据是4096个字节,4K大小。所以如果共享内存的大小是4097字节,其实操作系统为共享内存分配了2页的内存。所以一般size要设置为PAGE_SIZE的正数倍,否则会导致空间浪费。- 磁盘中的文件会以页帧的形式放入内存中的页框。
查看共享内存的命令
ipcs
:可以查看消息队列,共享内存,信号量ipcs -m
:查看共享内存ipcs -q
:查看消息队列ipcs -s
:查看信号量
shmid和key的区分和联系
- shmid是用户层找到共享内存的句柄,key是系统层找到共享内存资源。
- key:在内核层面上,多个进程之间区分共享内存的唯一方式
- shmid:在进程内部,可以更方便的区分一个共享内存
不同的进程只需要获得相同的key值就可以获得同一块共享内存。通过找到系统层面的同一个key就可以使得不同进程看到同一份资源,从而可以发生进程间通信。
共享内存的声明周期,system V IPC的生命周期?
共享内存的生命周期是随操作系统的,也就说进程如果不主动删除或者使用命令删除的话,共享内存一直存在,直到操作系统关闭。这一点需要与管道文件的生命周期做区分:管道文件的声明周期随进程的生命周期共存亡。
除了共享内存的生命周期,所有的system V IPC
生命周期都是随内核的。
由此可见System V IPC
都是由操作系统提供的。
二、关联共享内存
#include <sys/types.h>
#include <sys/shm.h>void* shmat(int shmid, const void* shmaddr, int shmflg);
- 作用
- 将物理内存中的共享内存和进程地址空间中的某一块空间建立映射关系
- 参数
shmaddr
:将共享内存和进程地址空间的哪一块空间建立映射。一般设置为NULL即可,表示和进程地址空间中任意一块空间建立映射shmflg
:在挂接共享内存的时候,设置某些属性。如:SHM_RDONLY
。SHM_RND
。如果是设置成0就是默认设置为可读可写。
- 返回值
- 成功:返回共享内存映射到进程地址空间的虚拟地址的起始地址;失败:返回-1
![[Pasted image 20220207152548.png]]
nattch
项是和共享内存挂接进程地址空间的数量。所以当共享内存和进程空间相关联之后,查看的shm
的nattch
数量就会+1。
三、去关联共享内存
#include <sys/types.h>
#include <sys/shm.h>int shmdt(const void* shmaddr);
- 作用
- 修改页表,将共享内存和进程地址空间去关联
- 参数
shmaddr
:共享内存映射在进程地址空间的虚拟地址的首地址,即shmat
的返回值。
- 返回值
- 成功:返回0,失败:返回-1
同样的当共享内存和进程空间去关联之后,查看的shm
的nattch
数量就会-1。
四、释放共享内存
使用命令删除共享内存
ipcrm -m [shmid]
:通过shmid删除共享内存ipcrm -M [key]
:通过key删除共享内存ipcrm -q [queueid]
:通过id删除删除消息队列ipcrm -s [semid]
:通过id删除删除信号量
使用代码删除共享内存
#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds* buf);
- 作用
- 通过shmid删除共享内存
- 参数
cmd
:设置为IPC_RMID
就可以删除共享内存
shmid_ds
,即共享内存的数据结构
![[Pasted image 20220207165952.png]]
其中ipc_perm
结构体中就有一个变量是key
。
可以通过获得shmid_ds
共享内存的数据结构进一步获得共享内存更为详细的信息。
代码的编写,举例说明
案例:client
端往共享内存中写字母,server
端从共享内内存中读字母。
.PHONY:all
all:client server
client:client.cgcc -o $@ $^
server:server.cgcc -o $@ $^
.PHONY:clean
clean:rm -rf client server
// "common.h"
#pragma once
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/ipc.h>#define PATHNAME "/home/zhy/shm"
#define PROJ_ID 0x6666
#define SIZE 4096
// "server.c"
#include "common.h"int main()
{// 标识共享内存的keykey_t key = ftok(PATHNAME, PROJ_ID);if (key < 0){perror("ftok");return 1;}// 创建共享内存int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0644);if (shmid < 0){perror("shmget");return 2;}// 挂接共享内存char* mem = shmat(shmid, NULL, 0);if ((int)mem < 0){perror("shmat");retunrn 3;}// 使用共享内存int i = 0;while (true){mem[i] = 'a' + i;i ++;mem[i] = '\0';sleep(1);}// 去关联共享内存shmdt(mem);// 删除共享内存shmctl(shmid, IPC_RMID, NULL);return 0;
}
// "client.c"
#include "common.h"int main()
{// 获得同一个key值key_t key = ftok(PATHNAME, PROJ_ID);if (key < 0) {perror("ftok");return 1;}// 获得共享内存int shmid = shmget(key, SIZE, IPC_CREAT);// 挂接共享内存char* mem = shmat(shmid, NULL, 0);while (true){printf("%s\n", mem);sleep(1);}// 去关联共享内存shmdt(mem);return 0;
}
共享内存的特点
由案例可知:
当使用共享内存读写的时候,并没有使用操作系统的接口。而是一端直接在共享内存中写,与之同时一端就可以看到共享内存。
类比前面管道的通信,使用管道通信的过程中,需要在client
和server
端都保存一个文件缓冲区,然后创建一个内核文件缓冲区,通过一次write
和一次read
将数据从内核文件缓冲区中周转一下才让进程通信达成。这种方式要比共享内存通信多至少2次以上的数据拷贝。
共享内存特点1:由于共享内存的特殊通信方式知道共享内存是IPC中速度最快的。
共享内存特点2:共享内存的通信不提供同步与互斥这样的保护机制。(可以使用加锁或者信号量的方式提供保护)
system V 消息队列
消息队列的原理:
同样操作系统为了管理消息队列,有专门的数据结构msqid_ds
去管理消息队列。
- 消息队列提供从一个进程向另一个进程发送一个数据块的方法
- 每个数据块都被认为有一个类型,接收的进程收到的数据块可以不同类型的数据块
特点:
- 消息队列的生命周期随内核
![[Pasted image 20220207173939.png]]
一、获取消息队列
int msgget(key_t key, int msgflg);
二、发送数据到消息队列中
int msgsnd(int msqid, const void* msgp, size_t msgsz, int msgflg);
- 参数
msgp
:发送的数据块msgsz
:发送数据块的大小
数据块的格式有规定
struct msgbuf
{long mtype; // 数据类型char mtext[1]; // 数据内容
};
- msgflg:如何发送数据
三、接收消息队列中的数据
ssize_t msggrcv(int msqid, void* msgp, size_t msgsz, long msgtyp);
- 参数
msgtyp
:接收数据块的类型
四、删除消息队列
int msgctl(int msqid, int cmd, struct msqid_ds* buf);
消息队列的数据结构
msqid_ds
![[Pasted image 20220207175131.png]]
其中第一个变量和共享内存是一样的,都是struct ipc_perm
,这个结构体中第一个变量就是在系统层面上唯一标识IPC资源的key
。
![[Pasted image 20220207175156.png]]
以此类推可以知道信号量的数据结构中的第一个变量也是struct ipc_perm
结构体。
![[Pasted image 20220207180116.png]]
所以操作系统中就可以维护一个struct ipc_perm
的数据,当需要使用struct shmid_ds
,struct msqid_ds
或者struct semid_ds
的时候,就可以直接强转成对应的类型,这样就可以第一个ipc_perm
变量的地址,也就是间接地拿到了共享内存,消息队列或者信号量结构体的地址了。
system V 信号量
信号量的原理
因为进程间通信所以需要不同的进程看到同一份资源,也就是共享资源。而共享资源也就是临界资源,多个进程访问临界资源的代码就叫做临界区。
如果不加以保护的话,多个线程就会导致数据不一致的问题。
所以我们就可以使用加锁提供同步与互斥的保护机制。也可以使用信号量来提供同步与互斥的机制。
信号量可以分为二元信号量和多元信号量。
信号量的本质就是一个计数器。信号量是用来描述临界区中需要保护临界资源的数量,这样就可以更为细粒度的保护临界资源从而最大化执行的效率。
而二元信号量就是描述只有一个临界资源的临界区,这种方式可以实现互斥功能。
下面使用伪代码的形式解释信号量的PV操作
// "进程A"中
while (true)
{// P操作,申请信号量,sem--if (sem == 1){sem --;}else{// 挂起等待}// 使用IPC临界资源// V操作,释放信号量,sem++sem ++;// 唤醒等待进程
}// "进程B"中
// 代码和进程A中代码是一模一样的
while (true)
{// P操作,申请信号量,sem--if (sem == 1){sem --;}else{// 挂起等待}// 使用IPC临界资源// V操作,释放信号量,sem++sem ++;// 唤醒等待进程
}
进程的角度看访问临界资源的状态,要么进程不有访问邻接资源,要么进程已经访问完了临界资源,没有中间正在访问地状态。因为一个进程在访问的时候,另一个进程一定是在等待挂起的,这种状态称之为原子性。
信号量由于也会被不同的进程同时访问,所以信号量本身也是临界资源,但是特殊地是信号量的PV操作一定是具有原子性的。
一、信号量的创建
int semget(key_t key, int nsems, int semflg);
二、操作信号量
int semop(int semid, struct sembuf* sops, size_t nsops);
int semtimeop(int semid, struct sembuf* sops, size_t nsops, const struct timespec* timeout);
三、删除信号量
int semctl(int semid, int semnum, int cmd, ...);