本文重点:进程间通信宏观认识;匿名管道;命名管道;共享内存;信号量(多线程)
:black_heart: people change.
正文开始@呀小边同学
进程是具有独立性的,一个进程看不到另一个的资源,那么交互数据成本一定很高。操作系统要设计特定通信方式。
两个进程要相互通信,必须先看到一份“公共资源”。所谓通信,就是一个人儿往里放,一个人儿从中取。那这里所谓的资源就要有“暂存”的功能,事实上,它就是一段内存!至于这段内存是以什么结构组织的并不重要,它可能以文件方式提供(管道),也可能以队列方式(消息队列)提供,也可能提供的就是原始的内存块(共享内存)。因此通信方式有很多种。
这个公共资源应该属于谁呢?为了维持进程独立性,它一定不属于进程A或B,它属于操作系统。
综上,进程间通信的前提就是:由OS参与,提供一份所有通信进程都能看到的公共资源。
接下来我们学习的所有接口,都是为了解决如何让不同进程看到同一份资源,至于传输些什么数据是上层业务的事儿,不是我们今天进程间通信关心的重点。
0. 进程间通信
进程之间会存在特定的协同工作的场景:
- 数据传输:一个进程要把自己的数据交给另一个进程,让其继续进行处理
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
进程间通信的发展
管道
- 匿名管道pipe
- 命名管道pipe
System 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创建子进程
" title="">
- 因为管道是一个只能单向通信的信道,父子进程需要关闭对应读写端,至于谁关闭谁,取决于通信方向。
" title="">
于是,通过子进程继承父进程资源的特性,双方进程看到了同一份资源。
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(笔):写入端。
至此我们就实现了双方进程看到同一份资源 ——
" title="">
在此基础上我们就要“通信”了,那我们用什么测试写入呢?你说你也没学过怎么向管道中写入呀~ 事实上这和向某个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
——
" title="">
那为什么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}; //4KB
ssize_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后退出 ——
" title="">
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
" title="">
当我们读端关闭,已经没有人读了,写端还在写入,此时站在OS层面,是严重不合理的!本质是在浪费OS的资源,OS会直接终止写入进程,操作系统会发送SIGPIPE
信号杀掉进程 ——
" title="">
我们在 进程控制@进程退出一节中说过,进程异常终止会设置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>
" title="">
:purple_heart: 总结上述4种场景 ——
- 写端不写或写得慢,读端就会等写端
- 读端不读或者读得慢,写端要等读端,且保证原子性
- 写端关闭,读端读完pipe数据,再读会读到0,表示读到文件结尾!
- 读端关闭,写端收到SIGPIPE信号直接终止
由此我们总结出匿名管道的5个特点 ——
- 管道是一个单向通信的通信管道
- 管道是面向字节流的 (tcp详谈)
- 只在具有血缘关系的进程进行通信,其中常用于父子通信
- 管道自带互斥同步机制,且原子性写入
- 管道的生命周期是随进程的。管道是文件,如果一个文件只被一些进程打开,相关进程都退出了,被打开的文件会被OS自动关闭,即使我忘记close... 也只是影响刷新罢了。。
1.2 命名管道
为了解决匿名管道只能父子通信,咱们引入命名管道,可以在任意不相关进程进行通信。
1.2.1 创建命名管道
:yellow_heart: make FIFOs 在命令行上创建named pipes
[bts@VM-24-5-centos fifo]$ mkfifo (named pipes)
FIFO
:好熟悉吧~ First In First Out 队列呀
" title="">
众所周知,命令行上执行的命令echo和cat都是进程,所以这就是通过管道文件进行的进程间通信 ——
" title="">
:yellow_heart: 那么如何用代码实现命名管道进程间通信的呢?
[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会创建失败
" title="">
我们发现设置权限时,并不是预想的0666
,这是因为还受到系统默认的权限掩码umask
的影响 ——
" title="">
我们可以通过一个系统调用,设置该程序上下文环境的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
{
//error
perror("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行)在设置字符串儿结束标志时把它抹掉。
效果展示
一定要先运行服务端创建命名管道,再运行客户端,实现了不相关进程通信 ——
" title="">
我们还可以让client控制server执行一些任务,这也是进程通信的目的之一 ——
" title="">
当然我们需要补充一点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
{
//error
perror("read");
break;
}
}
close(fd);
return 0;
}
下面我们server睡上50s,把匿名管道中内容读走,来验证一下管道的数据会不会刷新到硬盘 ——
" title="">
为了效率,并不会把内容刷新到磁盘上,命名管道文件真好~
1.3 pipe vs fifo
为什么pipe叫做匿名管道和和fifo叫做命名管道?
- 匿名管道文件不需要名字,因为它是通过父子继承的方式看到同一份资源
- 命名管道一定要有名字,从而使不相关进程定位同一个文件
2. System V标准下的进程间通信方式
以上都是基于文件的通信方式,下面我们要学习System V标准,是在同一主机内的进程间通信方案,是站在OS层面,专门为进程间通信设计的方案。
OS不相信任何人,于是给用户提供功能就一定要通过系统调用接口,于是就存在专门用来通信的接口system call.
进程通信的本质是先让不同进程看到同一份资源,System V提供了这三个主流方案 ——
- 共享内存 - 传递数据
- 消息队列(有点落伍) - 传递数据
- 信号量 (今天只渗透一部分理论,多线程讲POSIX标准) - 实现进程同步&控制详谈
2.1 共享内存
基于共享内存进行进程间通信原理 ——
- 通过某种调用,在内存中创建一份内存空间
- 通过某种调用,让参与通信的进程“挂接”到这份新开辟的内存空间上。于是我们就让不同的进程看到了同一份资源。
- 去关联(去挂接)
- 释放共享内存
" title="">
- OS中可能存在多个进程,使用不同的共享内存区域进行各自的进程间通信,因此共享内存在系统中可能存在很多,操作系统当然要管理这些共享内存,以实现创建删除挂接去关联一系列复杂的操作。那如何管理呢?先描述再组织。
- 那如何保证不同进程看到的是同一共享内存呢?共享内存一定要有唯一标识它的ID,使不同进程识别同一个共享内存资源。你看你看这和我们管理进程特别像,那这个“ID”存在于哪里呢?我们勇敢推知,这应该在描述共享内存的struct结构体中。
2.1.1 一系列系统调用接口
:yellow_heart: 创建共享内存 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 memory identifier is returned. On errir, -1 is returned, and errno is set to indicate the error.
:yellow_heart: 控制共享内存 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
:置空吧~ 喂喂,这就是个数据结构啊!这就是描述共享内存的数据结构啊" title="">
:yellow_heart: 关联&去关联: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);
" title="">
第一次./server执行完后,进程也就运行结束了,那为什么再次运行时,还告诉我这段共享内存还存在?
" title="">
我们可以通过如下命令查看共享内存:
ipcs -m 查看ipc资源,不带选项默认查看消息队列(-q)、共享内存(-m)、信号量(-s)
显而易见,该进程曾经创建的共享内存并没有被释放 ——
" title="">
system V的IPC资源,生命周期是随内核的。只能通过程序员显式的释放(命令/system call)或者是OS重启。
ipcrm -m [shmid] 通过命令释放ipc资源
这两个都用来标定唯一性的 key vs shmid有什么区别呢?
- key:是用来在系统层面上标识唯一性,不用来管理shm
- shmid:是OS给用户返回的ID,用来在用户层进行shm管理
命令行是属于用户层的,那么删除时一定使用的是shmid.
" title="">
经过多次试验,不停的删除有申请,发现申请到的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啊!虽然在底层申请到的是40962,但不会多给你,这样也可能引起错误~
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.
" title="">
测试通信部分: server不停直接读取共享内存内容(按照字符串儿读取),client不停向共享内存写入 ——
" title="">
当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);
查看描述消息队列的结构体 ——
" title="">
诶我们再看一下信号量的 ——
" title="">
用户层描述共享内存属性的数据结构,可能只是内核层数据结构的子集,只需要暴露用户关心的属性 ——
" title="">
我们可以通过key
找到同一个共享内存。
我们发现共享内存、消息队列、信号量的 ——
- 接口都类似
- 数据结构的第一个结构类型
struct ipc_perm
是完全一致的!
我们由shmid
申请到的都是01234... 大胆推测,在内核中,所有的ipc
资源都是通过数组组织起来的。可是描述它们的结构体类型并不相同啊?但是~ System V标准的IPC资源,xxxid_ds结构体的第一个成员都是ipc_perm
都是一样的。
事实上,这个数组就是按照ipc_perm*
类型存储的,把各种类型的结构体切片放进去,是通过强转做到的,要访问结构体中其它成员,再强转回来就行了~ (请自行脑补
2.3 信号量
今天我们只是简单认识信号量,多线程再详谈。
我们刚才详谈的匿名&命名管道,共享内存,消息队列,都是以传输数据为目的,而信号量是通过共享资源的方式来达到多个进程的同步和互斥。
信号量本质是一个计数器,类似int count
,用来衡量临界资源中的资源数目。
- 什么临界资源?
能被多个执行流同时访问的资源都是临界资源。比如,显示器文件、管道、共享内存、消息队列都是临界资源,因为进程间通信,需要引入能被多个进程看到的资源,但这也同时带来了临界资源的数据不安全的问题。count是用来保护临界资源的,前提是每个人得先看到count,因此信号量本身就是临界资源,那谁保护它呢?就是通过PV操作保证原子性。
- 什么是临界区?
进程代码有很多,其中用来访问临界资源的代码叫做临界区。比如我们刚刚的通信部分。
- 什么是原子性?
一件事儿,要么不做,要么就做完,没有中间态。
- 什么是互斥?
在任意一个时刻,只能允许一个执行流进入临界资源,执行它的临界区。
- 什么是同步?