文章目录
- 进程间通信
- 进程通信的意义
- 进程通信的方式
- 1.基于文件的方式
- 匿名管道
- 命名管道
- 2.基于内存的通信方式
- 共享内存
- 验证
- 内核相关的数据结构了解
进程间通信
进程通信的意义
当我们和另一个人打电话时两部手机都是独立的,通过基站传递信号等等复杂的过程就实现了通信。那么进程间是如何进行通信的呢?
我们知道进程是具有独立性的,即使是fork创建的子进程也一样;那么有时我们需要让两个进程进行协同工作,例如一个进程发送信号另一个进程接收信号执行对应的操作,再或者子进程把自己的数据交付给父进程让其处理拦截一些异常信号等等。
由于进程是独立的,是互相看不到对方的数据的,所以想要进行通信就必须看到一份公共资源,这里的公共资源是操作系统提供的一段内存是属于操作系统的。这段内存由于提供的方式不一样会使通信的方式多样性(例如文件struct file方式、队列、数组、原始内存块等等),所以进程间通信是具有一定的成本的。
进程通信的方式
1.基于文件的方式
先来梳理一下当一个进程打开文件时的进程内核角度是怎么样的:
那么fork之后的子进程是什么样的呢?
也就是拷贝了父进程的代码和数据以父进程为模板初始化(浅拷贝),所以子进程和父进程就指向了同一个文件,此时这个文件就是公共资源。
那么我们可以让父子进程在指向同一个文件的前提下,一个负责读缓冲区一个负责往缓冲区写,但不调用底层的读写函数不往文件上刷新,就实现了进程间的通信,这种基于文件的通信方式就叫做管道
(站在操作系统角度下一切皆文件)
所以管道是一个单向通信的通信通道(半双工)
匿名管道
进程是具有独立性的,想让进程间通信其实成本是比较高的,因为必须解决一个问题,创建出一份公共资源(内存、队列、文件、缓冲区)让不同的进程看到,因此操作系统提供了一个函数接口 int pipe(int pipefd[2])
,可以创建一个匿名管道
管道没有名字,只能在具有公共祖先的进程(父进程和子进程,或两个兄弟进程)之间使用,所以叫做匿名管道
头文件 #include <unistd.h> |
---|
参数:pipefd[2]是一个输出型参数,通过这个参数读取到打开的两个fd |
返回值:若成功,返回0;若出错,返回-1并设置errno |
匿名管道实际上是一种固定大小的缓冲区,匿名管道对于管道两端的进程而言就是一个文件,可以看成它是一种特殊的文件,并且只存在于内存中。
如何利用pipe实现通信?
- 父进程利用pipe获取到两个文件描述符
- 父进程fork出子进程,子进程以父进程为模板初始化得到一份相同的struct file* array[ ]
- 一个负责读,一个负责写,但要不能冲突,且要关闭不用的文件描述符接口
示例1:
写端写的慢(或者不写),读端要等写端
管道带有同步机制,读走的数据和写入数据是在管道内部是同步的,且数据一旦被读走,它就从管道中丢弃
写端退出,读端读完pipe内部数据后也退出
示例2:
读端读的慢或读端不读,写端要等读端
现象1:为什么一行读取很多个 hello 你好?
只要匿名管道里还有空间,写的一端就会一直按字节为单位地写,只要管道内还有数据,读的一端就会一直按字节为单位地读,直到返回0表明读到文件末尾,是面向字节流的
字节流:按字节写入,按字节读取
也就是子进程不断在写,而父进程延时读取,读的时候,管道有多少字节读都多少字节,所以可以看到读取很多
现象2:为什么读到65536就不读了?
实际是因为,管道内的大小为64KB(65536/1024 = 64)
所以写端在等读端读取(write置阻塞等待),读取一定的大的字节后才会继续写入
即:
- 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
- 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
写端写满后,读端至少读取4KB数据才能唤醒写端使写端继续写,同时写说明管道自带同步机制,不会出现新老数据覆盖的问题。
读端关闭,写端也退出产生终止信号并导致子进程退出,只有在管道的读端存在时,向管道中写入数据才有意义
否则,向管道中写入数据的进程将收到内核传来的SIFPIPE信号,应用程序可以处理该信号,也可以忽略(默认动作则是应用程序终止)
匿名管道读写规则:
- 读端不读或者读端读的慢,写端要等读端
- 管道的大小是64kb,管道写满时,最少读走4KB,才会继续写入(write挂起等待,暂停写入)
- 写端不写或者写端写的慢,读端要等写端(没有数据时,read挂起等待,暂停读取)
- 读端关闭,写端产生终止信号13(SIGPIPE)直接终止
- 写端关闭,读端读完内部数据后关闭(read返回0,代表读到末尾了)
管道的特点:
-
管道是一个单向通信的通道(半双工)
-
管道是面向字节流的(具体体现在,写端等读端,读端读的时候读取所有写入的字节,按字节写入按字节读取),所以一般双方会规定一些协议
-
管道自带同步机制,原子性写入(读过的数据和写入的数据在管道内部是同步的,所以不会出现读到老数据或新数据覆盖问题)
-
匿名管道是在带有继承关系的进程下完成的
-
管道的生命周期是随着进程的
管道也是文件,只要被一个进程打开,struct file的引用计数就会+1,相关进程退出,引用计数就-1,引用计数为0时,OS会自动关闭管道文件
命名管道
匿名管道只能用于具有继承关系的进程间通信,这就导致有很大局限性,无法在两个不相干的进程间通信。
假如在目录中创建一个真实存在的文件,就可以让不同的进程打开同一份文件了,那么操作系统提供了mkfifo
函数可创建一个命名管道文件
int mkfifo(const char *pathname, mode_t mode);
// 返回值:成功返回0,出错返回-1
头文件#include <sys/stat.h> |
---|
pathname:文件名+路径 |
mode:参数与open 函数中的 mode 相同,表示创建这个文件的权限 |
int main()
{
umask(0);
if(mkfifo("./myfifo",0666) < 0)
{
perror("mkfifo:");
return 1;
}
return 0;
}
命名管道文件有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中
命名管道文件不会重复创建
命名管道具有匿名管道所有特点,创建好管道文件后,只需要像文件操作一样即可实现不同进程间通信
/*************************************out.c*************************************/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/wait.h>
int main()
{
//创建管道文件
umask(0);
if(mkfifo("./myfifo",0666) < 0)
{
perror("mkfifo:");
return 1;
}
//打开文件
int fd = open("./myfifo",O_RDONLY);
if(fd < 0)
{
perror("open:");
return 1;
}
while(1)
{
char buffer[64] = {0};
ssize_t ret = read(fd,buffer,sizeof(buffer)-1 );
//期望读取63个但读到末尾就停止,留出一个位置 置一个\0,虽然内核存储没有\0,但是使用printf打印是C的接口,需要置一个\0
buffer[ret] = 0; //置\0
if(ret > 0)
{
if(strcmp(buffer,"show") == 0)
{
if(fork() == 0) //利用子进程替父进程执行命令
{
execl("/usr/bin/ls","ls","-a","-l",NULL); //进程替换
exit(-1);
}
int status = 0;
waitpid(-1,&status,0); //进程等待
if(WIFEXITED(status)) //获取进程正常执行退出的退出码
printf("exit code:%d\n",WEXITSTATUS(status));
else if(WIFSIGNALED(status)) //获取进程退出异常的信号
printf("exit signal:%d\n",WTERMSIG(status));
}
printf("%s\n",buffer);
}
else if(ret == 0)
{
printf("out quit...\n");
break;
}
else
{
perror("read:");
break;
}
}
close(fd);
return 0;
}
/*************************************put.c*************************************/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
//有一个创建管道文件即可,管道文件不可重复创建
/* umask(0);
if(mkfifo("./myfifo",0666) < 0)
{
perror("mkfifo:");
return 1;
}
*/
//推荐系统调用接口,可以减少一些拷贝,如C提供的用户缓冲区
int fd = open("./myfifo",O_WRONLY); //只写
if(fd < 0)
{
perror("open:");
return 1;
}
//发布命令
while(1)
{
char buffer[64] = {0}; //先把要发布的命令保存
printf("请输入:");
fflush(stdout);
ssize_t ret = read(0,buffer,sizeof(buffer)-1); //\0只是C语言规定的,read是系统调用接口,是没有\0这个概念的
if(ret > 0)
{
buffer[ret - 1] = 0; //因为输入完要按回车,所以要去除\n
write(fd,buffer,ret); //发布命令
}
}
close(fd);
return 0;
}
现在让out不读,而put一直写:
管道文件不会真的刷新到磁盘,只会在缓冲区中进行读写,可以节省效率
管道总结:
管道是最基本的进程间通讯,它是单向通讯(类似半双工)。它属于内存缓冲区中申请的一份公共资源,是一种特殊的文件,用于连接一个写进程一个读进程。一个进程把数据写入管道,由内核定向的流入另一个读进程。
命名管道:为了保证不同的进程看到同一个文件,必须有名字
匿名管道:文件没有名字,因为他是通过进程继承方式看到同一份资源的,不需要名字标识
读写规则:
- 读端不读或者读端读的慢,写端要等读端
- 管道的大小是64kb,管道写满时,最少读走4KB,才会继续写入(write挂起等待,暂停写入)
- 写端不写或者写端写的慢,读端要等写端(没有数据时,read挂起等待,暂停读取)
- 读端关闭,写端产生终止信号13(SIGPIPE)直接终止
- 写端关闭,读端读完内部数据后关闭(read返回0,代表读到末尾了)
管道的特点:
管道是一个单向通信的通道(半双工)
管道是面向字节流的(具体体现在,写端等读端,读端读的时候读取所有写入的字节,按字节写入按字节读取),所以一般双方会规定一些协议
管道自带同步机制,原子性写入(读过的数据和写入的数据在管道内部是同步的,所以不会出现读到老数据或新数据覆盖问题)
匿名管道是在带有继承关系的进程下完成的
管道的生命周期是随着进程的
管道也是文件,只要是文件被一个进程打开,struct file的引用计数就会+1,相关进程退出,引用计数就-1,引用计数为0时,OS会自动关闭管道文件
2.基于内存的通信方式
共享内存
进程间通信的本质是先让不同的进程看到同一份资源,除了前面介绍的管道通信还有一些其他的方式,systemV标准是在操作系统层面专门为同一主机内的进程间通信设计的一个方案。
systemV常见的通信方式有以下3种:
- 共享内存(本篇介绍)
- 消息队列
- 信号量
共享内存就是让多个进程地址空间在同一个物理内存通过映射的方式在页表中建立的联系
那么我们可以通过某种方式,在内存中申请一块空间,让进程都“挂接“上这块内存,等于让不同的进程看到了同一份资源
那么上述介绍的就是共享内存的原理
那么如何实现申请和挂接呢?若是不用了以后呢?所以可分为以下4个步骤
- 申请内存
- 挂接到内存
- 去除挂接
- 释放内存
这4个步骤都是利用OS提供的接口函数
-
申请内存,需要用到系统提供的
int shmget(key_t key, size_t size, int shmflg);
函数接口key:
key表示共享内存的键值,可以自己填写,也可以由一个算法生成(
key_t ftok(const char *pathname, int proj_id);
)-
生成成功则会返回 路径+id转换的IPC键值
-
生成失败返回 -1
size:
size表示想申请的空间大小,os是按4kb为基准值分配的,不满4kb的会向上调整分配为4kb,所以这里建议按4kb的倍数申请
shmflg:
shmflg可以设置申请方式以及权限,这里介绍两种:
-
IPC_CREAT:单独使用或者不用(即shmflg=0)时表示:申请的内存时的key值若存在,则返回这个内存的shmid号(返回值),若不存在则申请到共享内存
-
IPC_EXCL:IPC_EXCL单独使用没有任何意义,要配合IPC_CREAT使用,
IPC_EXCL|IPC_CREAT
表示:若申请内存的key已经存在则返回错误(-1),若不存在则创建
最后在后面或上8进制形式的权限即可
返回值:
-
申请成功返回一个id号(shmid),类似文件描述符fd,也是一个数组的下标。
(具体这个数组关连的哪些数据结构在后面介绍)
-
申请失败返回-1
shmget使用类似于管道,一个申请,另一个只要保证申请时传入的key是相同就可以获取到申请内存返回的shmid;而ftok函数保证传入的自定义路径和自定义id是相同的就可以获取到相同的key值
而通过运行结果可以反映出,程序结束并不会回收这个内存,所以out再次运行时会申请失败,put再次运行还是可以获取到
在命令行输入
ipcs -m
可以查看共享内存使用情况注意:system V的IPC资源,生命周期是随内核的,只能通过显示释放(命令、system call)或者重启OS
命令行输入
ipcrm -m shmid号
可释放内存 -
-
释放内存,需要用到的
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
函数接口上面介绍了命令行的释放,实际应该像申请动态内存、打开文件那样,要在程序中中做到谁申请谁释放
所以可以用 shmctl控制内存函数
-
成功返回0
-
失败返回-1
这里只介绍删除,在cmd参数传入 IPC_RMID,若cmd传的是IPC_RMID,第三个参数传NULL即可
-
-
进程挂载内存需要用到
void *shmat(int shmid, const void *shmaddr, int shmflg);
接口函数挂接内存的本质是,让进程地址空间与共享内存在页表建立映射关系
shmid:就是申请内存时返回的的shmid,共享内存标识符
shmaddr:指定挂接的地址,一般填NULL即可(真实情况只有OS清楚),
shmflg:表示挂接方式,一般设置0
返回值:
- 挂接成功返回首地址,也就是进程地址空间中对应页表映射的首地址,类似于malloc
- 失败返回-1
-
取消挂载,用到
int shmdt(const void *shmaddr);
函数接口取消挂接的本质是取消进程地址空与共享内存在页表的映射关系
shmaddr:挂载时返回给用户的地址
返回值:
- 成功返回0
- 失败返回-1
验证
/************************************com.h***************************************/
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/shm.h>
/************************************out.c***************************************/
#include "com.h"
int main()
{
key_t key = ftok(".",0x666);
if(key < 0)
{
perror("ftok:");
return 1;
}
//申请共享内存
int shmid = shmget(key,4097,IPC_CREAT|IPC_EXCL|0666);
if(shmid < 0)
{
perror("shmget");
return 1;
}
printf("key:%#x shmid:%d\n",key,shmid);
printf("申请成功\n");
sleep(10);
//挂接页表-虚拟内存,挂接成功返回起始地址,挂接到页表后,返回映射的虚拟地址中的首地址
char* addr =(char*)shmat(shmid,NULL,0);
if(addr == NULL)
{
perror("shmat");
return 1;
}
sleep(10);
printf("挂接成功\n");
//通信#############
while(1)
{
sleep(1);
printf("%s\n",addr);
}
//##################
//取消挂接,本质是去除进程地址空间与共享内存在页表的映射关系
shmdt(addr);
sleep(10);
printf("取消挂接\n");
//释放共享内存
shmctl(shmid,IPC_RMID,NULL);
printf("释放内存\n");
return 0;
}
/************************************put.c***************************************/
#include "com.h"
int main()
{
key_t key = ftok(".",0x666);
if(key < 0)
{
perror("ftok:");
return 1;
}
//申请共享内存
int shmid = shmget(key,4097,IPC_CREAT);//获取
if(shmid < 0)
{
perror("shmget");
return 1;
}
printf("key:%#x shmid:%d\n",key,shmid);
printf("获取成功\n");
sleep(10);
//挂接页表-虚拟内存,挂接成功返回起始地址,挂接到页表后,返回映射的虚拟地址中的首地址
char* addr =(char*)shmat(shmid,NULL,0);
if(addr == NULL)
{
perror("shmat");
return 1;
}
sleep(5);
printf("挂接成功\n");
//通信############
while(1)
{
char ch = 'A';
while(ch <= 'Z')
{
addr[ch - 'A'] = ch; //[0] = A
ch++; //CH = B
addr[ch - 'A'] = 0; //[1] = B
sleep(1);
}
}
//#################
//取消挂接
shmdt(addr);
sleep(10);
printf("取消挂接\n");
//释放内存
//谁申请谁释放,这里的内存是out.c申请的
/*
shmctl(shmid,IPC_RMID,NULL);
printf("key:%0x shmid:%d\n",key,shmid);
sleep(10);
*/
return 0;
}
共享内存与管道不同,是一旦申请好直接可以拿到内存的数据,比管道读写数据快,如同malloc一样,并且不提供任何的同步性、原子性,所以这也需要自己设计读写协议
总结:
- 共享内存生命周期随内核
- 进程间通信速度最快(用户->内核,而管道直接调用系统接口下至少要经历 用户->缓冲区->管道)
- 共享内存不提供任何同步机制、原子性等等,是直接可以拿到内存数据
- 共享内存申请时是以页为单位(4kb),不满页的会向上调整成页,但是不会显示出来
内核相关的数据结构了解
共享内存不仅只限于两个进程间的通信,还有可能多个进程进程之间多个内存间通信等等。为了防止这些进程之间都能正确的和对应的共享内存通信,os就需要将共享内存进行管理,只要提到管理就离不开“先描述,在组织“。
怎么描述,怎么组织?
通过查看共享内存部分内核源码发现,在申请共享内存时会有结构体来记录申请时时的所有详细信息,如创建时间、申请大小、key值等等
再查看消息队列、信号量的在内核中的结构体
发现它们都有一个共同点,第一个成员都是 struct ipc_perm
所以在打印shmid时发现其好像是呈数组下标形式,其实正是由于IPC(通信)的多样性,内核的IPC资源结构类型不一样,为了方便管理,所以在每个资源的结构的第一个成员都设置成相同的。
而struct ipc_perm
是记录一些比较关键的信息如key值,那么这里也可以类似struct file* array[]一样有一个ipc_perm指针数组,存每个IPC资源的中的ipc_perm,所以shmid的作用就等同于文件描述符fd的作用。
( ipc_perm是第一个成员,第一个成员的地址也是IPC的首地址,可以通过强转类型的方式将每个IPC强转成ipc_perm,再利用ipc_prem* 接收,就可以拿到每个IPC中的ipc_prem了,类似C++中的切片 )