进程间通信
0. 进程间通信1. 管道1.1 匿名管道1.1.1 匿名管道原理1.1.2 创建匿名管道pipe1.1.3 基于匿名管道通信的4种情况5个特点 1.2 命名管道1.2.1 创建命名管道1.2.2 基于命名管道通信lient.c效果展示 1.3 pipe vs fifo 2. System V标准下的进程间通信方式2.1 共享内存2.1.1 一系列系统调用接口2.1.2 基于共享内存的进程间通信lient.c效果展示 2.1.3 共享内存特征 2.2 消息队列2.3 信号量本文重点:进程间通信宏观认识;匿名管道;命名管道;共享内存;信号量(多线程)
🖤people change.
正文开始@呀小边同学
进程是具有独立性的,一个进程看不到另一个的资源,那么交互数据成本一定很高。操作系统要设计特定通信方式。
两个进程要相互通信,必须先看到一份**“公共资源”。所谓通信,就是一个人儿往里放,一个人儿从中取。那这里所谓的资源就要有“暂存”的功能,事实上,它就是一段内存**!至于这段内存是以什么结构组织的并不重要,它可能以文件方式提供(管道),也可能以队列方式(消息队列)提供,也可能提供的就是原始的内存块(共享内存)。因此通信方式有很多种。
这个公共资源应该属于谁呢?为了维持进程独立性,它一定不属于进程A或B,它属于操作系统。
综上,进程间通信的前提就是:由OS参与,提供一份所有通信进程都能看到的公共资源。
接下来我们学习的所有接口,都是为了解决如何让不同进程看到同一份资源,至于传输些什么数据是上层业务的事儿,不是我们今天进程间通信关心的重点。
0. 进程间通信
进程之间会存在特定的协同工作的场景:
数据传输:一个进程要把自己的数据交给另一个进程,让其继续进行处理资源共享:多个进程之间共享同样的资源。通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
进程间通信的发展
管道 匿名管道pipe命名管道pipeSystem V标准进程间通信 System V 消息队列System V共享内存System V 信号量POSIX标准进程间通信(多线程详谈) 消息队列共享内存信号量互斥量条件变量读写锁
1. 管道
1.1 匿名管道
众所周知,父子进程是两个独立进程,父子通信也是进程间通信的一种,基于父子间进程通信就是匿名管道。我们首先要对匿名管道有一个宏观的认识。
父进程创建子进程,子进程需要以父进程为模板创建自己的files_struct
,而不是与父进程共用;但是struct file
这个结构体就不会拷贝,因为打开文件也与创建进程无关。
write这个“写入”的系统调用函数实际上干了两件事儿:1. 拷贝数据到内核缓冲区 2. 触发底层的写入函数在合适的时机刷新到外设,如write_disk到磁盘上
嘘~ 现在父子进程就看到了“公共资源”:同一个文件(注意上图的红色剪头)。只要不触发底层写入函数,就可以通过fd找到同一个struct file结构,从而找到文件缓冲区,向它写&读数据,这种基于文件的通信方式叫做管道。
1.1.1 匿名管道原理
父进程创建管道,对同一文件分别以读&写方式打开
父进程fork创建子进程
因为管道是一个只能单向通信的信道,父子进程需要关闭对应读写端,至于谁关闭谁,取决于通信方向。
于是,通过子进程继承父进程资源的特性,双方进程看到了同一份资源。
1.1.2 创建匿名管道pipe
创建匿名管道
#include <unistd.h>int pipe(int pipefd[2]);
参数pipefd
:**输出型参数!**通过这个参数拿到两个打开的fd
返回值:建成功返回0;失败返回-1
浅浅的贴一下一会儿要用到的函数 ——
[bts@VM-24-5-centos pipe]$ man 2 fork#include <unistd.h>pid_t fork(void);[bts@VM-24-5-centos pipe]$ man 2 close#include <unistd.h>int close(int fd);[bts@VM-24-5-centos pipe]$ man 3 exit#include <stdlib.h>void exit(int status);
那么我们就按照1.1.1小节的原理进行操作:①创建管道②父进程创建子进程③关闭对应的读写端,形成单向信道
①②都很简单,③现在我们想让父进程读取,子进程写入,那么问题来了,pipefd[0]
和pipefd[1]
哪一个是读,哪一个是写呢?
0(嘴):读取端,1(笔):写入端。
至此我们就实现了双方进程看到同一份资源 ——
在此基础上我们就要“通信”了,那我们用什么测试写入呢?你说你也没学过怎么向管道中写入呀~ 事实上这和向某个fd
对应文件写入没有区别 。浅浅的贴一下一会而要用到的函数 ——
#include <unistd.h>ssize_t write(int fd, const void *buf, size_t count);#include <unistd.h>ssize_t read(int fd, void *buf, size_t count);返回值:- the number of bytes written is returned- zero indicates nothing was written,这里意味着对端进程关闭文件描述符#include <unistd.h>unsigned int sleep(unsigned int seconds);
读取时的返回值需要你特别注意。
1.1.3 基于匿名管道通信的4种情况5个特点
我们以父进程读取,子进程写入为例(其实是别有用意@1.1.3.4),演示4种场景,探究匿名管道的特点。
这些场景的代码每个只做了小小的修改,所以你乍一看眼晕但不要害怕,因为我会好好给你解释~ 你最好,哦不,你也应该自己动手验证一下。
1.1.3.1 读阻塞
父进程读取,子进程写入:现在我们只让子进程sleep隔一秒一写,父进程暴风吸入~ 会怎样呢?
#include<stdio.h> #include<string.h>#include<unistd.h> #include<stdlib.h> int main(){int pipefd[2] = {0};if(pipe(pipefd) != 0){perror("pipe error"); return 1; } printf("pipefd[0]: %d\n", pipefd[0]); //3 printf("pipefd[1]: %d\n", pipefd[1]); //4 /*现在我们让父进程读取,子进程写入*/ if(fork()==0) {//child close(pipefd[0]); const char* msg = "余下的路还有好长啊"; while(1) {write(pipefd[1], msg, strlen(msg)); sleep(1); } exit(0); } //father close(pipefd[1]); while(1) {char buffer[64] = {0}; //清空缓冲区~ssize_t s = read(pipefd[0], buffer, sizeof(buffer)-1);if(s == 0){printf("child quit...\n");break;}else if(s > 0){buffer[s]=0; //字符串儿结束printf("child said to father# %s\n",buffer);}else {printf("read error...\n");break;}}return 0;} //ps: 读入时少读取一个,以避免buffer读满时,字符串儿末尾置0时发生越界访问
也就是写的慢读的快的情况下,读端就会等写端 ——
1.1.3.2 写阻塞
父进程读取,子进程写入:现在我不让子进程sleep疯狂地写,而父进程隔一秒读一下~
#include<stdio.h> #include<string.h>#include<unistd.h> #include<stdlib.h> int main(){int pipefd[2] = {0};if(pipe(pipefd) != 0){perror("pipe error"); return 1; } printf("pipefd[0]: %d\n", pipefd[0]); //3 printf("pipefd[1]: %d\n", pipefd[1]); //4 /*现在我们让父进程读取,子进程写入*/ if(fork()==0) {//child close(pipefd[0]); const char* msg = "余下的路还有好长啊"; while(1) {write(pipefd[1], msg, strlen(msg)); //sleep(1); } exit(0); } //father close(pipefd[1]); while(1) {sleep(1);char buffer[64] = {0}; //清空缓冲区~ssize_t s = read(pipefd[0], buffer, sizeof(buffer)-1);if(s == 0){printf("child quit...\n");break;}else if(s > 0){buffer[s]=0; //字符串儿结束printf("child said to father# %s\n",buffer);}else {printf("read error...\n");break;}}return 0;}
为什么一下子读出来这么多呢?事实上,pipe里只要有缓冲区就一直写入,read只要有东西就会一直读取,管道是面向字节流的,也就是只有字节的概念,究竟读成什么样也无法保证,甚至可能读出乱码,所以父子进程通信也是需要制定协议的,但这个我们网络再细说。。
(父进程读取,子进程写入):如果我们子进程一个字符一个字符写入,并定义一个计数器计数;父进程摆烂,啥也不读。。
#include<stdio.h> #include<string.h>#include<unistd.h> #include<stdlib.h> int main(){int pipefd[2] = {0};if(pipe(pipefd) != 0){perror("pipe error"); return 1; } printf("pipefd[0]: %d\n", pipefd[0]); //3 printf("pipefd[1]: %d\n", pipefd[1]); //4 /*现在我们让父进程读取,子进程写入*/ if(fork()==0) {//child close(pipefd[0]);int count = 0;const char* msg = "a"; while(1) {write(pipefd[1], msg, strlen(msg)); count++;printf("count: %d\n", count);//sleep(1); } exit(0); } //father close(pipefd[1]); while(1) {sleep(1);//摆烂...}return 0;}
最终程序卡在了65536
这个数,也就是说写端就不再写入了,这说明管道是有大小的,事实证明我云服务器上管道容量是64KB
——
那为什么writer写满的时候就不写了?难道不可以覆盖呀? 这是为了等待对方来读,覆盖等其它做法都是违背进程通信的初衷的。事实上,管道是自带同步机制的,也就是父子读写会相互等待合适的时机,这种机制很好地保障了数据的安全。
那我就想了,如果我读走一些,是不是写端会继续写入?测试代码如下:
#include<stdio.h> #include<string.h>#include<unistd.h> #include<stdlib.h> int main(){int pipefd[2] = {0};if(pipe(pipefd) != 0){perror("pipe error"); return 1; } printf("pipefd[0]: %d\n", pipefd[0]); //3 printf("pipefd[1]: %d\n", pipefd[1]); //4 /*现在我们让父进程读取,子进程写入*/ if(fork()==0) {//child close(pipefd[0]);int count = 0;const char* msg = "a"; while(1) {write(pipefd[1], msg, strlen(msg)); count++;printf("count: %d\n", count);//sleep(1); } exit(0); } //father close(pipefd[1]); while(1) {sleep(5);char buffer[4*1024] = {0}; //4KBssize_t s = read(pipefd[0], buffer, sizeof(buffer));printf("well, child is taking 4KB data......");}return 0;}
事实证明,读的较少的字节时时候,是不会触发对端来写的;而是要读走一批数据(经测试我这儿是4KB),才能唤醒,如果你换成一次读走2KB,经验证则需要读两次,严谨 ——
这是为了保证写入的原子性 ——
1.1.3.3 写端关闭
父进程读取,子进程写入:5s后写端把自己的文件描述符关了,读端会怎样?测试代码如下:
#include<stdio.h> #include<string.h>#include<unistd.h> #include<stdlib.h> int main(){int pipefd[2] = {0};if(pipe(pipefd) != 0){perror("pipe error"); return 1; } printf("pipefd[0]: %d\n", pipefd[0]); //3 printf("pipefd[1]: %d\n", pipefd[1]); //4 /*现在我们让父进程读取,子进程写入*/ if(fork()==0) {//child close(pipefd[0]); const char* msg = "余下的路还有好长啊"; while(1) {write(pipefd[1], msg, strlen(msg));sleep(5);break;} close(pipefd[1]); /*写端关闭写端...*/exit(0); } //father close(pipefd[1]); while(1) {sleep(1);char buffer[64] = {0}; //清空缓冲区~ssize_t s = read(pipefd[0], buffer, sizeof(buffer)-1);if(s == 0){printf("writer quit... \n");break;}else if(s > 0){buffer[s]=0; //字符串儿结束printf("child said to father# %s\n",buffer);}else {printf("read error...\n");break;}}return 0;}
读端拿到返回值0后退出 ——
1.1.3.4 读端关闭
父进程读取,子进程写入:写端疯狂地写,5s后读端退出,这时写端会怎样?
#include<stdio.h> #include<string.h>#include<unistd.h> #include<stdlib.h> int main(){int pipefd[2] = {0};if(pipe(pipefd) != 0){perror("pipe error"); return 1; } printf("pipefd[0]: %d\n", pipefd[0]); //3 printf("pipefd[1]: %d\n", pipefd[1]); //4 /*现在我们让父进程读取,子进程写入*/ if(fork()==0) {//child close(pipefd[0]); const char* msg = "余下的路还有好长啊"; while(1) {write(pipefd[1], msg, strlen(msg)); } exit(0); } //father close(pipefd[1]); while(1) {char buffer[64] = {0}; //清空缓冲区~ssize_t s = read(pipefd[0], buffer, sizeof(buffer)-1);if(s == 0){printf("child quit...\n");break;}else if(s > 0){buffer[s]=0; //字符串儿结束printf("child said to father# %s\n",buffer);}else {printf("read error...\n");break;}printf("reader is going to leave...")sleep(5);break; //读一条就退出~}close(pipefd[0]); //读端关闭读端return 0;}
我们复制SSH渠道监视,发现读端退出后,写端也随之退出 ——
while :; do ps axj | grep pipe_communicate | grep -v grep; sleep 1; echo "===================================================================="; done
当我们读端关闭,已经没有人读了,写端还在写入,此时站在OS层面,是严重不合理的!本质是在浪费OS的资源,OS会直接终止写入进程,操作系统会发送SIGPIPE
信号杀掉进程 ——
我们在 进程控制@进程退出一节中说过,进程异常终止会设置status
的退出信号,我们可以通过waitpid
使父进程获取子进程的退出信息。这就是为什么咱们非要让父进程来读,让子进程来写,是别有用心的。
我们通过增加如下代码来查看子进程如何退出(忘了的宝子们快去复习 ——
int status = 0;waitpid(-1, &status, 0);printf("exit code: %d\n", (status>>8)&0xFF); printf("signal: %d\n", status&0x7F)//ps: waitpid头文件 #include <sys/wait.h>
💜 总结上述4种场景 ——
写端不写或写得慢,读端就会等写端
读端不读或者读得慢,写端要等读端,且保证原子性
写端关闭,读端读完pipe数据,再读会读到0,表示读到文件结尾!
读端关闭,写端收到SIGPIPE信号直接终止
由此我们总结出匿名管道的5个特点 ——
管道是一个单向通信的通信管道
管道是面向字节流的 (tcp详谈)
只在具有血缘关系的进程进行通信,其中常用于父子通信
管道自带互斥同步机制,且原子性写入
管道的生命周期是随进程的。管道是文件,如果一个文件只被一些进程打开,相关进程都退出了,被打开的文件会被OS自动关闭,即使我忘记close… 也只是影响刷新罢了。。
1.2 命名管道
为了解决匿名管道只能父子通信,咱们引入命名管道,可以在任意不相关进程进行通信。
1.2.1 创建命名管道
💛 make FIFOs 在命令行上创建named pipes
[bts@VM-24-5-centos fifo]$ mkfifo (named pipes)
FIFO
:好熟悉吧~ First In First Out 队列呀
众所周知,命令行上执行的命令echo和cat都是进程,所以这就是通过管道文件进行的进程间通信 ——
💛 那么如何用代码实现命名管道进程间通信的呢?
[bts@VM-24-5-centos fifo]$ man 3 mkfifo#include <sys/types.h>#include <sys/stat.h>int mkfifo(const char *pathname, mode_t mode);
pathname
:管道文件路径mode
:管道文件权限返回值:创建成功返回0;创建失败返回-1,并设置错误码
首先我们要让A, B进程看到同一份资源,这里就是一个加载到内存的文件,但是不要把数据刷新到磁盘,这样情况下两进程分别再以读或写方式打开文件;另外进程毫不相关又如何打开同一个文件呢?匿名管道是借助了子进程对父进程的继承性,那命名管道就是通过路径/文件名的方式定位唯一磁盘文件的。
我touch了server.c和client.c,最终希望在server
和client
两个进程之间相互通信,先写一个Makefile ——
.PHONY:all all:client server client:client.c gcc -o $@ $^ server:server.c gcc -o $@ $^ .PHONY:clean clean: rm -rf client server fifo
Makefile自顶向下扫描,只会把第一个目标文件作为最终的目标文件。所以要一次性生成两个可执行程序,需要定义伪目标.PHONY: all
,并添加依赖关系。
别忘了删掉fifo哦~ 否则再次./server会创建失败
我们发现设置权限时,并不是预想的0666
,这是因为还受到系统默认的权限掩码umask
的影响 ——
我们可以通过一个系统调用,设置该程序上下文环境的umask,那我们把umask
清为0 ——
#include <sys/types.h>#include <sys/stat.h>mode_t umask(mode_t mask);
一旦我们有了一个命名管道,我们只需要通信双方进行文件操作进行通信即可,推荐使用系统调用接口,因为语言层的文件操作会有缓冲区干扰。
1.2.2 基于命名管道通信
comm.h
我们创建一个共用的头文件,这只是为了两个程序能有看到同一个资源的能力了。你看这一坨眼晕就直接往后看好啦~
#pragma once#include<stdio.h> #include<sys/stat.h> #include<sys/types.h> #include<fcntl.h> #include<unistd.h> #define MY_FIFO "./fifo"
server.c
创建命名管道读信息,并实现相应业务逻辑#include"comm.h" int main() {if(mkfifo(MY_FIFO, 0666)<0) {perror("mkfifo"); return 1; } /*只需要文件操作即可*/ int fd = open(MY_FIFO, O_RDONLY); if(fd < 0) {perror("open"); return 2; } //业务逻辑,可以进行对应的读写了 while(1) {char buffer[64] = {0}; ssize_t s = read(fd, buffer, sizeof(buffer)-1); if(s > 0) {//success buffer[s] = 0; printf("client# %s\n", buffer); } else if(s == 0) {//peer close... printf("client quit...\n");break;}else {//errorperror("read");break;}}close(fd);return 0; }
client.c
此时不需要再创建命名管道,只需要获取已打开的命名管道文件
从键盘拿到了待发送数据发送数据,也就是向管道中写入
#include"comm.h" #include<string.h> int main() {/*不需要创建fifo,只需获取即可*/ int fd = open(MY_FIFO, O_WRONLY); if(fd < 0) {perror("open"); return 1; } //业务逻辑 while(1) {char buffer[64] = {0}; /*先把数据从标准输入拿到client进程内部*/ printf("Plz enter message:"); fflush(stdout); ssize_t s = read(0, buffer, sizeof(buffer)-1); if(s > 0) {//从键盘拿到了待发送数据 buffer[s-1]=0; //当做字符串儿,并覆盖读入的'\n' printf("%s\n",buffer); //发送数据 write(fd, buffer, strlen(buffer)); } } close(fd); return 0; }
注:语言层的键盘输入接口,回显时都自动过滤掉了\n
,但是系统接口write
不同,会把回车也作为读到的内容,因此我们可以(24行)在设置字符串儿结束标志时把它抹掉。
效果展示
一定要先运行服务端创建命名管道,再运行客户端,实现了不相关进程通信 ——
我们还可以让client控制server执行一些任务,这也是进程通信的目的之一 ——
当然我们需要补充一点server.c
的业务逻辑:
#include"comm.h" #include<string.h> #include<stdlib.h> //exit #include<sys/types.h> #include<sys/wait.h> //waitpid int main() {if(mkfifo(MY_FIFO, 0666)<0) {perror("mkfifo"); return 1; } /*只需要文件操作即可*/ int fd = open(MY_FIFO, O_RDONLY); if(fd < 0) {perror("open"); return 2; } //业务逻辑,可以进行对应的读写了 while(1) {char buffer[64] = {0}; //sleep(50);ssize_t s = read(fd, buffer, sizeof(buffer)-1); if(s > 0) {//success buffer[s] = 0; if(strcmp(buffer, "show") == 0) {if(fork() == 0) {execl("/usr/bin/ls", "ls", "-l", NULL); exit(1); } waitpid(-1, NULL, 0); } else if(strcmp(buffer, "wait for me") == 0) {if(fork() == 0) {execl("/usr/bin/sl", "sl", NULL); exit(1); } waitpid(-1, NULL, 0); } else {printf("client# %s\n", buffer); }} else if(s == 0) {//peer close... printf("client quit...\n");break;}else {//errorperror("read");break;}}close(fd);return 0; }
下面我们server睡上50s,把匿名管道中内容读走,来验证一下管道的数据会不会刷新到硬盘 ——
为了效率,并不会把内容刷新到磁盘上,命名管道文件真好~
1.3 pipe vs fifo
为什么pipe叫做匿名管道和和fifo叫做命名管道?
匿名管道文件不需要名字,因为它是通过父子继承的方式看到同一份资源命名管道一定要有名字,从而使不相关进程定位同一个文件
2. System V标准下的进程间通信方式
以上都是基于文件的通信方式,下面我们要学习System V标准,是在同一主机内的进程间通信方案,是站在OS层面,专门为进程间通信设计的方案。
OS不相信任何人,于是给用户提供功能就一定要通过系统调用接口,于是就存在专门用来通信的接口system call.
进程通信的本质是先让不同进程看到同一份资源,System V提供了这三个主流方案 ——
共享内存- 传递数据消息队列(有点落伍) - 传递数据信号量 (今天只渗透一部分理论,多线程讲POSIX标准) - 实现进程同步&控制详谈
2.1 共享内存
基于共享内存进行进程间通信原理——
通过某种调用,在内存中创建一份内存空间通过某种调用,让参与通信的进程“挂接”到这份新开辟的内存空间上。于是我们就让不同的进程看到了同一份资源。去关联(去挂接)释放共享内存OS中可能存在多个进程,使用不同的共享内存区域进行各自的进程间通信,因此共享内存在系统中可能存在很多,操作系统当然要管理这些共享内存,以实现创建删除挂接去关联一系列复杂的操作。那如何管理呢?先描述再组织。那如何保证不同进程看到的是同一共享内存呢?共享内存一定要有唯一标识它的ID,使不同进程识别同一个共享内存资源。你看你看这和我们管理进程特别像,那这个“ID”存在于哪里呢?我们勇敢推知,这应该在描述共享内存的struct结构体中。
2.1.1 一系列系统调用接口
💛 创建共享内存 allocates a System V shared memory segment
//[bts@VM-24-5-centos shared_memory]$ man shmget#include <sys/ipc.h>#include <sys/shm.h>int shmget(key_t key, size_t size, int shmflg);
参数:
key
:为了使不同进程看到同一段共享内存,即让不同进程拿到同一个ID,需要由用户自己设定,但如何设定的与众不同好难啊,就要借助下面这个函数。
只要我们 [形成key的算法+输入key算法的原始数据] 是一样的,就能保证不同进程看到同一段共享内存(ID
),and这个key也会被设置进内核描述共享内存的结构体中。
#include <sys/types.h>#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id);
pathname
:自定义路径名
proj_id
:自定义项目ID
返回值:On success, the generated key_t value is returned. On failure -1 is returned
size
:共享内存的大小,建议是4KB
的整数倍,因为共享内存在内核中申请的基本单位是页(内存页),4KB。
shmflg
:标记位,这一看就是宏,都是只有一个比特位是1且相互不重复的数据,这样|
在一起,就能传递多个标志位,这我们早就知道了~
IPC_CREAT
:如果单独使用IPC_CREAT或者flg为0,表示创建一个共享内存。若不存在,则创建;若已存在,则直接返回当前已存在的共享内存,也就是说基本不会空手而归。IPC_EXCL
:单独使用没有意义,通常要搭配起来IPC_CREAT | IPC_EXCL
。若不存在,则创建;若已存在,则返回出错。这样的意义在于如果调用成功,得到的一定是一个全新的,没被别人使用过的共享内存。
返回值:On success, a valid shared memoryidentifieris returned. On errir, -1 is returned, and errno is set to indicate the error.
💛 控制共享内存 System V shared memory control
//[bts@VM-24-5-centos shared_memory]$ man shmctl#include <sys/ipc.h>#include <sys/shm.h>int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:(我们只关心删除,获取属性先不管)
cmd
:设置IPC_RMID
就行啦~
buf
:置空吧~ 喂喂,这就是个数据结构啊!这就是描述共享内存的数据结构啊
💛 关联&去关联:shmat, shmdt - System V shared memory operations
attach挂接 ——
#include <sys/types.h>#include <sys/shm.h>void *shmat(int shmid, const void *shmaddr, int shmflg);
shmaddr
:挂接到什么位置,我们也不知道,给NULLshmflg
: 给0
返回值:(重要) 这个地址一定是虚拟地址,类似malloc返回申请到的起始地址。
On success shmat() returns the address of the attached shared memory segment; on error (void *) -1 is returned, and errno is set to indicate the cause of the error.
detach去关联 ——
int shmdt(const void *shmaddr);
shmaddr
:shmat返回的地址
注意去关联,不是释放共性内存,而是取消当前进程和共享内存的关系,本质是去掉进程和物理内存构建映射关系的页表项去掉。
返回值:
On success shmdt() returns 0; on error -1 is returned, and errno is set to indicate the cause of the error.
2.1.2 基于共享内存的进程间通信
comm.h
#pragma once#include<stdio.h> #include<sys/types.h> #include<sys/ipc.h> #include<sys/shm.h> #define PATH_NAME "./"#define PROJ_ID 0x6666 #define SIZE 4097
不知所云往下读就好了~
server.c
请搭配代码和脑子食用 ——
生成key
,并把这段代码重定向到client.c,以使两进程看到同一段共享内存。
创建全新的shm,带选项IPC_CREAT | IPC_EXCL
若和系统中已经存在的ID冲突,则出错返回。
注意到其中权限perm
是0,那也可以设置一下,和设置文件权限类似,进一步体现一切皆文件的思想。
int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666);
第一次./server执行完后,进程也就运行结束了,那为什么再次运行时,还告诉我这段共享内存还存在?
我们可以通过如下命令查看共享内存:
ipcs -m 查看ipc资源,不带选项默认查看消息队列(-q)、共享内存(-m)、信号量(-s)
显而易见,该进程曾经创建的共享内存并没有被释放 ——
system V的IPC资源,生命周期是随内核的。只能通过程序员显式的释放(命令/system call)或者是OS重启。
ipcrm -m [shmid] 通过命令释放ipc资源
这两个都用来标定唯一性的 key vs shmid有什么区别呢?
key:是用来在系统层面上标识唯一性,不用来管理shmshmid:是OS给用户返回的ID,用来在用户层进行shm管理
命令行是属于用户层的,那么删除时一定使用的是shmid.
经过多次试验,不停的删除有申请,发现申请到的shmid也是连续的01234… 大胆猜测描述共享内存的数据结构是用数组组织的,这个到2.2小节详谈。
#include"comm.h" #include<unistd.h>int main(){key_t key = ftok(PATH_NAME, PROJ_ID);if(key < 0) {perror("ftok");return 1;} int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666);if(shmid < 0) {perror("shmget");return 2; } printf("key:0x%x, shmid:%u\n",key, shmid);//sleep(2);char* mem = (char*)shmat(shmid, NULL, 0);printf("attach shm done...\n");//sleep(10); /*通信*/ while(1){sleep(1);printf("%s\n", mem);} shmdt(mem);printf("detach shm done...\n"); //sleep(5); shmctl(shmid, IPC_RMID, NULL); printf("shm delete success...");sleep(10); return 0; }
关于申请共享内存的大小size
,我们说建议是4KB
的整数倍,因为共享内存在内核中申请的基本单位是页(内存页),4KB。如果我申请4097Byte大小的空间,内核会向上取整给我4096* 2Byte,诶?那我监视到的↑怎么还是4097啊!虽然在底层申请到的是4096*2,但不会多给你,这样也可能引起错误~
client.c
只需获取共享内存;不用删除#include"comm.h" #include<unistd.h> int main() {key_t key = ftok(PATH_NAME, PROJ_ID); if(key < 0) {perror("ftok"); return 1; } //client只需要获取 int shmid = shmget(key, SIZE, IPC_CREAT); if(shmid < 0) {perror("shmget"); return 2; } printf("key:0x%x, shmid:%u\n",key, shmid); char* mem = (char*)shmat(shmid, NULL, 0); //sleep(5); printf("client attach shm done...\n"); /*通信*/ char c = 'a'; while(c <= 'z') {sleep(1); mem[c-'a'] = c; c++; mem[c-'a'] = 0; }shmdt(mem);printf("client detach shm done...\n");//sleep(5);//不需要删除共享内存return 0;}
效果展示
写一个命令行脚本来监视共享内存 ——
while :; do ipcs -m; echo "_________________________________________________________________"; sleep 1; done
我们首先观察申请挂接去关联删除共享内存的过程,注意观察nattch
这个参数的变化:0->1->2->1->0.
测试通信部分: server不停直接读取共享内存内容(按照字符串儿读取),client不停向共享内存写入 ——
当client没有写入时(甚至没有启动时),server端并没有等待,而是不停读入。
通信过程中,printf写入我们根本就没有像pipe或fifo这样调用write/read这样的接口,一旦建立好并映射进自己的进程地址空间,该进程就可以直接看到共享内存,如同malloc的空间一般,不需要任何系统调用接口。而管道需要这些read/write接口,是因为需要将数据从内核拷贝到用户;或者从用户拷贝到内核。
通过这些现象得出 ——
2.1.3 共享内存特征
共享内存的生命周期随内核共享内存是所有进程中速度最快的,只需要经过页表映射,不需来回拷贝共享内存没有任何同步或互斥机制 (但这并不代表它不需要),需要程序员自行保证数据安全。2.2 消息队列
消息队列了解即可。
创建消息队列,与创建共享内存极其相似:
#include <sys/types.h>#include <sys/ipc.h>#include <sys/msg.h>int msgget(key_t key, int msgflg);
删除消息队列:
#include <sys/types.h>#include <sys/ipc.h>#include <sys/msg.h>int msgctl(int msqid, int cmd, struct msqid_ds *buf);
查看描述消息队列的结构体 ——
诶我们再看一下信号量的 ——
用户层描述共享内存属性的数据结构,可能只是内核层数据结构的子集,只需要暴露用户关心的属性 ——
我们可以通过key
找到同一个共享内存。
我们发现共享内存、消息队列、信号量的 ——
接口都类似数据结构的第一个结构类型struct ipc_perm
是完全一致的!
我们由shmid
申请到的都是01234… 大胆推测,在内核中,所有的ipc
资源都是通过数组组织起来的。可是描述它们的结构体类型并不相同啊?但是~ System V标准的IPC资源,xxxid_ds结构体的第一个成员都是ipc_perm
都是一样的。
事实上,这个数组就是按照ipc_perm*
类型存储的,把各种类型的结构体切片放进去,是通过强转做到的,要访问结构体中其它成员,再强转回来就行了~ (请自行脑补
2.3 信号量
今天我们只是简单认识信号量,多线程再详谈。
我们刚才详谈的匿名&命名管道,共享内存,消息队列,都是以传输数据为目的,而信号量是通过共享资源的方式来达到多个进程的同步和互斥。
信号量本质是一个计数器,类似int count
,用来衡量临界资源中的资源数目。
什么临界资源?
能被多个执行流同时访问的资源都是临界资源。比如,显示器文件、管道、共享内存、消息队列都是临界资源,因为进程间通信,需要引入能被多个进程看到的资源,但这也同时带来了临界资源的数据不安全的问题。count是用来保护临界资源的,前提是每个人得先看到count,因此信号量本身就是临界资源,那谁保护它呢?就是通过PV操作保证原子性。
什么是临界区?
进程代码有很多,其中用来访问临界资源的代码叫做临界区。比如我们刚刚的通信部分。
什么是原子性?
一件事儿,要么不做,要么就做完,没有中间态。
什么是互斥?
在任意一个时刻,只能允许一个执行流进入临界资源,执行它的临界区。
什么是同步?