进程间通信
Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间考到内核缓存区,进程2再内核缓存区把数据读走,内核提供这种机制称为进程间通信。通信方式有:管道、共享内存、消息对列、信号量等
管道
-
什么是管道呢?
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”,管道的本质是内核中的一块缓冲区。
-
管道的特性
①半双工通信:半双工通信(Half-duplex Communication)可以实现双向的通信,但不能在两个方向上同时进行,必须轮流交替地进行。在这种工作方式下,发送端可以转变为接收端;相应地,接收端也可以转变为发送端。但是在同一个时刻,信息只能在一个方向上传输。因此,也可以将半双工通信理解为一种切换方向的单工通信。
②:管道的生命周期随进程,进程关闭,对应的管道端口关闭,两个进程都关闭,则管道关闭。
③:管道自带同步与互斥:管道为空时读取,read 阻塞;管道满时写入,write 阻塞。
④:管道提供字节流传输服务 -
管道的分类
①:匿名管道
②:命名管道
匿名管道
什么是匿名管道?
- 匿名管道之所以可以通信的本质在于,父进程frok子进程,父子进程各自拥有一个文件描述符表,但是两者的内容是一样的,既然内容一样,那么指向的就是同一个管道,即父子进程看到了同一份公共资源。匿名管道只能在具有公共祖先的进程(如父进程与子进程,或者兄弟进程,具有亲缘关系的进程之间)之间使用,创建匿名管道一定要放在创建子进程之前。
如何创建匿名管道
- int pipe(int fildes[2]);
成功:返回0 。失败:返回-1
pipefd[0]:从管道读取数据
pipefd[0]:从管道写入数据
代码举例:
#include <stdio.h>
#include <unistd.h>
int main() {
int pipefd[2];
int ret = pipe(pipefd);
if(ret < 0) {
perror("pipe error");
return -1;
}
pid_t pid = fork();
if(pid < 0)
{
perror("fork error");
return -1;
}
else if(pid == 0)
{
sleep(3); //子进程休眠3s
close(pipefd[0]);
write(pipefd[1], "Hello pipe", 10);
close(pipefd[1]);
}
else
{
close(pipefd[1]);
char buf[20] = {0};
read(pipefd[0], buf, 20);
printf("Parent process read:%s\n", buf);
}
return 0;
}
匿名管道的读写规则(命名管道也是相同)(详细了解匿名管道读写规则)
-
管道为空时读取:
-
管道为满时写入:
-
当管道所有读端关闭时写入:
如果所有管道读端对应的文件描述符被关闭,则 write 操作会产生 SIGPIPE 信号,进而导致 write 进程直接退出。
-
当管道所有写端关闭时读取:
如果所有管道写端对应的文件描述符被关闭,那么管道中剩余的数据都被读取完后, read 会返回 0。
管道的原子性问题(详细了解管道的原子性问题)
- 当要写入的数据量不大于 PIPE_BUF(4096B) 时,Linux将保证写入的原子性;
- 当要写入的数据量大于 PIPE_BUF(4096B) 时,Linux将不再保证写入的原子性。
命名管道
什么是命名管道?
- 命名管道在某种程度上可以看做是匿名管道,但它打破了匿名管道只能在有血缘关系的进程间的通信。命名管道之所以可以实现进程间通信在于通过同一个路径名而看到同一份资源,这份资源以FIFO的文件形式存于文件系统中。注意是创建一个真实存在于文件系统的文件,含有文件标识符。可用于同一主机上的任意进程间通信(这也是和匿名管道相比最主要的区别)。
如何创建命名管道
- 通过指令创建
mkfifo filename(管道文件名)
- 在程序中创建
int mkfifo(const char *pathname, mode_t mode);
pathname:文件路径名
mode:文件权限
成功:返回 0 。失败:返回 -1
代码举例:
int main()
{
//int mkfifo(const char *pathname, mode_t mode);
char *file = "./mytest.fifo";
umask(0);
int ret = mkfifo(file,0444);
if(ret < 0)
{
if(errno != EEXIST)//设置为当返回的错误不是因为文件已经存在时才报错
{
perror("mkfifo error");
return -1;
}
}
return 0;
}
命名管道文件的打开规则(此处是受open接口的第二个参数影响,O_RDONLY、O_WRONLY时阻塞,O_RDWR时成功打开)
- 若命名管道文件以只读的方式打开,则会阻塞,直到管道文件被其它进程以写的方式打开。
- 若命名管道文件以只写的方式打开,则会阻塞,直到管道文件被其它进程以读的方式打开。
命名管道读写规则
-
用代码来检验:
fifo_write.c (向管道写入数据) :
//fifo_write.c
int main()
{
//int mkfifo(const char *pathname, mode_t mode);
char *file = "./mytest.fifo";
umask(0);
int ret = mkfifo(file,0666);
if(ret < 0)
{
if(errno != EEXIST)//当错误不是因为文件已经存在时才报错
{
perror("mkfifo error");
return -1;
}
}
int fd = open(file,O_WRONLY);//设置当前进程管道文件已只写方式打开
if(fd < 0)
{
perror("open error");
return 0;
}
printf("open success\n");
while(1)
{
char buf[1024] = {0};
scanf("%s",buf);
int ret1 = write(fd,buf,strlen(buf));
if(ret1 < 0)
{
perror("write error");
return -1;
}
}
return 0;
}
fifo_read.c (从管道读取数据):
//fifo_read.c
int main()
{
//int mkfifo(const char *pathname, mode_t mode);
char *file = "./mytest.fifo";
umask(0);
int ret = mkfifo(file,0666);
if(ret < 0)
{
if(errno != EEXIST)//当错误不是因为文件已经存在时才报错
{
perror("mkfifo error");
return -1;
}
}
int fd = open(file,O_RDONLY);//设置当前进程管道文件已只写方式打开
if(fd < 0)
{
perror("open error");
return 0;
}
printf("open success\n");
while(1)
{
char buf[1024]={0};
int ret = read(fd,buf,1023);
if(ret == 0)
{
printf("所有写端被关闭!\n");
return 0;
}
else if(ret < 0)
{
perror("read error");
return -1;
}
printf("buf:[%s]\n",buf);
}
return 0;
}
-
文件打开的规则验证(上面已经提到具体规则,此处进行验证)
当只编译运行 fifo_read.c 或 fifo_write.c 文件时,可以发现打开文件失败,进程阻塞:
当在两个终端同时运行 fifo_read.c 和 fifo_write.c 文件时,可以发现打开文件成功:
总结:若命名管道文件以只读的方式打开,则会阻塞,直到管道文件被其它进程以写的方式打开。若命名管道文件以只写的方式打开,则会阻塞,直到管道文件被其它进程以读的方式打开。 -
命名管道的读写规则与匿名管道的读写规则相同
①管道为空时读取:
当同时运行 fifo_read.c 和 fifo_write.c 文件时 ,
总结:
②管道满时写入:当我在 fifo_read.c 中在从管道读取数据之前 sleep(1000),在 fifo_wirte 中不断写入,制造管道写满时再写入的情况:
输出结果:
总结:
③当管道所有读端关闭时写入:
总结:如果所有管道读端对应的文件描述符被关闭,则 write 操作会产生 SIGPIPE 信号,进而导致 write 进程直接退出。
④当管道所有写端关闭时读取:
总结:如果所有管道写端对应的文件描述符被关闭,那么管道中剩余的数据都被读取完后, read 会返回 0。
共享内存
什么是共享内存?
- 用于进程间的数据共享,相比较于其它通信方式来说,它是最快的进程间通信方式。因为在通信过程中,少了两次用户态与内核态之间的数据拷贝。进程不再通过执行进入内核的系统调用来传递彼此的数据,而是直接进行读写 。
共享内存特性
- 生命周期随内核,当进程退出后依然存在于内核。
- 不存在同步与互斥!
实现原理
- 1、在物理内存中开辟一块空间(这块空间在内核中是具有标识的)
- 2、将这块空间通过页表映射到自己的虚拟地址空间中。
- 3、通过虚拟地址进行内存操作。
- 4、解除映射关系。
- 5、删除共享内存。
通过实现原理体会对共享内存的基本操作
-
开辟物理空间,创建共享内存
int shmget(key_t key, size_t size, int shmflg);
key:共享内存在内核中的标识,其他进程通过相同标识符打开同一个内存。是一个大于0的32位整数
size:共享内存大小
shmflg: 设置参数有:IPC_CREAT、IPC_EXCL
// IPC_CREAT: 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存;如果存在这样的共享内存,返回此共享内存的标识符。
// IPC_EXCL:如果内核中不存在键值 与key相等的共享内存,则新建一个共享内存;如果存在这样的共享内存则报错。
//shmflg参数还需要加上权限,一般用法为:IPC_CREAT|IPC_EXCL|mode(mode为权限,如:0664),如果不加权限,则默认为 0
返回值:成功- - -》返回共享内存的操作句柄 。失败- - - 》返回 -1#define IPC_KEY 0x22222222 #define SHM_SIZE 4096 int main() { int shmid = shmget(IPC_KEY,SHM_SIZE,IPC_CREAT|IPC_EXCL|0664); if(shmid < 0) { perror("shmget error"); return -1; } return 0; }
可以通过指令 ipcs 可以查看进程间通信方式,其中 ipcs -m 可以只查看共享内存:
-
建立映射关系(将共享内存映射到进程地址空间)
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmid:共享内存的操作句柄
shmaddr:指定共享内存出现在进程内存地址的什么位置,一般直接指定为NULL,让内核自己决定一个合适的地址位置
shmflg:SHM_RDONLY:为只读模式,0 为读写模式//建立映射关系 void *shm_start = shmat(shmid,NULL,0); if(shm_start == (void*)-1) { perror("shmat error"); return -1; }
-
解除映射关系
int shmdt(const void *shmaddr);
shmaddr:连接的共享内存的起始地址
//解除映射关系 shmdt(shm_start);
-
删除共享内存
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmid:共享内存的操作句柄
cmd:可设置的参数有IPC_STAT、IPC_SET、IPC_RMID三个,其中主要了解IPC_RMID(删除这片共享内存)
buf:不获取相关信息则通常设置为NULL//删除共享内存 shmctl(shmid,IPC_RMID,NULL);
其中,IPC_RMID调用后并不会直接删除共享内存,而是等到 nattch (链接数,可以通过 ipcs -m 查看)减到 0 时才会真正删除。建立映射的进程每退出一个,链接数就减 1。
通过共享内存实现读端进程与写端进程通信
-
shm_write.c:
//shm_write.c #define IPC_KEY 0x22222222 #define SHM_SIZE 4096 int main() { //开辟物理空间,创建共享内部才能 int shmid = shmget(IPC_KEY,SHM_SIZE,IPC_CREAT|0664); if(shmid < 0) { perror("shmget error"); return -1; } //建立映射关系 void *shm_start = shmat(shmid,NULL,0); if(shm_start == (void*)-1) { perror("shmat error"); return -1; } //通过虚拟地址进行内存操作 int i = 0; while(1) { sprintf(shm_start,"%s-%d\n","hello world",i++); //sprintf:格式化数据放到一个buf里面 } //解除映射关系 shmdt(shm_start); //删除共享内存 shmctl(shmid,IPC_RMID,NULL); return 0; }
-
shm_read.c:
//shm_read.c #define IPC_KEY 0x33333333 #define SHM_SIZE 4096 int main() { //开辟物理空间,创建共享内部才能 int shmid = shmget(IPC_KEY,SHM_SIZE,IPC_CREAT|0664); if(shmid < 0) { perror("shmget error"); return -1; } shmctl(shmid,IPC_RMID,NULL); return 0; //建立映射关系 void *shm_start = shmat(shmid,NULL,0); if(shm_start == (void*)-1) { perror("shmat error"); return -1; } //通过虚拟地址进行内存操作 int i = 0; while(1) { printf("%s\n",shm_start); sleep(1); } //解除映射关系 shmdt(shm_start); //删除共享内存 shmctl(shmid,IPC_RMID,NULL); return 0; }
运行如下图:
值得注意的是,向共享内存映射的进程地址空间写数据时是覆盖写入的!!!
消息队列(了解)
什么是消息队列?
- 用于进程间的数据块传输,它的本质是内核中的一个优先级队列,多个进程通过向同一队列中放置队列结点或获取结点实现通信。
消息队列特性
- 消息队列自带同步与互斥
- 传输有类型的数据块
- 传输数据时不会粘连
- 生命周期随内核
实现原理
- 在内河中创建消息队列
- 向队列中获取节点
- 从队列中获取节点
- 删除消息队列
信号量(了解)
什么是信号量?
- 本质上就是共享资源的数目,就是内核中一个原子操作的计数器和一个等待队列,用来控制对共享资源的访问。信号量用于实现进程间的同步与互斥。
如何实现进程间的同步与互斥
-
实现互斥(通过一个只有0/1的计数器实现)
通过一个状态标记临界资源当前的访问状态。对临界资源进行访问之前先判断一下这个标记,若状态为可访问,则将这个状态修改为不可访问,然后去访问数据,访问完毕之后再将状态修改为可访问状态。
-
实现同步(通过一个计数的判断以及等待与唤醒功能的实现)
通过一个计数器对资源数量进行计数,想要获取临界资源的时候,则先判断计数,是否有资源可供访问。若有资源(计数 > 0),则计数 -1 ,获取一个资源进行操作。若没有资源(计数 <= 0),则进行等待,等到其他进程生产数据后计数进行 +1,然后唤醒等待的进程。
-
信号的PV原语:
P操作:对计数进行判断,然后进行 -1 ,若没有资源则等待。
V操作:对计数进行 +1 ,唤醒等待队列中的挂起进程。
来源:CSDN
作者:风隐君
链接:https://blog.csdn.net/Adenson/article/details/104683175