进程间通信介绍
进程间通信简称IPC(interprocess communication),进程间通信就是多个进程在交换信息。
进程间通信目的
- 数据传输: 一个进程需要将它的数据发送给另一个进程。
- 资源共享: 多个进程之间共享同样的资源。
- 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的本质
要让两个进程通信的前提是:让两个进程看到同一个资源。 这个资源可以是文件,也可以是内存。
进程通信方式
有三大类
- 管道
- system v IPC
- POSIX IPC
管道
管道的定义
管道是最古老的进行通信的一种方式。 进程与进程的数据交流通过管道。 管道其实是一个文件。
举个例子: 下面命令行的功能是显示bash的进程信息 具体实现逻辑是这样的: 问题来了:具体ps是怎么把数据写进管道的,grep又是怎么把数据读出来的?
后面讲管道种类会讲。
匿名管道
匿名管道只能用于具有亲缘关系之间的进程进行通信。 比如说:兄弟进程,父子进程,爷孙进程等等…
为什么叫匿名管道? 因为这个文件没有名字,只有文件描述符。
匿名管道的原理
之前说过,进程通信的本质是让两个进程看到同一份资源。 而管道是一个文件,因此匿名管道的原理就是让两个进程看到同一个文件即可。(文件描述符角度)系统角度是让两个进程看到同一个inode即可。
注:
- 虽然说管道是一个文件,但是我们在把数据往管道文件写的时候,管道文件的大小并不会发生变化,把数据从管道上读出来的时候,管道文件大小也不会发生变化。因此管道的读写是与IO无关的。(不会把数据写到硬盘上面)
- 为什么子进程可以指向和父进程同样的文件。之前讲过,没有发生写时拷贝的数据是只读的,只读的数据其实是共享的。这里没有发生写时拷贝,因此父子进程看到的是同一份文件
- 管道只能单向通信,如果要双向通信,就要开两个管道
- inode是文件系统的数据结构,因此两个进程是共有的。
pipe函数
fd是输出型参数。打开pipe之后fd数组里面会放着两个新打开的文件描述符
数组元素 | 含义 |
---|
fd[0] | 管道读端的文件描述符 | fd[1] | 管道写端的文件描述符 |
记忆方法:0像一张嘴,可以读。1像一只笔,可以写
图示:
匿名管道使用步骤
1.父进程创建管道
2.fork子进程 3.父进程关闭读端(或者写端),子进程关闭写端(或者读端) 具体谁写谁读都可以,只要让管道是单向流通的就可以。
使用pipe实现一个管道通信
代码:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd[2] = {0};
int res = pipe(fd);
if(res < 0)
{
perror("pipe");
return 1;
}
pid_t id = fork();
if(id == 0)
{
close(fd[0]);
const char* msg = "i am child : i'm writing ...\n";
int count = 0;
while(count++ < 5)
{
write(fd[1], msg, strlen(msg));
sleep(1);
}
}
else if(id > 0)
{
close(fd[1]);
char buffer[100];
while(1)
{
ssize_t s = read(fd[0], buffer, sizeof(buffer) - 1);
if(s > 0)
{
buffer[s] = 0;
printf("%s", buffer);
}
else if(s == 0)
{
printf("read file end\n");
break;
}
else
{
printf("read error");
break;
}
}
}
return 0;
}
文件描述符理解匿名管道(重点)
同一份资源在文件描述符角度指的是管道这个文件
内核角度理解pipe(重点)
注:struct files_struct 和struct file都是进程管理文件的数据结构。inode是文件系统的数据结构。进程间的同一份资源在内核角度是指inode
管道读写特点,4个实验(重点)
有四个。
1.当管道为空,读端还在一直读,那么读端就会阻塞。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd[2] = {0};
int res = pipe(fd);
if(res < 0)
{
perror("pipe");
return 1;
}
pid_t id = fork();
if(id == 0)
{
close(fd[0]);
const char* msg = "i am child : i'm writing ...\n";
int count = 0;
while(1)
{
write(fd[1], msg, strlen(msg));
sleep(5);
}
}
else if(id > 0)
{
close(fd[1]);
char buffer[100];
while(1)
{
ssize_t s = read(fd[0], buffer, sizeof(buffer) - 1);
if(s > 0)
{
buffer[s] = 0;
printf("%s", buffer);
}
else if(s == 0)
{
printf("read file end\n");
break;
}
else
{
printf("read error");
break;
}
}
}
return 0;
}
现象: 子进程写入的时候sleep(5)秒,父进程读取的时候,虽然没有加上sleep,但是他也跟着sleep了五秒之后才读取并打印出来。
这验证了我们的结论:当管道为空的时候,如果读端继续读,那么读端会阻塞等待
注:管道不为空的时候,就算读端继续读且写端不写了。读端也不会堵塞
2.当管道被写满时,写端还在继续写,那么写端就会被阻塞 (写端狂写)
代码: 现在我让写端疯狂写,不sleep了。读端每读一次sleep(1)。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd[2] = {0};
int res = pipe(fd);
if(res < 0)
{
perror("pipe");
return 1;
}
pid_t id = fork();
if(id == 0)
{
close(fd[0]);
const char* msg = "i am child : i'm writing ...\n";
int count = 0;
while(1)
{
write(fd[1], msg, strlen(msg));
printf("child: %d\n", count++);
}
}
else if(id > 0)
{
close(fd[1]);
char buffer[100];
while(1)
{
ssize_t s = read(fd[0], buffer, sizeof(buffer) - 1);
if(s > 0)
{
buffer[s] = 0;
printf("%s", buffer);
sleep(5);
}
else if(s == 0)
{
}
else
{
printf("read error");
break;
}
}
}
return 0;
}
结果: 我们发现写端写到2255行之后就不写了。 因此可以验证结论:当管道满了之后,写端还想继续写的话,会被阻塞。
注:管道不满时,就算写端还想写,还是可以写的,不会堵塞
3.如果把写端fd关闭之后,读端读完管道之后,会读到end of file。 (先关写端,再关读端)
代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd[2] = {0};
int res = pipe(fd);
if(res < 0)
{
perror("pipe");
return 1;
}
pid_t id = fork();
if(id == 0)
{
close(fd[0]);
const char* msg = "i am child : i'm writing ...\n";
int count = 0;
while(1)
{
write(fd[1], msg, strlen(msg));
printf("child: %d\n", count++);
if(count == 5)
{
close(fd[1]);
break;
}
}
exit(2);
}
else if(id > 0)
{
close(fd[1]);
char buffer[100];
while(1)
{
ssize_t s = read(fd[0], buffer, sizeof(buffer) - 1);
if(s > 0)
{
buffer[s] = 0;
printf("%s", buffer);
sleep(5);
}
else if(s == 0)
{
}
else
{
printf("read error");
break;
}
printf("father exit return : %d\n", s);
if(s == 0) break;
}
}
return 0;
}
结果 大致讲一下过程:子进程写了5次之后关闭了fd。然后父进程读取信息,第一次读了99个字符。睡眠5秒,第二次读了46个字符睡眠5秒,此时已经读完了,管道已经为空了。因此第三次读了end of file,即0值。
4.写端还在写的时候,读端把fd关闭了。写端的进程可能会被直接杀掉 (先关读端再关写端)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd[2] = {0};
int res = pipe(fd);
if(res < 0)
{
perror("pipe");
return 1;
}
pid_t id = fork();
if(id == 0)
{
close(fd[0]);
const char* msg = "i am child : i'm writing ...\n";
int count = 0;
while(1)
{
write(fd[1], msg, strlen(msg));
printf("child: %d\n", count++);
}
exit(2);
}
else if(id > 0)
{
close(fd[1]);
char buffer[100];
int count = 0;
while(1)
{
ssize_t s = read(fd[0], buffer, sizeof(buffer) - 1);
if(s > 0)
{
buffer[s] = 0;
printf("%s", buffer);
sleep(5);
}
else if(s == 0)
{
printf("read file end\n");
close(fd[0]);
break;
}
if(count++ == 3) close(fd[0]);
}
}
return 0;
}
结果: 跑着跑着子进程变成僵尸了,意味着子进程退出了。 大致讲一下过程: 首先子进程一直写,写满了管道之后,由于写端没有关闭fd,因此写端堵塞了。读端开始读,读到第四次的时候,我们手动把读端的fd关闭了。此时系统检测到读端不读了,因此写也没有必要了,直接杀掉了子进程。由于父进程还在running,因此子进程变成僵尸了。
这个子进程是被信号杀掉的。我们实验一下是什么信号 用waitpid回收子进程
是13号进程。 是SIGPIPE信号
管道5个特征
1.管道只能让有亲缘关系的进程通信 2.管道是流式服务。流式服务是和数据报服务相反的。数据报是一次固定拿一定的数量,而流式服务是你想拿多少就拿多少。 3.进程退出,管道释放。因此管道文件的生命周期和进程一样 4.内核会对管道操作进行同步和互斥。
互斥:我读的时候,你就别写了。你写的时候,我就别读了。 同步:
5.管道的通信是单向的。要实现双向就要两个管道。
用实例解释匿名管道
这个命令的执行过程是怎样的?
首先,who和grep这两个进程是兄弟进程,因此可以用管道。
首先创建一个管道。
然后who因为要写入管道,因此关闭读端。close(fd[0]) 因为grep要读入管道,因此要关闭写端.close(fd[1]) (单向性决定)。
因为who本来是写到显示器的,现在要写到管道。因此需要重定向,使用dup2 因为本来grep是从显示器中读入的,现在要从管道读,也要重定向,使用dup2
命名管道
匿名管道的限制是只能在有亲缘关系的进程之间通信。 命名管道可以在不相关的进程之间交换数据
如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道
- 命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
mkfifo filename
int mkfifo(const char *filename,mode_t mode);
mode是创建的时候的权限,这个权限受umask影响。
创建fifo代码:
#include <stdio.h>
#include <sys/types.h>
int main()
{
mkfifo("fifo", 0666);
}
结果:
用代码fifo实现通信程序
创建两个客户端 server端读,client写
注: 1.这两个进程是没有关系的。因为它们两个的bash不同。 2.fifo只是一个标志,没有发生IO行为,因此不管输入什么fifo的大小都是0 3.当read返回值大于0的时候,证明读取成功。 4.当read返回值等于0的时候,证明写端关闭了,读端读到了end of file
指令mkfifo
创建一个管道。
一边一直向fifo里面打印 一直循环打印
匿名管道和命名管道对比
system V 共享内存
简单来说,就是很多个进程。它们的共享区经过映射指向同一块物理内存。
共享内存数据结构
共享内存可以在物理内存中存在很多份,因此要用ds来管理起来。
shmid_ds
shm_atime 上一次挂接时间 shm_dtime 上一次取消挂接时间 shm_ctime 上一次改变时间 shm_nattach 当前这块共享内存有多少个进程在挂接使用 shm_segsz 共享内存块的大小 shm_segments size
ipc_perm
每一个共享内存都有一个key,进程如何知道它挂接到哪一块共享内存了?就是根据这个key
ftok
key_t ftok(const char *pathname, int proj_id)
ftok可以根据你提供的这两个参数,得到一个唯一的key值,用来表示这一块共享区。(原理可能是使用了字符串哈希)
注:只是拿到一个唯一的key,系统什么也没干
shmget
int shmget(key_t key, size_t size, int shmflg)
-
key 创建共享内存。 拿到一份内存之后,把这份内存编上号,号码是key -
size 想申请的内存大小,系统只会为你申请你申请4096的整数倍。但是显示给你看的时候还是你申请的大小 -
shmflg 有九个权限标识。 常用的是IPC_CREAT IPC_EXCL。 IPC_CREAT 没有这块内存就创建。 IPC_EXCL 有这块内存就报错 IPC_CREAT | IPC_EXCL 没有就创建,有就报错。因此创建的绝对是一块新的内存。
实验:如果存在了一块对应key的共享内存,就会返回这样的错误信息。
返回值
查找共享内存命令
ipcs -m
key 系统层面标识唯一性 shmid 系统层面定位共享内存 owner 拥有者 perms 权限 bytes 大小 nattch 挂接的数量
删除共享内存命令
ipcrm -m -shmid
注:共享内存是需要手动删除的,共享内存的生命周期随内核
shmctl删除共享内存
int shmctl(int shmid, int cmd, struct shmid_ds *buf)
这个接口有点复杂,我们只需要知道cmd填IPC_RMID,第三个参数填NULL即可。
IPC_RMID命令解析:
shmat
void * shmat(int shmid, const void *shmaddr, int shmflg)
这个接口用来给共享内存挂接进程的。shmaddr直接给NULL即可,不用关心。 shmflg是挂接方式,默认挂接方式是读写。因此给0即可。
关于shmaddr给null的解释: 关于shmflg给0的解释: 返回值:返回的是共享内存对应挂接进程的虚拟地址。和malloc一样性质,返回的都是虚拟地址。
我这里把返回的地址当成字符串了。
shmdt
用处:取消挂架
int shmdt(const void *shmaddr);
参数是挂接函数shmat的返回值。
共享内存没有同步和互斥
做一个实验: 让client端5秒输入数据进内存一次,让server端1秒从内存读一次数据。
发现结果:在client不写的时候,server端并不会等待,会一直打印老旧信息。
证明共享内存没有同步和互斥。
实验代码: server端:
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include "comm.h"
#include <sys/shm.h>
#include <unistd.h>
int main()
{
key_t k = ftok(PATHNAME, PROJ_ID);
printf("key : %x\n", k);
int shmid = shmget(k,SIZE, IPC_CREAT | IPC_EXCL | 0666);
if(shmid < 0)
{
perror("shmget");
return 1;
}
printf("shmid:%d\n", shmid);
char* s = (char*)shmat(shmid, NULL, 0);
while(1)
{
sleep(1);
printf("%s\n", s);
}
shmdt(s);
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
client端:
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include "comm.h"
#include <sys/shm.h>
#include <unistd.h>
int main()
{
key_t k = ftok(PATHNAME, PROJ_ID);
printf("key : %x\n", k);
int shmid = shmget(k,SIZE,IPC_CREAT);
if(shmid < 0)
{
perror("shmget");
return 1;
}
printf("shmid:%d\n", shmid);
char* s = (char*)shmat(shmid, NULL, 0);
char ch = 'a';
for(; ch <= 'z'; ch++)
{
s[ch - 'a'] = ch;
sleep(5);
}
shmdt(s);
return 0;
}
system V 消息队列
进程和进程之间用队列进行通信。
有很多消息队列,因此要用ds管理起来
里面有一个msg_perm 和shm_perm是同样类型的。
system V 信号量
信号量主要用于同步和互斥的,下面先来看看什么是同步和互斥。
进程互斥
因为要通信,所以要看到同一份资源。因为看到了同一份资源(这种资源被称为临界资源,我们把访问临界资源的代码叫临界区)。
有可能发生一种情况:我在写的时候你在读,这样可能造成数据不一致。
为了解决这个问题:要进程互斥。任何一个进程在操作的时候,其他进程都不能操作。
什么是system V
我们发现shm,msq,sem三种system V 通信方式里面的ds和接口参数都很像,这是因为system V就是一个标准,标准要求它们相似。
|