进程间通信介绍
进程间通信目的
进程通信的目的有四点:
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 资源共享:多个进程之间共享同样的资源
- 通知事件:一个进程需要向另一个进程或一个组发送消息,通知它(它们)发生了某种事件
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信发展
进程间通信分类
管道
System V IPV
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
但是这篇博客是讲管道、System V共享内存。
管道
什么是管道
在学Linux指令的时候,知道管道是| 来表示的。 如一行指令head -n5 text.txt | tail -n3 表示获取text.txt文件3~5行内容。 head命令通过选项 -n5 把text.txt文件中的前五行内容通过管道传输给tail进程把传输过来的内容中的末尾三行内容拿出来。这里管道就提供了传输数据的作用。
- 管道是最古老的进程间通信的形式。
- 我们把从一个进程连接另一个进程是一个数据流称为一个"管道"
如图(简易图) head进程是通过管道来对tail进程进行数据传输,这看似很正常。但这个管道是谁提供的呢?进程不是具有独立性吗?这两个进程是怎么玩到一块去的呢?
进程和进程具有独立性没错,但是不一定玩不到一块去。比如,人是独立的,但是人可以和人交流,只是要通过一个媒介。而OS就可以提供一个媒介——管道。 OS提供一段内存区域,让head进程和tail进程都看到这块区域,让这两个进程进行通信。所以,进程与进程之间进行通信的本质是看到同一份资源,这种资源称之为临界资源(内存、文件内核缓存等)。
匿名管道
匿名管道:创建一种无名的管道,提供具有血缘关系的进程进行通信。
函数
#include<unistd.h>
int pipe(int fd[2])
参数:fd:文件描述符数组,其中fd[0]表示读端,fd[1]表示写端
返回值:成功返回0,失败返回错误代码
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<sys/types.h>
int main()
{
int fd[2];
if(pipe(fd)<0){
perror("pipe!");
return 1;
}
int id=fork();
if(id==0){
close(fd[0]);
const char* mag="hello linux\n";
int count=5;
while(count){
write(fd[1],mag,strlen(mag));
count--;
sleep(1);
}
exit(0);
}
close(fd[1]);
char buff[64];
while(1){
ssize_t s=read(fd[0],buff,sizeof(buff));
if(s>0){
buff[s]='\0';
printf("child --->father#%s",buff);
sleep(1);
}
else if(s==0){
printf("read end\n");
break;
}
else{
printf("error\n");
break;
}
}
waitpid(id,NULL,0);
close(fd[0]);
return 0;
}
运行结果:
child --->father#hello linux
child --->father#hello linux
child --->father#hello linux
child --->father#hello linux
child --->father#hello linux
read end
<1> 如果说通信可以通过文件来作为媒介,那么为什么不直接open一个文件来呢?要用pipe来创建管道?
答:pipe创建的文件是内存文件,数据一定不会刷新到磁盘。并且用普通文件会有很多问题(同步与互斥),有IO参与会降低效率,没有必要。
<2> 创建子进程进行写入难道不会发生写时拷贝吗?
答:不会,管道是OS提供的,子进程写入时,不会改变父进程的数据区,故不会发生写时拷贝。
<3> 子进程还没写完,父进程就不会读取吗?
答:管道是自带同步与互斥的。不会发生子进程还没写完,父进程就开始读了。
用fork来共享管道的原理
父进程创建了匿名管道,父进程PCB中的files*指针指向file_struct,通过文件描述符找到file结构体,对管道进行读写。 fork子进程,是为了让子进程也可以看到相同的管道,对管道进行读写,这样就可以通信了。 管道只能单向通信,只能一方读,另一方写。所以在fork之后,要关闭掉不需要的描述符。
站在内核角度-深度理解管道
站在内核角度-管道的本质
所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了“Linux一切皆文件的思想”。
管道读写规则
当没有数据可读时:
当管道满了时:
如果管道写端对应的文件描述符被关闭,read返回0 如果父进程write关闭时,子进程read没有意义,子进程会接收到13号信号退出。
用代码演示子进程接收信号退出:
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
int main()
{
int fd[2];
if(pipe(fd)<0){
perror("pipe!");
return 1;
}
pid_t id=fork();
if(id==0){
close(fd[0]);
const char* mag="hello linux\n";
write(fd[1],mag,strlen(mag));
exit(0);
}
close(fd[1]);
close(fd[0]);
int status=0;
waitpid(id,&status,0);
printf("child get signal:%d\n",status&0x7F);
return 0;
}
运行结果:
child get signal:13
接收的是13号信号,信号是由OS发送的。
当要写入的数据量不大于PIPE_BUF时,Linux将保持原子性。 当要写入的数据量大于PIPE_BUF时,Linux将不保持原子性。
那么PIPE_BUF是什么呢?
(翻译过来的) POSIX.1-2001规定,小于PIPE_BUF字节的写入(2)必须是原子的:输出数据作为连续序列写入管道。写入超过PIPE_BUF字节可能是非原子的:内核可能会将数据与其他进程写入的数据交错。POSIX.1-2001要求管道长度至少为512字节。(在Linux上,PIPE_BUF是4096精确的语义取决于文件描述符是否为非阻塞(O_NON?块),管道是否有多个写入程序,以及在n上,要写入的字节数
管道有多大?代码测试一下。
#include<stdio.h>
#include<unistd.h>
int main()
{
int fd[2];
pipe(fd);
pid_t id=fork();
if(id==0){
close(fd[0]);
char a='a';
int count=0;
while(1){
write(fd[1],&a,1);
count++;
printf("%d:a\n",count);
}
}
sleep(1000);
return 0;
}
管道特点
-
只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。 -
管道提供流式服务 -
一般而言,进程退出,管道释放,所以管道的生命周期随进程 -
一般而言,内核会对管道操作进行同步与互斥 -
管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
命名管道
匿名管道对具有血缘关系的进程进行通信,那么两个毫不相关的进程是如何通信的呢?
答:进程的通信本质是看到同一份资源,毫不相干的进程可以通过命名管道进行通信。
- 匿名管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
- 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
- 命名管道是一种特殊类型的文件
创建一个命名管道
命名管道可以直接在命令行上进行创建。
mkfifo filename
利用命名管道让两个毫不相干的进程进行通信
#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fd=open("filename",O_WRONLY);
char *mag="ni hao a\n";
write(fd,mag,strlen(mag));
close(fd);
return 0;
}
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
int main()
{
char buff[64];
int fd=open("filename",O_RDONLY);
ssize_t s=read(fd,buff,sizeof(buff));
buff[s]='\0';
printf("srever----->client:%s",buff);
close(fd);
return 0;
}
运行结果
srever----->client:ni hao a
命名管道也可以从程序里创建,相关函数有:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename,mode_t mode);
第一个参数是文件名或者路径。第二个参数是文件的权限。
成功是返回0,失败是返回-1。
匿名管道与命名管道的区别
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,打开用open
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。
- 命名管道作用于毫不相干的进程,匿名管道作用于具有血缘关系的进程
例子1-用命名管道实现文件拷贝
原理:
把一个普通文件的内容通过server1进程读取到管道中,再通过client1进程创建新的一个文件,并把管道中的内容写入新文件中,这样就完成有文件的copy.
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
umask(0);
int fd1=open("copyfile",O_RDONLY);
if(mkfifo("filename",0666)<0){
perror("mkfifo!");
return 1;
}
int fd=open("filename",O_WRONLY);
char mag[128];
while(1){
mag[0]=0;
ssize_t s=read(fd1,mag,sizeof(mag));
if(s>0){
mag[s]=0;
write(fd,mag,strlen(mag));
}
else{
break;
}
}
return 0;
}
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
umask(0);
int fd1=open("copy",O_WRONLY|O_CREAT,0666);
int fd=open("filename",O_RDONLY);
char buff[128];
while(1){
buff[0]=0;
ssize_t s=read(fd,buff,sizeof(buff));
if(s>0){
write(fd1,buff,strlen(buff));
}
else{
break;
}
}
return 0;
}
例子2-用命名管道实现server&client通信
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
umask(0);
if(mkfifo("filename",0666)<0){
perror("mkfifo!");
return 1;
}
int fd=open("filename",O_WRONLY);
if(fd<0){
perror("open!");
return 1;
}
char mag[128];
while(1){
mag[0]=0;
printf("server say $:");
ffshul(stdout);
ssize_t s=read(0,mag,sizeof(mag));
if(s>0){
mag[s-1]=0;
write(fd,mag,strlen(mag));
}
else{
printf("error\n");
break;
}
}
return 0;
}
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
int fd=open("filename",O_RDONLY);
if(fd<0){
perror("open!");
return 1;
}
char buff[128];
while(1){
buff[0]=0;
ssize_t s=read(fd,buff,sizeof(buff));
if(s>0){
buff[s]=0;
printf("server-->client#:%s\n",buff);
}
}
return 0;
}
例子3-用命名管道实现对进程的控制
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
umask(0);
if(mkfifo("filename",0666)<0){
perror("mkfifo!");
return 1;
}
int fd=open("filename",O_WRONLY);
char mag[128];
while(1){
mag[0]=0;
printf("server say $:");
fflush(stdout);
ssize_t s=read(0,mag,sizeof(mag));
if(s>0){
mag[s-1]=0;
write(fd,mag,strlen(mag));
}
else{
printf("error\n");
break;
}
}
return 0;
}
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
int main()
{
int fd=open("filename",O_RDONLY);
char buff[64];
while(1){
buff[0]=0;
ssize_t s=read(fd,buff,sizeof(buff));
if(s>0){
if(fork()==0){
execlp(buff,buff,NULL);
exit(0);
}
waitpid(-1,NULL,0);
}
}
return 0;
}
我们还可以看到,当client进程没有读取时,命名管道的大小还是0,这说明数据并没有刷到磁盘中去。
system V共享内存
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。 管道是需要进入内核的系统调用来进行数据的传输的(write、read……)。
管道通信的本质是基于文件的,而system V没有这种设计。system V是让不同进程的进程地址空间通过页表映射到同一块内存区域上,这快内存区域就叫做共享内存。这种形式提高了数据传输的效率。
共享内存示意图
系统开辟一块空间,通过页表映射到进程的地址空间上。其映射是在虚拟地址空间上开辟空间,让该空间在页表上建立新的映射关系,映射到共享内存(修改页表的映射关系)。 映射建立完成后,可以让不同的进程看到同一块资源。
共享内存函数
shmget函数
#include<sys/ipc.h>
#iclude<sys/shm.h>
功能:用来创建共享内存。
原型:int shmget(key_t key,size_t size,int shmflg);
参数:key:共享内存段的名字。
size:共享内存的大小。
shmflg:权限。由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的。
返回值:成功返回一个非负整数,该共享内存段的标识码。失败返回-1。
既然有共享内存这个设计,那么肯定也会有多个进程都来创建共享内存。创建好的共享内存不可能就放在那里不管了,OS会管理这些共享内存,就如同进程的创建会有PCB一样。OS会维护共享内存的数据结构,当然,这都是OS的事情。
<1> 怎么理解key? 怎么理解shmget函数的返回值? 答:使用共享内存先要找到共享内存,key就是共享内存的名字。key是内核中的。共享内存创建或者获取好了以后,会返回一个非负整数,这个非负整数是用来标识key的,是用户层面的,用来交给我们使用的。
<2> 如何理解size? 答:创建共享内存的大小。一般创建4096bity(4KB)个大小,也就是一页大小。磁盘的Date blocks中一块区域是4KB(这要看文件系统的设定),刚好对应。当然,我们也可以把共享内存的大小写成4097bity……但是,操作系统会把共享内存的大小设定成4096*2,也就是两倍的4096bity。但是我们看到的大小依然是4097bity。
<3> 权限 在创建共享内存中,我们只关心IPC_CREAT和IPC_EXCL。 IPC_CREAT:如果共享内存存在,返回共享内存,如果不存在,再创建。(在调用成功的情况下,一定会获得一个共享内存,但是无法七确认是否是新的) IPC_EXCL:单独使用无意义,经常和IPC_CREAT组合使用。组合使用如果共享内存不存在,则创建。如果存在则出错返回。调用成功一定会获得全新的共享内存。 (通过key值来了解共享内存存不存在)
ftokt函数
#include<sys/types.h>
#include<sys/ipc.h>
功能:创建一个key值
原型:key_t ftok(const char *pathname, int proj_id);
参数:pathname:工程名称(路径名)
proj_id:工程id(数据)
我们可以任意指定,如果不成功,我们可以修改一下。
返回值:成功时,返回生成的密钥值。失败时,返回-1,其中errno表示stat(2)系统调用的错误。
我们通过上面两个函数就可以创建一个共享内存。
#include<stdio.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<sys/types.h>
#define PATH_NAME "/home/cxy/text_cxy/c_11_22"
#define PROJ_ID 0x77
#define SIZE 4096
int main()
{
key_t key=ftok(PATH_NAME,PROJ_ID);
if(key<0){
perror("fyok");
return 1;
}
int shmid=shmget(key,SIZE,IPC_CREAT|IPC_EXCL);
if(shmid<0){
perror("shmget");
return 2;
}
printf("key:%x\n",key);
printf("shmid:%d\n",shmid);
return 0;
}
运行结果:
key:77011c7d
shmid:2
查看共享内存:ipcs -m 命令 大家有没有发现一个问题,我的进程退出了,查看共享内存时,共享内存为什么还存在?。
答:这是因为,进程结束,共享内存不会被释放。生命周期随内核的。进程不主动删除或用命令删除共享内存一直存在,直到关机重启。
使用命令ipcrm -m shmid值 释放共享内存
shmctl函数
#include<sys/ipc.h>
#include<sys/shm.h>
功能:用于控制共享内存
原型:int shmctl(int shmid, int cmd, struct shmid_ds *buf)
参数:shmid:key的标识符
cmd:将要采取的动作
buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回0,失败返回-1。
命令 | 说明 |
---|
IPC_STAT | 把shmid_ds结构中的数据设置未共享内存的当前关联值 | IPC_SET | 在进程有足够权限的前提下,把共享内存的当前值设置为shmid_ds数据结构中给出的值 | IPC_RMID | 删除共享内存段 |
我们删除共享内存只需要IPC_RMID。第三个参数设置NULL就行,现在不讲,后面会提这个结构体。
int main()
{
……
shmctl(shmid,IPC_RMID,NULL);
return 0;
}
shmat函数
共享内存创建出来了,是需要关联的,也就是说,要建立映射的。
#include<sys/types.h>
#include<sys/shm.h>
功能:将共享内存连接到进程地址空间
原型:void *shmat(int shmid, const void *shmaddr, int shmflg)
参数:shmid:key的标识符
shmaddr:要连接到那一段进程地址空间的地址
shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
返回值:成功返回一个指针,指向对应共享内存的映射到进程地址空间中的虚拟地址的起始地址;失败返回-1。
一般在设置shmaddr的时候设置为NULL,让操作系统去找一个地方挂接。因为在一般情况下,我们是无法指定进程地址空间上的地址的。第三个参数我们设置为默认值0,为可读可写的。
int main()
{
……
char* mem=shmat(shmid,NULL,0);
return 0;
}
说明: shmaddr为NULL,内核会自动选择一个地址 shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。 shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA) shmflg=SHM_RDONLY,表示连接操作用来只读共享内存。
shmdt函数
关联完成后,在删除之前,我们要先去掉关联。
#include<sys/types.h>
#include<sys/shm.h>
功能:将共享内存段与当前进程脱离
原型:int shmdt(const void *shmaddr)
参数:shnaddr:由shmat所返回的指针
返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段。
int main()
{
……
char* mem=shmat(shmid,NULL,0);
shmdt(mem);
return 0;
}
实例代码-server&client简易通讯
shm.h
#include<sys/ipc.h>
#include<sys/shm.h>
#include<sys/types.h>
#include<unistd.h>
#define PATH_NAME "/home/cxy/text_cxy/c_11_22"
#define PROJ_ID 0x77
#define SIZE 4096
server.h
#include"shm.h"
#include<stdio.h>
int main()
{
key_t key=ftok(PATH_NAME,PROJ_ID);
if(key<0){
perror("fyok");
return 1;
}
int shmid=shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0666);
if(shmid<0){
perror("shmget");
return 2;
}
char *mem=shmat(shmid,NULL,0);
while(1){
printf("server#%s\n",mem);
sleep(1);
}
shmdt(mem);
if(shmctl(shmid,IPC_RMID,NULL)<0){
perror("shmctl");
return -1;
}
return 0;
}
client.h
#include<stdio.h>
#include"shm.h"
int main()
{
key_t key=ftok(PATH_NAME,PROJ_ID);
if(key<0){
perror("ftok");
return 1;
}
int shmid=shmget(key,SIZE,IPC_CREAT);
if(shmid<0){
perror("shmget");
return 2;
}
char* mems=shmat(shmid,NULL,0);
int i=0;
while(1){
mems[i]='a'+i;
i++;
mems[i]=0;
sleep(1);
}
shmdt(mems);
return 0;
}
运行结果: 注意:ctrl+C结束进程server,不会释放共享内存。
命令ipcs -m 下的数据是什么
使用ipcs -m 可以查看共享内存。 我们已经知道了key 是共享内存的名称、shmid是key的标识符。 下面的是: owner:用户 perms:权限(在创建共享内存时,可以设置,上面代码中可以看到) bytes:共享内存大小(该大小是我们设置的大小,当我们设置4097时,bytes为4097,但实际大小为2*4097) nattch:共享内存的关联数量(使用shmat函数关联的数量)
管道和共享内存的区别
1、管道是通过文件来进行通信的。共享内存则是让不同的进程通过页表的映射看到同一块内存区域,进行通信时不用到内核中进行数据的传输,在效率上高于管道。
2、管道自带同步于互斥,共享内存不提供同步和互斥,共享内存需要自己去维护同步和互斥,这里可以通过信号量来完成。
3、管道的生命周期随进程,共享内存的生命周期随内核。
共享内存数据结构
struct shmid_ds {
struct ipc_perm shm_perm;
int shm_segsz;
__kernel_time_t shm_atime;
__kernel_time_t shm_dtime;
__kernel_time_t shm_ctime;
__kernel_ipc_pid_t shm_cpid;
__kernel_ipc_pid_t shm_lpid;
unsigned short shm_nattch;
unsigned short shm_unused;
void *shm_unused2;
void *shm_unused3;
};
其中struct ipc_perm shm_perm; /* operation perms */ 数据结构中,就有key。key是在这个数据结构中的,权限也在。
system V消息队列
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法。 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值。 通过类型来判断这个这块数据是谁发给谁的。 消息队列也有一块结构体,该结构体的第一个字段和共享内存的结构体字段相同。 IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核 柔性数组中存放的是指向ipc_perm结构体的地址。解引用就是消息队列的地址。
system V信号量
共享内存是不提供同步和互斥的,信号量就可以完成互斥。 首先我们要知道临界资源,访问临界资源的代码我们叫做临界区,我们需要保护的就是这块临界区,用信号量保护。 对于申请一块资源,进程就必须要占有这块资源吗?当然不是,就好比去电影院看电影,不是要到座位上去了,这个座位就是你的,而是买了票,这个座位就是你的。我们进程只要申请信号量成功了,就一定有你的资源。 信号量有二元信号量和多元信号量,这里我们讲的是二元信号量。 对于进程A和进程B,要访问同一块资源时,为了防止它们同时访问造成错误,我们使用信号量。 信号量本质是一个计数器,下面通过伪代码来表示出来。 PV操作保证了互斥,整体来看,一个进程访问共享内存,那么没有访问,要么访问完毕了,这种叫做原子性。
信号量保护了临界资源,但是信号量本身也是临界资源,谁来保护信号量呢? 其实不用谁来保护,PV操作,必须保证原子性,就是说信号量本身具有原子性。 进程A和进程B属于竞争申请。
|