一.进程通信的概念
为什么要进程通信?
进程通信:顾名思义,应该是两个进程间进行通信。
进程之间具有独立性,每个进程都有自己的虚拟地址空间,进程A不知道进程B的虚拟地址空间的数据内容(类似于一个人不知道另一个人脑子里在想啥)
二.进程间通信方式的分类
进程间通信方式的共同点:进程间需要“介质”—两个进程都能访问到的公共资源。
常见的通信方式:
-
a.文件(最简单的方法)
假如用vim打开一个test.c文件,这时候会自动产生一个以文件名结尾的.swap文件,用于保存数据。
当正常关闭时,此文件会被删除。当文件非正常关闭时(比如编辑代码时突然断网),如果此时再次通过vim打开该文件,就会提示存在.swap文件,此时你可以通过它来恢复文件:vim -r filename.c 恢复以后把.swap文件删掉,就不会再出现一堆提示了。所以该文件存在就是为了进行进程中的通信。 -
b.管道
1.管道定义:一个进程连接到另一个进程的数据流。
ps aux | grep test
,将前一个进程(ps)的输出作为后一个进程(grep)的输入两进程间通过管道进行通信。
ps aux | -l
:wc指word count,-l指行数,将ps aux进程的标准输出作为wc -l的标准输入。
2.管道分类
匿名管道和命名管道。
匿名管道
管道是在内核中的一块内存(构成了一个队列),使用一对文件描述符来操作内核中的内存。当前的文件描述符就是内存的句柄,此时读文件描述符—从队列中取数据,写文件描述符—往队列中插数据。Linux中,一切皆文件,所以可以借助管理文件的思想来管理内存。
1.匿名管道特点
- 使用完需要及时关闭文件描述符close();
- 匿名管道必须用于具有亲缘关系之间的进程(父子进程,爷孙进程,兄弟进程),两个操作不同管道的进程之间无法行通信。
- 管道提供流式服务。
- 管道的生命周期随进程,所有引用管道的进程退出,管道就释放。
- 内核会对管道进行同步和互斥。
- 管道是半双工的,数据只能向一个方向流动。需要两个进程之间双向通信时,就需要两个管道。
2.操作管道的函数
#include <unistd.h>
//函数原型
int pipe(int fd[2]);
//输出型参数 fd:文件描述符数组,其中fd[0]表示读端(读数据), fd[1]表示写端(写数据)
//返回值:返回值<0时,pipe失败,否则成功
下面我就来使用pipe函数创建一对文件描述符,通过这一对问价描述符来操作内核中的管道。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
//pipe
int fd[2];
int ret=pipe(fd);
if(ret<0)
{
perror("pipe");
return 1;
}
//write words to pipeline
char buf_write[1024]="hello pipe!";
write(fd[1],buf_write,strlen(buf_write));
char buf_read[1024]={0};
ssize_t n=read(fd[0],buf_read,sizeof(buf_read)-1);//a place for '\0'
buf_read[n]='\0';
printf("%s\n",buf_read);
//close fd
close(fd[0]);
close(fd[1]);
return 0;
}
运行后看结果:输出结果是从fd[0]中读取出来的,它对应的就是通过write写进去的管道中的数据。
熟悉了一下pipe函数的用法后,下面需要进行两个进程间的通信。
原理:当fork出来一个子进程时,会复制父进程的PCB,由于PCB中包括文件描述符表,所以文件描述符表也会被复制一份,子进程也能访问到相同的管道这个时候就可以实现一个进程往管道中写数据,一个进程从管道中读数据。(父子进程读写都没什么区别)
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
//pipe
int fd[2];
int ret=pipe(fd);
if(ret<0)
{
perror("pipe");
return 1;
}
pid_t ret1=fork();
if(ret1>0)
{
//father 读数据
char buf_write[1024]="hell0,father!\n";
write(fd[1],buf_write,strlen(buf_write));
}
else if (ret==0)
{
//child 写数据
char buf_read[1024]={0};
ssize_t n=read(fd[0],buf_read,sizeof(buf_read)-1);
buf_read[n]='\0';
printf("child read:%s\n",buf_read);
}
else{
perror("fork");
}
//close fd
close(fd[0]);
close(fd[1]);
return 0;
}
运行结果:子进程确实打印了父进程写入管道的数据。父进程会向管道中写入数据,子进程就会从管道中读数据
如果尝试父进程写,父子进程同时读?
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
//pipe
int fd[2];
int ret=pipe(fd);
if(ret<0)
{
perror("pipe");
return 1;
}
pid_t ret1=fork();
if(ret1>0)
{
//father 读数据
char buf_write[1024]="hell0,father!\n";
write(fd[1],buf_write,strlen(buf_write));
char buf_read[1024]={0};
ssize_t n=read(fd[0],buf_read,sizeof(buf_read)-1);
buf_read[n]='\0';
printf("father read:%s\n",buf_read);
}
else if (ret==0)
{
//child 写数据
char buf_read[1024]={0};
ssize_t n=read(fd[0],buf_read,sizeof(buf_read)-1);
buf_read[n]='\0';
printf("child read:%s\n",buf_read);
}
else{
perror("fork");
}
//close fd
close(fd[0]);
close(fd[1]);
return 0;
}
结果是父进程先读到数据。
假如我在写数据的下面加上一个sleep(1),即延迟父进程读数据过程。再次编译执行后,结果就是子进程先读到数据,并且好像阻塞住了。
那么关于父子进程谁先读到数据,取决于谁的read函数先执行。
管道内置的“同步互斥机制”限制了:
- 不会出现两个管道一个读一半数据的错乱情况。
- 如果管道中的数据一旦被读,就相当于出队列,这个时候管道就为空,如果管道为空,如果有多个进程尝试来读数据,都会读不到,就会在read函数处阻塞。
- 如果管道满了,就会在write函数处阻塞。
这就可以解释上面运行结果的阻塞了,先打开一个新的会话窗口,用ps aux 来查看一下当前的进程。再通过gdb attach+进程号来调试正在运行的进程,看父进程是否在read函数处阻塞。
进入调试后敲bt,查看调用栈,可以看到有两行,第一行的这个函数就是read函数,证明当前父进程就是阻塞在read函数处。
接下来再来看看在什么情况下管道会满,我现在尝试一直网管道写数据,只写不读。
int count=0;
while(1)
{
write(fd[1],"a",1);
printf("count:%d\n",count);
count++;
}
可以看到,管道最大容量是65535。此时如果像上面方法一样再用gdb attach调试当前进程,就可以证明,管道写满后,就会在write函数处阻塞。
为什么pipe要放在fork的上面?
命名管道
命令行创建语句:mkfifo filename
下面就来尝试一下使用该语句创建一个命名管道,可以看到一种新的文件类型p类型。
下面就来尝试使用该命名管道进行通信。将读数据和写数据放在两个可执行程序中,对应两个进程,一个尝试读取,另一个尝试写入。
//read
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
//操作命名管道,与文件一样
//1.先打开命名管道,只读
int fd =open("./myfifo",O_RDONLY);
if(fd<0)
{
perror("read open");
return 1;
}
//2.读数据
while(1)
{
char buf[1024]={0};
ssize_t n=read(fd,buf,sizeof(buf)-1);
if(n<0)
{
perror("read");
return 1;
}
if(n==0)//所有写端关闭,读段已经结束
{
printf("read over\n");
return 0;
}
buf[n]='\0';
printf("readbuf:%s\n",buf);
}
close(fd);
return 0;
}
//write
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int main()
{
//先打开管道(文件)
int fd=open("./myfifo",O_WRONLY);
if(fd<0)
{
perror("write open");
return 1;
}
//写数据
while(1)
{
//提示用户输入一个数据
printf("enter>:");
fflush(stdout);
char buf[1024]={0};
ssize_t n=read(0,buf,sizeof(buf)-1);//0--是stdin的文件描述符
if(n<0)
{
perror("write");
return 1;
}
buf[n]='\0';
write(fd,buf,strlen(buf));
}
close(fd);
return 0;
}
两个代码编译后,如果先执行./fiforeadtest,则会阻塞在open处,因为此时该管道只有一个人按read方式打开,没有人按write方式打开,那么read就不能打开这个文件。
执行./fifowritetest后(但不输入写入内容时),会阻塞在read函数处,因为管道中为空。
只有在执行./fifowritetest且输入写入内容后,read函数才能读出内容。
那么这个时候,每写入一个字符串,就会显示该字符串到显示屏,让我想起了聊天小窗口。
匿名管道和命名管道的区别
- 匿名管道只限于具有亲缘关系之间的进程(父子进程,爷孙进程,兄弟进程),两个操作不同管道的进程之间无法行通信。而对于命名管道,任何的多个进程之间都能通信。
其余的部分两种管道都相同,要注意用mkfifo创建出来的 myfifo这个文件仅仅是管道的一个入口,管道的本体依然是内核中的一个内存。所谈到的生命周期是围绕着内核中的内存来讨论的。
- c.System V 进程间通信
System V 共享内存
相比于管道来说,共享内存要更加高效,直接访问内存即可完成通信。而管道涉及到用户态和内核态之间的数据相互拷贝,效率比较低。共享 内存的生命周期随内核,共享内存会一直存在到手动释放或者系统重启。
共享内存的使用方式
1.在内核中创建出共享内存的对象,并打开(这里创建和打卡的都是物理内存)
2.多个进程附加到这个共享内存对象上(shmat 使得共享内存和进程之间建立联系–虚拟地址空间与物理内存之间取得映射)
3.直接读写使用这个共享内存
1.在内核中创建出共享内存的对象
//创建对象
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
//key--身份标识(键值对) 可找到一个具体的共享内存对象
//shmflg--选项 对应两个宏:IPC_CREAT(已经存在直接打开,不存在就创建)|IPC_EXCL(已经存在,就会返失败)位图方式表示。
//获取key
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
//pathname--路径(必须是存在的路径)
//proj_id--一个随机的数字
//只要这两个参数相同,得到的key就会永远相同
//举例
key_t key=ftok(".",0x1);
if(key==-1)
{
perror("ftok");
return 1;
}
printf("%d\n",key);
下面我们就来创建对象
#include <stdio.h>
#include <unistd.h>
#include <sys/shm.h>
int main()
{
//获取key
key_t key=ftok(".",0x3);
if(key==-1)
{
perror("ftok");
return 1;
}
printf("key:%d\n",key);
//创建对象
int ret=shmget(key,1024,IPC_CREAT | IPC_EXCL| 0666);//0666--权限
if(ret<0)
{
perror("shmget");
return 1;
}
printf("ret:%d\n",ret);
return 0;
}
创建结果:
验证:用ipcs -m 查看当前系统上的共享内存,可以看见,ret为622597的共享内存已经创建。(一旦电脑关机,共享内存就会结束)
2.多个进程附加到这个共享内存对象上
因为考虑到代码重复,我将之前创建对象的代码封装了一下,写成一个方法放在一个myshm.h的文件中,调用这个方法时,引入这个头文件即可。
//myshm.h
#include <stdio.h>
#include <unistd.h>
#include <sys/shm.h>
int CreateShm()
{
key_t key=ftok(".",0x3);
if(key==-1)
{
perror("ftok");
return 1;
}
printf("key:%d\n",key);
int ret=shmget(key,1024,IPC_CREAT | IPC_EXCL| 0666);//0666--权限
if(ret<0)
{
perror("shmget");
return 1;
}
printf("ret:%d\n",ret);
return ret;
}
修改后的createmem.c:
分别创建一个reader.c和一个writer.c对应一会通过共享内存进行通信的两个进程。
将进程附加到内存上时使用的函数(类似于malloc的使用):
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
//shmid--共享内存的句柄 shmget的返回值,
//shmaddr--指定与物理内存映射的虚拟地址空间,一般传空指针,系统说了算
//shmflg--flag一般默认为0
//返回值:void*
int shmdt(const void *shmaddr);//解出物理内存与虚拟地址空间的映射关系
//writer
#include "myshm.h"
#include <string.h>
int main()
{
//往共享内存中写数据
//1.创建共享内存
int shmid=CreateShm();
//2.附加进程到共享内存上
char*p=(char*)shmat(shmid,NULL,0);
//3.使用该空间
strcpy(p,"hello sharespace!");
return 0;
}
//reader
#include "myshm.h"
int main()
{
//从共享内存中读数据
//1.创建/打开共享内存
int shmid=CreateShm();
//2.附加到共享内存上
char* p=(char*)shmat(shmid,NULL,0);
//3.直接使用
printf("reader:%s\n",p);
return 0;
}
最后编译reader.c和write.c,得到结果。
System V 消息队列
广义消息队列:
消息队列也是一个队列,每个元素都带有一个类型。每次按照指定类型(业务场景下的类型)先进先出,找对应指定类型的第一个。
System V 消息队列仅限于进程间通信。很多客户端服务器会共用消息队列服务器集群(中间件)。
System V 信号量
是一个计数器(描述可用资源的个数),主要用于进行进程间的同步和互斥。每次有进程想申请一个可用资源时,计数器-1(P操作),有进程想释放一个可用资源时,计数器+1(V操作).
如果计数器为0,还有进程想申请资源,则会有两种可能:1.进程挂起等待。2.进程放弃申请资源
- d.POSIX 进程间通信
- e.网络(最重要最主要的进程间通信方式)