文章目录
为什么你的0,1,2号文件默认打开?
- 因为bash打开了,后面的进程继承了它的file_struct,所以只要一个进程默认打开,其他的子进程都会默认打开。
为什么file结构体不会为子进程创建? - 因为file结构体属于文件,不属于进程。只是和进程关联起来罢了。
进程通信的原因
- 前面我们说过进程之间具有独立性,进程的数据独有。但这不是绝对的,有的时候我们也需要进程之间具有联系。我们称这为进程间通信。进程之间会有什么样的联系呢?
- 交换数据,一个进程将数据发送给另外一个进程。
- 资源共享,多个进程之间共享资源。
- 通知事件的完成,一个进程向另外的进程发消息,比如父进程wait子进程。
- 一个进程通过通信操控其他的进程。
管道
- 管道是一种比较古老和经典的进程间通信方法。
- 进程间通信的本质是让不同的进程看到同一块资源。而管道利用的是文件系统。在两个进程之间创建一个文件,将这个文件以读和写的不同方式打开,然后一个进程接管读端,一个进程接管写端。而这个文件我们称之为管道。这也是一个形象的说法。
- 但是管道是一个文件,那么往文件中写和读取数据就是IO,这非常浪费效率,所以实际的管道是内核的缓冲区,也就是说管道在内存中完成进程的通信。
匿名管道
-
管道分为匿名管道和命名管道,我们先来介绍第一种。
-
匿名管道的原理就是创建一个匿名管道,然后使用读和写的方式分别打开该文件,然后将产生的两个文件描述符分别用于不同的进程。这样就实现了进程的通信。
-
从文件描述符的角度理解管道: 我们先在父进程中使用pipe函数创建管道,pipe函数的参数是输出型参数,返回一个数组。该数组的0号元素是读端的文件描述符,1号元素是写端的文件描述符。如果成功返回0,失败返回-1.
-
然后父进程fork子进程,子进程继承了父进程的fd_array,这样子进程就和父进程的fd_array一样,指向相同的管道。
-
再关闭父子进程的一个文件描述符,就实现了父子进程的通信。
-
从内核的角度理解管道:pipe是文件,那么系统就会为pipe创建inode,创建file结构体。在file中有inode信息,而在inode中就标识了pipe是一个管道文件。而在file中封装的函数指针就会帮助我们读和写管道文件。
情况1
- 代码如下
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main(){
int pipefd[2] = { 0 }; //定义一个数组接收pipe的文件描述符
if(-1 == pipe(pipefd)){ //pipe的同时,检查是否错误
perror("pipe error");
exit(1);
}
pid_t id = fork(); //创建子进程
if(id < 0){ //fork失败
perror("fork error");
exit(2);
}
else if(id == 0){ //子进程,这里我让子进程写数据
close(pipefd[0]); //先将读端关闭,节省资源,避免误操作
const char *message = "I am child .";
while(1){
write(pipefd[1], message, strlen(buf)); //不断的写入数据,然后睡5秒。
sleep(5);
}
}
else{ // 父进程,读取数据
close(pipefd[1]);
char *buf[64] = { 0 };
while(1){
ssize_t s = read(pipefd[0], buf, sizeof(buf) - 1); //读取数据,不睡眠
buf[s] = 0;
printf("get message : %s\n", buf);
}
}
return 0;
}
- 子进程每次写入数据的时候都会睡上5秒,父进程是一直读取数据打印到显示器上。但是我们发现一个现象:终端上的信息也是每隔一段时间才打印,准确的说是等待子进程写入才开始打印,不是迅速的打印。
- 这里我们发现的第一个现象也是管道读写规则的一条:
- 当我们在进行读取的时候,如果读取条件不满足(写端不关闭文件描述符且不写入数据), 那么读端就会堵塞等待。
情况2
- 接下来我们看第二种情况,如果不停的写,每隔一段时间才读呢?
只改变父子进程的里面的代码,其他部分和第一段代码相同。
-
我们让子进程不停的打印,然后根据count判断打印的次数。然后让父进程每隔1秒读取1次。我们发现结果是
-
先是子进程疯狂的打印,然后子进程停止打印,父进程开始每隔1秒读取数据。为什么不是子进程一直打印呢?因为管道是一个文件,它的容量有限。子进程一瞬间将管道写满了,导致没有地方写入,只能堵塞。然后就该父进程打印了。
-
这里就有第二条规则: 当我们在进行写入的时候,如果写入条件不满足(读端不关闭文件描述符且不读取数据), 那么写端就会堵塞等待。
情况3
- 我们让子进程写入一段时间后就直接关闭写端,然后看看父进程的打印结果。
- 我们发现,读端的read的返回值不久一直是0,我们查看read的手册发现,返回值是0代表没有数据可读了,代表已经到了文件末尾,不会堵塞等待。
- 规则3 : 如果写端关闭文件描述符,那么读端读取完数据之后不会堵塞等待,会读取到文件末尾。
情况4
-
我们让读端先退出,然后让写端不断读取数据,我们发现最终写端也会退出!我们没有退出写端啊,为什么会接收到写端的退出码呢?因为你的读端已经退出,操作系统判断这个管道已经没有,占用资源,就会发射13号信号,帮你干掉写端的进程。你可以打印出写端进程的退出信号:
第四条规则:如果读端关闭文件描述符,那么写端不会堵塞等待,系统会直接发射13号信号终止写端进程。 -
匿名管道的特点:
-
1, 只有拥有血缘关系的进程才可以通信,因为需要继承fd_array数组。
-
2,是流式服务。数据读取没有明显的界限。
-
3,进程退出,管道的生命周期结束。
-
4,系统会对管道的读取和写入做互斥和同步。前两条规则均衡了读写速度。不能边写边读,写和读只能一个一个来。
-
5, 只能单向通信。如果想要双向通信,需要建立双向管道。
命名管道
-
命名管道顾名思义,有名字的管道。它也是管道,那么实现的思想都是一样的,利用文件来实现。
-
我们上述讨论,发现匿名管道有一个致命缺陷:不支持非血缘关系的进程通信。那么如果我想让两个毫不相干的进程通信,匿名管道就不太好用。这里就轮到命名管道登场了。
-
命名管道在语言层面的使用是mkfifo函数,fifo就是先进先出,因为管道是流式的服务,所以符合先进先出。这个名字也很形象。
-
第一个参数就是文件的路径和名字,目的是找到并且执行文件。第二个参数是文件的权限。如果创建管道成功,则返回0,失败返回-1.简单的一比。
//客户端
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#define FIFO_FILE "./pipe"
int main(){
char message[64] = { 0 };
int fd = open(FIFO_FILE, O_WRONLY); // 以写方式打开
while(1){
printf("Please Enter Message $");
fflush(stdout);
ssize_t s = read(0, message, sizeof(message) - 1); //从键盘读取数据到message中
message[s] = 0;
write(fd, message, strlen(message)); //写数据到管道中
}
return 0;
}
//服务器
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <sys/types.h>
4 #include <sys/stat.h>
5 #include <fcntl.h>
6 #include <stdlib.h>
7
8 #define FIFO_FILE "./pipe"
9 int main(){
10 umask(0);
11 if (-1 == mkfifo(FIFO_FILE, 0666)){ //创建命名管道pipe
12 perror("mkfifo error");
13 exit(1);
14 }
15 int fd = open(FIFO_FILE, O_RDONLY); //以读方式打开
16 char buf[64] = { 0 };
17 while(1){
18 ssize_t s = read(fd, buf, sizeof(buf) - 1); //从管道中读取数据
19 buf[s] = 0;
20 if(s < 0){
21 perror("read error");
22 exit(1);
23 }
24 else if(s == 0){
25 printf("read over.\n"); //如果写端退出,那么读端读到文件末尾,也跟着退出。
26 break;
27 }
28 else{
29 printf("get message from client # %s\n", buf);
30 }
31 }
32
33 return 0;
34 }
-
运行起来就是这种效果:
-
可以看到,pipe的类型是p(管道),它的大小是0,因为它在内核的缓冲区中,它不会出现在磁盘空间上。
-
命名管道除了在语言层面上的mkfifo,还有命令mkfifo,在命令行上直接生成一个管道。
-
我们先对pipo进行输出重定向,然后用输入重定向读取到一个文件中去。
system V 共享内存
- system V是一种标准,我放到了文章下面去讲解。你现在可以无视它。
- 管道是一种比较经典的做法,但是它也有明显缺陷。你会发现,在管道的操作中,使用了大量的系统调用接口,这一定会产生大量的拷贝。导致效率降低。
- 写入数据得将数据从键盘写入C语言缓冲区,然后从C语言缓冲区拿到管道(系统内核缓冲区),然后读取的时候将数据从管道拿到C语言缓冲区,再输出到显示器。这其中伴随着大量的拷贝数据。
- 而共享内存是一种减少拷贝的进程间通信方法。在某种层面上你甚至可以说它是最快的通信方式。进程间通信的本质是不同进程看见同一块资源,管道使用了文件这个不属于进程本身的东西,而共享内存选择在内存中再开辟一块空间,这块空间是进程共享,本身属于进程,属于使用的是用户级的缓冲区。这样进程的写和读几乎没有拷贝。
- 再虚拟地址中有一块空间是共享区,这块空间有动态库,而共享内存也在这里。不同的进程通过页表映射到自己的共享区,然后映射到同一块物理内存,这块物理内存属于这两个进程,也就是说,A写入的数据,实际上是往B上写入。而B读取数据,实际上就是读取的A的数据。
- 而且管道一般是两个进程之间进行通信,而共享内存支持多个进程之间进行通信,只要内存同时被这几个进程共享即可。
- 共享内存也会被映射到物理内存,最终还是要操作系统来分配这块内存。而如果有多对正在通信的进程,那么就应该被管理起来,而管理的方法自然是先描述再组织。 在内核中也有对应的数据结构描述共享内存。
- 创建共享内存的步骤大致是:1,分配内存,创建对应的数据结构shmid_ds。2,将要通信的进程挂接到共享内存上。3,通信结束,结束挂接。4,删除共享内存。
key
- A进程和B进程想要进程通信。但是内存中有很多块共享内存,如果保证它们的共享内存是同一块呢?在描述共享内存的数据结构struct shmid_ds{}中,存储着标识共享内存的信息,这个信息就是key。一个共享内存有独一无二的key值,不同的进程只要找到相同的key值,就可以共享一块内存。
- 而在linux中,我们有函数ftok帮助我们生成独一无二的key值。这个函数的参数是任意的,几乎没有经过内核的操作,完全是返回一个key值罢了。
- 可以看到ftok的返回值是一个System V的共享内存的标识id,也就是key值。
共享内存的使用4步
1,创建。我们使用shmget函数来创建共享内存。
- shmget的第一个参数就是我们的key值。第二个参数是共享内存的大小,系统为我们分配的时候,是按照页为单位分配的,所以我们最好分配4096字节的整数倍。第三个参数shmflg就是创建的方式,我常用的是IPC_CTRAT和IPC_EXCL。
- IPC_CREAT意味着如果key对应的共享内存不存在则创建共享内存。
- IPC_EXCL意味着如果共享内存存在,则报错返回。如果不带上这个选项,那么如果共享内存存在会打开它。
- linux下一切皆文件。共享内存也可以看作文件,所以shmflg也可以带上权限。
- RETURN VALUE:返回值是一个共享内存id,这个id连接着key值。你可能会问,我们已经有了key值,为什么还要shmid?key值是系统用来标识的,我们的shmid是用户层面的标识。如果报错,那么shmid就是-1。
// shared.h
#pragma once
#define PATHNAME "./share"
#define PROJ_ID 0xff
#define SIZE 4096
// server.c,
1 #include <stdio.h>
2 #include <sys/shm.h>
3 #include <sys/types.h>
4 #include <sys/ipc.h>
5 #include "share.h"
6 #include <stdlib.h>
7
8 int main(){
9 key_t key = ftok(PATHNAME, PROJ_ID); //产生一个独一无二的key值
10
11 int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建新的共享内存,权限是666
12 if(shmid < 0){ //创建失败
13 perror("shmget");
14 exit(1);
15 }
16 printf("the shmid is %d, the key is %p\n", shmid, key);
17 }
- 可以看到运行两次后,就出错了。这也证明了IPC_EXCL的作用。
- 我们可以直接在命令行上查看共享内存的情况:
ipcs -m # 查看共享内存的情况
ipcrm -m shmid #删除共享内存,
- 第一列是key值,shmid就是key值转换的共享内存id号。权限就是我们设置的权限。而字节就是我们给的大小。一般最好给4096的整数倍。nattch,number attch,连接这个共享内存的进程数量。
- 我们刚刚运行了server文件,产生了一段共享内存。当进程结束时我们查看,发现一个现象:共享内存竟然没有随着进程的结束而销毁!!!
- 管道是属于文件系统的,文件的生命周期跟随进程,所以进程结束,管道也释放资源。但是IPC资源的生命周期随内核,不随进程。如果不手动删除共享内存,那么这块资源不会被释放。所以需要我们手动释放,否则会造成资源浪费。
- 上面是我们在命令行上的手动删除共享内存。那么在代码层面有没有方法呢?有的。
2, 我们使用shmctl删除共享内存。
- 你可能对它的名字感到疑惑:shmctl?共享内存控制?为什么不是shmdel?因为这个函数的功能很强大,删除共享内存只是它的冰山一角,对共享内存的增删查改都由它来做。它的功能就是共享内存控制。
- 第一个参数就是shmget的返回值,shmid。cmd就是我们要shmctl执行的命令,我们只需要使用IPC_RMID就可以,这是删除指令。
- 最后一个参数是shmid_ds结构体,这是系统为管理共享内存创建的数据结构,这个参数的作用是你可以自定义一个shmid_ds结构体对象,然后这个共享内存的属性就根据你的来设置。不过我们一般使用默认的就可以,所以这个参数一般是NULL。
//这段代码跟上面的server.c
int main(){
//
sleep(10);
shmctl(shmid, IPC_RMID, NULL);
sleep(10);
}
- 我们使用脚本来检测共享内存的使用情况:
- 结果如我们所想:最先是系统中没有共享内存。等server运行起来,产生一个共享内存。10秒钟后,共享内存被删除,再过10秒,进程结束。
3, 我们使用shmat将进程挂接到共享内存
-
我们前面已经创建出了共享内存,并且掌握了删除共享内存的方法。但是我们接下来完成进程通信还需要挂接进程到共享内存上。
-
哪个进程调用这个函数,则挂接哪个进程。
-
第一个参数就是shmid,不再解释。第二个参数shmaddr代表你想要挂接的虚拟地址,也就是说你想要你的页表将共享内存映射到哪块虚拟地址上。一般我们直接给NULL,使用默认就行。第三个参数shmflg表示挂接的共享内存的使用方式,,比如只读,只写,我们也使用默认的(读写)就行,直接给0.
-
RETURN VALUE:如果你使用过malloc,那么这个返回值就很好理解。共享内存在进程的虚拟地址空间中会有映射,而返回值的void*指针就指向这段虚拟地址的起始地址。
4, 取消对共享内存的挂接
-
我们使用shmdt(shared memory dattach),这个函数就是取消当前进程对共享内存的挂接。
-
使用方法跟free一样,非常简单,它的参数就是shmat的返回值,也就是虚拟空间的首地址。它的作用仅仅是去除进程对共享内存的挂接,而非删除共享内存,这两点要分清楚。
-
所有代码:
1 #include <stdio.h>
2 #include <sys/shm.h>
3 #include <sys/types.h>
4 #include <sys/ipc.h>
5 #include "share.h"
6 #include <stdlib.h>
7 #include <unistd.h>
8
9 int main(){
10 key_t key = ftok(PATHNAME, PROJ_ID); //拿到独一无二的key值
11
12 int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建共享内存
13 if(shmid < 0){
14 perror("shmget");
15 exit(1);
16 }
17
18 printf("the shmid is %d, the key is %p\n", shmid, key);
19 sleep(5);
20 char *str = (char*)shmat(shmid, NULL, 0); //将当前进程挂接到共享内存
21
22 sleep(10);
23 shmdt(str); // 取消挂接
24 sleep(5);
25 shmctl(shmid, IPC_RMID, NULL); //删除共享内存
26 sleep(10);
27
28 sleep(5);
29 return 0;
30 }
-
我在每个阶段中间都穿插了sleep,仅仅是为了我的脚本能看到现象。也可以去掉所有的sleep。
-
上面就是对一个进程而言,一个共享内存由创建到删除的所以过程。但是我们的目的是完成进程间通信,所以我们需要再来一个客户端,让client和server通过共享内存产生通信。
-
clinet需要与server相同的key值,所以ftok的参数在两个进程中必须相同。
-
通过运行程序我们发现确实使用共享内存实现了进程间的通信。但是我们还发现即使写的速度很慢,它的读取速度却不会被影响。这与管道不同。
-
共享内存底层不提供任何同步与互斥机制。也就是说如果A和B进程使用共享内存通信,那么A进程不知道有B进程的存在,它只会做自己的事。共享内存是纯的通信。如果想要加上控制,需要信号量。
system V 信号队列
- 信号队列是另外一种通信方式。由系统为我们创建一个消息队列,一个进程将数据写入队列,另外一个进程读取消息队列。借由队列这种数据结构完成进程间通信。
- 你可能对system V感到疑惑,这是什么呢?别急,下面为你解答。
- 消息队列的创建方式是使用函数msgget,
- 你是否对这个函数的参数感到熟悉?如果有多个进程之间通信,需要多个消息队列,那么系统就需要管理这些消息队列,而每个消息队列都需要一个key值来标识。这个函数的参数不就是shmget的参数吗?
- 消息队列的删除方法:
- 你是否对这个函数感到熟悉?没错,这个函数的参数和shmctl的参数一样。而且,第三个参数msqid_ds就是操作系统描述消息队列的数据结构,如果你看过它的源码,你会发现它里面的成员布局和shmid_ds类似!
system V 信号量
- 我们先来看一看信号量的创建和删除,
- 没错,信号量也有key值,函数接口跟上面的一样。那你可以试想一下信号量的删除函数,
- 描述信号量的数据结构是semid_ds,没错,你发现共享内存,消息队列,信号量的函数接口,参数,数据结构几乎一样,这就是system V,它是一个标准,意味着你写的进程通信方式要按照这个标准来。
互斥
- 我们前面说过共享内存不支持同步和互斥。这里我们先说一说互斥的概念。
- A和B进程在进程通信,A进程写到一半的时候,B进程也写入数据。这是不被允许的。也就是说一个进程在享有资源进行某些操作的时候,另外的进程不应该打扰它的操作。
- 系统中某些资源一次只允许一个进程使用,这样的资源叫做临界资源。
- 在进程中涉及对临界资源操作的程序段叫做临界区。
二元信号量的作用
-
所以我们需要想方设法使得临界资源每次只能一个进程使用,而我们采用的方法是给临界区上锁。这个说法很形象,就像你在上厕所,会把门关上,等你完事再打开门。信号量就是一种锁。
-
信号量分为二元和多元信号量,我们主要说一说二元信号量。二元信号量就是一种只有两种状态的锁。
-
比如进程1和进程2都想访问一段空间。都想进行写入操作。但是写入操作互斥。所以我们给进程1和进程2的临界区都加上锁,谁先到,谁就上锁。等完事,再解锁。然后下一个进程再上锁,解锁。
-
但是,我们再仔细想一想。如果想要完成互斥,需要所有想要使用这块资源的进程看到同一把锁,一把锁在某一时刻只能一个进程使用。临界区是对临界资源操作的程序段,所以,锁也是一种临界资源!所以,锁在保护进程段之前需要保护自己!所以锁必须是原子操作的。
-
什么是原子操作呢?i++需要几步?将内存中的i加载到cpu的寄存器中,将i++,将结果返回内存。需要三步!!原子操作是指一个操作只有两种状态,有或者没有,而i++需要3步,中间的那一步就是不定的状态。你可以粗暴的理解为原子操作就是汇编只有一句的操作!
ipcs -m #查看共享内存
ipcs -s #查看信号量
ipcs -q #查看消息队列
# 删除
ipcsrm -m / -s --q