进程之间,进行相互通信,是要有一定目的的。
而通常,我们所说的进程之间是相互独立的。
而一旦进程之间相互进行了通信之后呢,它们之间也就会有一定的联系了。
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
什么叫做管道?
到目前为止,提到进程,我们会想到哪些结构呢?
task_struct ; mm_struct ; 页表;files_struct ;fd_struct;fd_array ;inode .
在子进程被创建的时候,父进程的fd_array也会被拷贝到子进程当中,而父进程fd_array所对应的文件实际上是被存储到内存的页当中。那么,实际上,父进程和子进程的fd_array里的文件指向的是同一块内容(同一块内容,该内容不会拷贝,因为其实属于文件系统里面的东西)。而这一块内容,就是管道。
由于第一个进程打开了0,1,2号文件,那么后续的进程也就默认都打开了。(因为子进程是由父进程为模板进行拷贝的)
我们想要让两个进程之间实现通信,那么我们就要得想办法让两个进程看到同一块资源(即内存)
那么这块资源是由谁提供的、怎样提供的,这就是进程间通信所决定的了。
而如果这块资源是由操作系统以文件的形式提供的,那么我们就将其称为管道。(即一个进程连接到另一个进程的数据流)
注意:管道只能够进行单项通信!(要么是父进程读,子进程写;要么是子进程读,父进程写)
匿名管道:
我们来用代码来更加深入的理解一下:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<string.h> 4 int main() 5 { 6 int pipefd[2] = {0}; 7 pipe(pipefd); 8 pid_t id = fork(); 9 if(id == 0)//child 10 { 11 close(pipefd[0]); 12 const char* msg = "I am a child\n"; 13 int count = 0; 14 while(1) 15 { 16 write(pipefd[1],msg,strlen(msg)); 17 printf("child:%d\n",count++); 18 sleep(3); 19 } 20 } 21 else 22 { 23 close(pipefd[1]); 24 char buffer[64]; 25 while(1) 26 { 27 ssize_t s = read(pipefd[0],buffer,sizeof(buffer)-1); 28 if(s > 0) 29 { 30 printf("father massage : %s\n",buffer); 31 } 32 } 33 } 34 35 return 0; 36 }
我们创建管道的时候,用的函数为 pipefd( )
其需要传进去含有两个元素的数组。经过变换之后,第一个元素(下标为0)被给上小的fd,第二个元素(下标为1)被给上大的fd。
一般情况下,第一个fd 的用来读的,而第二个fd是用来写的。
我们按照创建管道的步骤:
1、用pipe函数创建两个fd。
2、创建新的进程。
3、一个关闭读,一个关闭写。
用图示来表示:
针对第三点解释一下:前面说过,由于其通信和文件等属性,使得该文件在该进程当中只能一个来读,一个来写。而我们关闭,是因为这样可以减少不必要的fd的使用,节省资源。
那么上面的代码的运行结果是怎样的呢?
我们可以来看看:
实际上是,它每隔3秒钟,就会在显示器上打印出来I am a child。
为什么会这样呢?其原理是什么?
这就是我们上面所说的阻塞。
当写端不写入,不关闭文件描述符;那么读端在一段时间内可能会阻塞。
那么我如果要是这个样子呢?
(注意上面两次sleep的位置)
其打印出来的就是成了这个样子:
这又是为啥?
这个时候,父进程更慢,子进程更快。也就是说,子进程写的速度要大于父进程读的速度。
当我们在写端写入时,写入条件不满足,就要进行阻塞。
所以,我们得到最终的结论就是:
当读取条件或者写入条件不满足时,读端或者写端都会被阻塞。
条件就是:管道不为空并且不为满。
一个进程受到另外一个进程的影响,就是一个快另外一个也快,一个慢另外一个也慢,这样的关系就叫做进程间的同步。
而如果写端不仅不写了,还撂挑子了,直接关闭了文件标识符,那么读端就会读到文件结束。
而如果读端关闭,写端进程就可能直接被OS杀掉。因为读端都关闭了,那写还有什么意思呢?就都变成没有意义的事情了。
管道特点:
只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
面向字节流:数据都多少或者写多少完全是由上层决定,而底层只是提供一个通信的信道,不关心其具体是怎么读的。
进程退出,管道释放。
管道是自带互斥机制的,就相当于一个写,另一个就不能读了。
管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
关于在模拟,minishell里面添加管道:
命名管道
如果说,上面的匿名管道是必须有亲缘关系的进程才能够去使用,那么我们如果是两个独立的进程(实际上这两个进程也一定是有血缘关系的,因为有-bash进程的存在),想要二者之间实现通信,我们可以用命名管道。
命名管道是一种特殊类型的文件
命名管道是可以直接在命令行上面创建的。
system V共享内存
共享内存区是最快的IPC形式。
一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
共享内存示意图
就是说:
在多个进程的共享区中,同时映射着同一块内存,即共享内存区。
当一个进程在写的时候,另外的进程就能够看到读到。不需要经过数次的拷贝(比如从输入缓冲区中拷贝到文件中、再拷贝到输出缓冲区中等)
而共享内存之间的生命,并不是随进程。而是随内核。(即IPC的生命周期随内核)
所以,一个共享内存的释放要么是手动释放,要么是进行了系统重启等强制让其释放的手段才可以。
那么,关于共享内存的底层逻辑,我们暂时先了解到这里。
我们下面来看看,关于共享内存如何创建、如何删除、如何关联、如何取消关联。
如何创建:
其实,共享内存其实和进程等一样,同样遵循先描述再组织的原则。那么当我们要创建一个共享内存的时候,操作系统不仅仅需要给我们提供一块空间,还需要给我们提供用于组织它的一系列的数据结构。
我们可以通过查看Linux源码等手段,发现其会有一个结构体用来专门存储这一块共享内存的相关数据。
其代码后面也附有英文注释。
其中,shm_perm是用来描述操作权限的一个结构体。就是我们通常所说的标识符等信息。
后面还存在着shm_nattch/shm_unused等标识符,其功能用于记录该动态内存有多少个关联进程。其他的就不一一解释了。
我们再来说一下上面的shm_perm,它是什么样的一个结构体呢?
可以看到,其也是包含了key、uid等等标号,其主要是用来标志记录该ipc的用户相关的信息,即标识信息。这个key可以了解一下:这里的key相当于这块共享内存本身的标识信息。
就比如:一块内存中可能有许多共享内存,而我将来将我的进程和共享内存进行挂接的时候,我怎么知道就是和这一块共享内存进行挂接而不是其他的共享内存。原因就是这里的key可以进行标识。
ok,我们接下来就来说说接口是什么、怎么用。
第一个接口:ftok函数
这里的ftok函数与fork很像,但是他们之间没有任何关系,甚至连亲戚都算不上。
从理论上来说,这两个参数是可以随便填,其作用就是通过这两个参数,再通过某种算法,计算出一个唯一的key值出来(也就是这里的返回值),这种算法本质上和哈希类似,甚至就是哈希。(还记得我们说用哈希来计算唯一性的吗?在哈希 除留余数法 那一块)
只不过需要注意的是,这里的返回值key是在用户层面的值(就是用户拿到和使用的)。 (需要在这里注意的是,如果失败了,就返回-1)
那么我们创建共享内存所用到的函数是什么呢?shmget函数
这里的函数参数来说一下:
第一个参数就是我们上面拿到的key值,第二个参数就是告诉操作系统我要创建多大的空间。第三个参数相当于我们之前看到的状态等,一个标识。由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的。
通常情况下,我们会用这两个:
第一个表示我要创建一个IPC,第二个表示如果创建失败了,不要打开,直接错误返回。
关于第二个参数,还需要提一下:
最好在创建的时候,以4096(页)为单位,因为操作系统在分配内存空间的时候是以页为单位的(如果不以其为整数倍,操作系统在分配空间的时候当然会按照你所需要的来,但是其剩下的多开辟出来的空间就被浪费了。这部分空间你看不见,也是不会给你用的)
最后再说一下,这个函数的返回值是什么呢?
如上所示,其返回的是一个动态内存标识符。(类比文件标识符)
这里和key值区分一下:
key值是在操作系统的概念,其是用来标识操作系统IPC资源唯一性的。
而我们这里的返回值,是给用户使用的,是用户层面的概念。比如挂接、取关联等。
我们现在就尝试着用代码来去实现一下:
1 #include<stdio.h> 2 #include<sys/types.h> 3 #include<sys/ipc.h> 4 #include<sys/shm.h> 5 #include"comm.h" 6 int main() 7 { 8 key_t k = ftok(PATHNAME,PROJ_ID); 9 printf("key : %d\n",k); 10 int shmid = shmget(k,SIZE,IPC_CREAT|IPC_EXCL|0666); 11 12 if(shmid < 0) 13 { 14 perror("shmget error\n"); 15 return 1; 16 } 17 18 return 0; 19 }
如上。
我们先是生成了一个k,然后用其去创建共享内存。如果创建失败就直接返回1,创建成功我们暂时不做任何事情。
如图,当我们第二次及以后运行的时候,其告诉我们创建失败了,原因是文件已经存在。
我们用ipcs -m来查看当前的共享内存使用情况:
解释一下:
key就是我们前面所说的key值(操作系统层面的)
shmid就是我们之前所说的返回值(用户层面的);
owner表示所属者;
perms表示使用权限;(如果我们没有后面的0666,这里的权限就会是0)
bytes表示大小;
nattch表示关联进程数
那么我们想要结束一个共享内存怎么办呢?
可以直接用命令:ipcrm -m + 数字
删了后就没了。(注意我们这里后面跟的数字是shmid的值,同样的理由,这里针对的是用户层面的值)
删除共享内存难道每一次都要这么复杂吗?
当然不是,我们也是有专门的删除共享内存的接口的:shmctl
第一个参数就是要删除的shmid,
第二个参数我们用一个参数IPC_RMID,表示将一个动态内存给归还释放。
第三个参数是一个结构体参数,可以传一个结构体进去,然后将系统中的这些值初始化成你所传递的结构体的值。但是一般情况下,我们都不关心,都用默认值NULL。
所以我们在我们刚刚代码的基础上改改:
1 #include<stdio.h> 2 #include<sys/types.h> 3 #include<sys/ipc.h> 4 #include<sys/shm.h> 5 #include"comm.h" 6 int main() 7 { 8 key_t k = ftok(PATHNAME,PROJ_ID); 9 printf("key : %d\n",k); 10 int shmid = shmget(k,SIZE,IPC_CREAT|IPC_EXCL|0666); 11 12 if(shmid < 0) 13 { 14 perror("shmget error\n"); 15 return 1; 16 } 17 shmctl(shmid,IPC_RMID,NULL); //删除 18 return 0; 19 }
这样,我们再运行它的时候,所申请的动态内存在结束的时候就会被释放。(类比于malloc这样的动态内存分配的函数)
我们监视一下来看看:(下面是改动后的代码)
1 #include<stdio.h> 2 #include<sys/types.h> 3 #include<sys/ipc.h> 4 #include<sys/shm.h> 5 #include"comm.h" 6 #include<unistd.h> 7 int main() 8 { 9 key_t k = ftok(PATHNAME,PROJ_ID); 10 printf("key : %d\n",k); 11 int shmid = shmget(k,SIZE,IPC_CREAT|IPC_EXCL|666); 12 13 if(shmid < 0) 14 { 15 perror("shmget error\n"); 16 return 1; 17 } 18 sleep(9); 19 shmctl(shmid,IPC_RMID,NULL); 20 sleep(9); 21 return 0; 22 }
那我们接下来说说关联和取消关联怎么弄。
关联:shmat
取消关联:shmdt
来说说这两个接口怎么用吧。
第一个函数 :第一个参数shmid表示共享内存的编号(如前文所述),第二个参数表示和哪一块虚拟内存进行挂接(这一部分通常不用我们来执行,因为我们页不知道在哪,所以通常就用默认的就够了);第三个参数表示挂接方式是怎样的(只读还是读写还是什么的),这里我们就也用默认的就够了(默认为0)
第二个函数:将所要取关的虚拟地址给它。(与malloc创建动态内存十分相似)
来看其怎么用:
1 #include<stdio.h> 2 #include<sys/types.h> 3 #include<sys/ipc.h> 4 #include<sys/shm.h> 5 #include"comm.h" 6 #include<unistd.h> 7 int main() 8 { 9 key_t k = ftok(PATHNAME,PROJ_ID); 10 printf("key : %d\n",k); 11 int shmid = shmget(k,SIZE,IPC_CREAT|IPC_EXCL|0666); 12 13 if(shmid < 0) 14 { 15 perror("shmget error\n"); 16 return 1; 17 } 18 sleep(5); 19 char* tmp = (char*)shmat(shmid,NULL,0); 20 sleep(9); 21 shmdt(tmp); 22 shmctl(shmid,IPC_RMID,NULL); 23 sleep(9); 24 return 0; 25 }
我们来运行监视一下:
好。接下来我们有关 已经全部介绍完,现在来说有关通信的问题了。
我们创建两个文件:
serve.c和client.c
我们要求client.c在前面跑,往共享内存中去写,然后让serve.c去接收。
如(上)下所示为两个文件中的代码。
1 #include<stdio.h> 1 #include<stdio.h> 2 #include<sys/types.h> | 2 #include<sys/types.h> 3 #include<sys/ipc.h> | 3 #include<sys/ipc.h> 4 #include<sys/shm.h> | 4 #include<sys/shm.h> 5 #include"comm.h" | 5 #include"comm.h" 6 #include<unistd.h> | 6 #include<unistd.h> 7 int main() | 7 int main() 8 { | 8 { 9 key_t k = ftok(PATHNAME,PROJ_ID); | 9 key_t k = ftok(PATHNAME,PROJ_ID); 10 printf("key : %d\n",k); | 10 int shmid = shmget(k,SIZE,0); 11 int shmid = shmget(k,SIZE,IPC_CREAT|IPC_EXCL|| 11 if(shmid < 0) 0666); | 12 { 12 | 13 perror("shmget error\n"); 13 if(shmid < 0) | 14 return 1; 14 { | 15 } 15 perror("shmget error\n"); | 16 char i = 'a'; 16 return 1; | 17 char* tmp =(char*)shmat(shmid,NULL,0); 17 } | 18 for( ;i <= 'z';i++) 18 char* tmp =(char*)shmat(shmid,NULL,0); | 19 { 19 while(1) | 20 tmp[i-'a'] = i; 20 { | 21 sleep(4); 21 sleep(1); | 22 } 22 printf("%s\n",tmp); | 23 shmdt(tmp); 23 } | 24 return 0; 24 shmdt(tmp); | 25 } 25 shmctl(shmid,IPC_RMID,NULL); |~ 26 return 0; |~ 27 }
可以从代码中看出我们想要干什么了:
一个文件不断往共享内存当中去写(client.c);另外一个不断从共享内存当中去读(serve.c)。
我们让我们的代码跑起来看看。
如上如,我们一个窗口用来去跑serve的代码,另外一个窗口去跑client的代码。
我们可以看到,在client里面写的内容,被打到了serve的里面。
这样,我们就实现了通信。
当然了,我们这里还可以做一个监测,就是检测后台的共享内存的关联数。(关联数先由0变成1再变成2最后再变成0,我们这里就不过多举例了)
而从上面的例子,我们也可以看到,与我们管道不同的是,这里的共享内存所打印出来的有连续的相同的值(比如a a a a,一连打了四个a,其他也是同理),也就是说,我明明都没有在写,你却还在读。这就说明:我们的共享内存是不存在同步与互斥的机制的!!!
那么具体让他们更好地联系起来,还需要一个叫信号量的东西进行控制。
这也从侧面说明了共享内存的效率(访问效率)是很快的。
system V 消息队列(了解)
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法
每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值
我们获取消息队列的函数可以为msgget(参数和共享内存很像)
消除消息队列的函数为msgctl(就不细说了)
发送消息队列的函数:msgsnd
接收的函数:msgrcv(不详细解释了)
消息队列在操作系统中也是有一个结构体来进行维护(其和共享内存很像了,有兴趣可以深究一下)
这里有关信号和信号量的有关内容我们下一节专门去说。
获取信号量:semget
删除信号量:semctl
(底层消息量的结构)
我们通过上面的函数接口、底层结构可以发现,它们描述信号的相关接口和结构是非常像的。
这就是一个标准。而这个标准就是我们常说的system V 标准——大家的相关接口、功能都是相似的。
system 信号量
因为要通信,所以进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥。
系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。(就是说两个进程都有可能用到的公共资源才有可能会称为临界资源)
通俗一点来说,就是只能一个读或者一个写。
在进程中涉及到互斥资源的程序段叫临界区。
这里我们还会将提到一个“锁”的概念。
一般锁都是用来保护临界区的。
而这里的锁就是我们所说的system信号量。
IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
而我们在加锁和解锁的时候,必须要保持原子性。像i++;i--;就不具备很好的原子性。
就是说一个东西要么就是做了,要么就是没做。不存在中间状态。
就像拿快递,要么就是拿了一个,要么就是拿了两个,不能说我拿了一个半。
从底层理解,可以理解为i++要执行首先要将i的值加载到CPU中,然后i再加一,再将值赋给i.
这样一来,一条代码至少需要三条汇编指令、三个步骤,就很有可能出现一个进程在写的过程当中,另一个读了一半。这样的状态,就不是原子的。
需要注意:锁资源也是临界资源。