今天我们来讲解一下进程间的通信。
1. 进程间通信介绍
1.1 进程间通信目的
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知时间:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种时间(比如进程终止要通知父进程)。
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程)。此时进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
1.2 进程间通信的发展
进程通信的发展分为三个阶段;
- 管道
- System V进程间通信
- POSIX进程间通信
其中前两种是针对本地设备之间的进程通信,第三种可以通过网络实现跨设备的进程通信。今天我们只学习后两种,第三种等我们接触了网络之后再讲解。
不管是哪一种通信方式,实际上我们要做的都是 保证不同的进程,看到同一份资源。关于这一观点,在本文中,我会多次强调。
1.3 进程间通信分类
- 管道
- 匿名管道
- 命名管道
- System V IPC
- System V 消息队列
- System V 共享队列
- System V 信号量
- POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
2. 管道
2.1 什么是管道
管道是 UNIX 中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
简单来说,管道就是一个文件,并且以数据的传输为目的而存在。
2.2 匿名管道
匿名管道 的作用是 供具有血缘关系的进程之间进行通信,常见于父子进程之间。
我们先来回忆一下我们之前讲过的IO 文件系统:
【Linux】基础IO详解
其中我们讲了 进程 与 文件之间的关系以及 进程找到文件的原理。管道的原理与这一块内容是强相关的。
2.2.1 创建管道
-
父进程通过 struct files_struct内部的fd_array数组 找到 一个 struct file (文件结构体)(这个文件是pipe_file,并不是随意的一个文件),分别以读方式打开一次,写方式再打开一次。
-
父进程 fork出子进程,那么很显然,子进程也会对该文件分别以读方式打开一次,写方式再打开一次。
- 父进程关闭 fd[0],子进程关闭fd[1],也就是保留父进程的写段,子进程的读端,这样父进程就可以通过管道向子进程传输信息。
那么这里就有两个问题:
-
为什么父进程开始的时候要打开两次文件?
如果不打开读写。那么子进程拿到的文件打开方式必定与父进程的相同,无法通信。同时也更加灵活,父子进程可以选择作为输入还是输出端。 -
为什么一定要关闭一个,不关闭的话不是可以互相传输了吗?
防止误操作
2.2.2 匿名管道创建接口 pipe
int pipe (int pipefd[2]);
其中数组pipefd是一个输出型参数,它存储这拿到的管道文件的文件描述符fd, 两个fd 一个read,一个write.
对于该函数,默认fd[0] 打开的是读端,fd[1]打开的是写端
如代码所示,我们做完了创建匿名管道的第一步,接下来还有两步:
创建子进程,然后将子进程的读端关闭,父进程的写段关闭,建立一个由子向父的信息传输管道
2.2.3 匿名管道的数据传输
完成了匿名管道的建立,现在我们可以来测试一下:对于子进程写入管道的数据,父进程程能否接受到。
- 首先是子进程:
我们调用write向管道中连续输入五行:“hello parent,I am child”。在写入结束之后,要记得关闭写端。
- 然后是父进程
我们调用 read 接受来自管道中的数据。如果read返回值大于0,说明读取成功,如果等于0,说明文件关闭了,也就是读完了。
最后我们对子进程进行进程等待,保证子进程结束之后,在关闭接受端(读端)。
完整代码:
#include<stdio.h>
2 #include<unistd.h>
3 #include<sys/wait.h>
4 #include<string.h>
5 #include<stdlib.h>
6
7 int main()
8 {
9 int pipe_fd[2]={0};
10
11 if(pipe(pipe_fd)<0){
12 perror("pipe");
13 return 1;
14 }
15
16 printf("%d, %d\n",pipe_fd[0],pipe_fd[1]);
17
18 pid_t id = fork();
19 if(id<0){
20 perror("fork");
21 return 2;
22 }
23 else if(id==0) {
24 //child write
25 close(pipe_fd[0]);
26
27 const char* msg =" hello parent,I am child";
28
29 int count=5;
30 while(count){
31 write(pipe_fd[1],msg,strlen(msg));
32 sleep(1);
33 count--;
34 }
35 close(pipe_fd[1]);
36 exit(0);
37
38 }
39 else{
40 //parent read
41 close(pipe_fd[1]);
42
43 char buffer[64];
44 while(1){
45 buffer[0]=0;
46 ssize_t size = read(pipe_fd[0],buffer,sizeof(buffer)-1);
47 if(size>0){
48 buffer[size] = 0;
49 printf("parent get message from child# %s\n",buffer);
50 }
51 else if(size==0){
52 printf("pipe file close ,child quit!\n");
53 break;
54 }
55 else{
56 //TODO
57 break;
58 }
59 }
60
61 int status = 0;
62 if(waitpid(id,&status,0)>0){
63
64 printf("child quit,wait sucess!\n");
65 }
66 close(pipe_fd[0]);
67 }
68
69 return 0;
2.2.4 匿名管道的特性
- 管道 自带同步机制
- 如果管道中没有消息,此时父进程会等待,等待管道内部有数据就绪
- 如果管道中已经写满了,写端不会继续写入,而是等待, 等待管道的内部有空闲空间。
read端 | write端 | 结果 |
---|---|---|
不读 | 写 | write阻塞 |
读 | 不写 | read阻塞 |
不读&关闭 | 写 | write被终止 |
读 | 不读&关闭 | read读取到0代表文件结束 |
如果读端不读,而且关闭读端,写端此时如果继续写,本质就是在浪费系统资源,写进程会立马被OS 发送SIGPIPE信号终止掉。
同时我们还有一个问题,匿名管道的大小是多少?
65536 字节 ,即64KB(linux)
同时我们也可以使用系统命令【ulimit -a】.可以看到pipe size =512字节(4096bytes),约为4kb.这并不矛盾,这是原子性写入管道的单元大小。
- 管道是单向通信的
- 管道是面向字节流的
- 匿名管道只能保证具有血缘关系的进程进行进程通信,常用于父子
- 管道可以保证一定程度的数据读取的原子性(4KB以内)
- 管道的生命周期随进程
2.3 命名管道
管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
命名管道是一种特殊类型的文件。简单来说,普通文件是需要将接受的数据刷新到磁盘的并持久化存储的,但是fio文件(管道文件)不需要,其作用是作为管道用来传递信息。
2.3.1 创建管道
我们可以直接通过命令行来创建命名管道的:
$ mkfifo filename
命名管道也可以从程序里创建,相关函数有:
int mkfifo(const char *filename,mode_t mode);
2.3.2 命名管道实例
多说无益,我们写一个小例子来增进对命名管道的使用与理解。
我们想实现一个客户端程序(client.c) 与一个 服务端程序(server.c),所有客户端输入的数据,都能被服务端实时的接收到。
为了方便之后的编译,我们编写一个Makefile,这里注意要添加all关键字,否则makefile 只会默认编译第一程序。
- 服务端 server.c
- 创建命名管道
- 调用open,打开管道文件
- 调用read ,读取管道中的信息
- 用户端 client.c
- 打开管道文件
- 向管道中写入信息
我们来测试一下:
可以看见,当我们通过用户端输入 语句的时候,服务端能够作出实时的接收。
2.4 匿名管道 与 命名管道 的区别
- 匿名管道由pipe函数创建
- 命名管道由mkfifo函数创建,打开用open
- FIFO(命名管道)与 pipe(匿名管道)之间的唯一区别在于它们打开与创建的方式不同,一旦这些工作完成之后,它们具有相同的语义。
3. system V 共享内存
3.1 什么是共享内存
我们在物理内存上开辟一块 内存空间,并且通过 页表映射到进程地址空间 的 共享区,
共享内存的基本原理是:
- OS申请一块物理内存地址
- OS将该内存映射进入对应的进程的共享区(堆区与栈区之间)
- OS可以将映射之后的虚拟地址返回给用户
也就是说,为了保证大量进程之间的通信,OS内存在提供了大量的共享内存,那么,这些共享内存OS该如何管理?
先描述,再组织。
我们今天学习的主要是两个进程之间的通信(如上图),所以我们可以这样概括system V共享内存:
- 进程向os申请共享内存
- 进程1与进程2分别挂接对应的共享内存到自己的地址空间(共享区)
- 双方此时看到的就是同一份资源,即 可以进行正常的通信
3.2 共享内存的相关操作
1. shmget
功能: 用来创建共享内存
函数原型:
#include<sys/ipc.h>
#include<sys/shm.h>
int shmget(key_t key,size_t size,int shmflg);
参数解释:
- key: 这个共享内存段名字,作用是 进行共享内存之间的唯一性区分
- size: 共享内存大小(建议是4kb 的倍数)
- shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的。
对于第三个参数,我们主要选择IPC_CREAT 和 IPC_EXCEL 一起使用。 如果目标的共享内存不存在,就创建。如果已经存在,则出错返回。除此之外,单独使用IPC_EXCL是没有意义的。
返回值:
成功返回一个非负整数,即该共享内存段的标识码;失败返回-1。
2 ftok
功能: 获取 kEY值
函数原型;
key_t ftok(const char* pathname,int proj_id);
参数解释:
这里的参数可以按照自己的情况,任意填写。只需要之后保证A,B进程填写的是一样的,这样就可以保证AB进程使用的是同一个共享内存。
我们在使用ftok之前,一般在头文件中将pathname 与 proj_id 以宏的方式设定号,方便两个进程拿取。
在 服务端程序(server.c)创建一个共享内存
3 查看共享内存
我们可以通过命令行来获取当前OS 内的共享内存:
$ ipcs -m
可以发现,在我们运行server之前,只有一个共享内存,在我运行之后,多出了一个。
细心的同学可能发现,为什么在server运行结束之后,我们依旧能看到我们创建的共享内存?
这是因为与管道不同,所有的ipc资源都是随内核的,不随进程。只有当我们主动在进程退出的时候,用系统调用释放它,或者OS重启,它才会释放。
所以我们上面的程序就像 打开了文件 却不关闭一样,是有缺陷和隐患的,还要继续改进。
当然,也存在命令行命令可以释放共享内存:
ipcrm -m shmid的值
4 shmctl
功能: 用于控制共享内存
函数原型:
int shmctl(int shmid, int cmd ,struct shmid_ds* buf);
参数:
- shmid : 由shmget返回的共享内存表示码
- cmd: 将要采取的动作 (有三个可取值)
- buf :指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:
成功返回0,失败返回-1
这里我们主要学习使用IPC_RMID 来删除共享内存段:
5 shmat
功能:将共享内存段连接到进程地址空间
函数原型
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数
- shmid: 共享内存标识
- shmaddr:指定连接的地址
- shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
返回值:
成功返回一个指针,指向共享内存第一个节;失败返回-1
说明:
shmaddr为NULL,核心自动选择一个地址
shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr -(shmaddr % SHMLBA)
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存
简单来说,在我们不知道共享内存的地址的时候,我们把shmaddr设置为NULL就行。
6 shmdt
功能:将共享内存段与当前进程脱离
函数原型:
int shmdt(const void *shmaddr);
参数
shmaddr: 由shmat所返回的指针
返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段
3.3 共享内存实例
-
服务端
-
用户端
运行结果;
3.4 共享内存的特性
- 共享内存的生命周期随OS
- 共享内存不提供任何同步与互斥的操作,双方彼此独立
- 共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数
系统在分配 shm 的时候,是按照4KB为基本单位的,也就是说,如果我们申请了4097byte的时候,实际上系统会分配4096*2 个byte,但是我们的使用权限只在0–>4097byte之间,所以,为避免浪费,一般建议申请4kb的整数倍空间
3.5 shmid 和key 再理解
key: 是一个用户层生成的唯一键值,核心作用是为了区分“唯一性”,不能用来进行IPC资源的操作。
shmid: 是一个系统给我们返回的IPC资源标识符,用来进行操作ipc资源。
类比一下,key值就是文件的inode号,shmid是文件的fd
共享内存数据结构(了解)
struct shmid_ds {
struct ipc_perm shm_perm; /* operation perms */
int shm_segsz; /* size of segment (bytes) */
__kernel_time_t shm_atime; /* last attach time */
__kernel_time_t shm_dtime; /* last detach time */
__kernel_time_t shm_ctime; /* last change time */
__kernel_ipc_pid_t shm_cpid; /* pid of creator */
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
unsigned short shm_nattch; /* no. of current attaches */
unsigned short shm_unused; /* compatibility */
void *shm_unused2; /* ditto - used by DIPC */
void *shm_unused3; /* unused */
};
其中 ipc_perm 里存储了key值