进程间通信
进程间通信的方式
在操作系统中进程具有独立性,那么进程之间进行通信必然成本不低。那么进程间通信方式有哪些呢?
- 数据传输:一个进程需要将自己的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它们发生了某些事件(如子进程终止了需要通知父进程)
- 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的概念
一个进程可以完成大多数事情,但有些事情是需要多进程进行协同去完成的,那么就衍生出了进程通信的概念。比如 cat file.cc |grep '123456':cat指令是把文件里的内容打印到屏幕上,|是内存里的一块空间,cat |是把文件的内容打印到内存里的一块空间里,grep '123456'是将有123456内容的这行代码打印到屏幕上。cat和grep也都是进程,那么这行指令就是cat进程把内容通过内存里的一块空间,发送给grep进程,grep筛选出与123456有关的代码打印到屏幕上。 即是数据传输的行为
如何实现进程间通信
对于标准:行业上有有两套标准
那么posix解决了什么问题呢?
不同操作系统内核为同一功能提供的系统调用(函数)是不同的,例如创建进程,linux下是fork函数,windows下是createprocess函数,如果在Linux下写了一个程序用到了fork函数,要往windows上移植就得把源代码里面的fork通通改成createprocess,然后重新编译。
解决方法: 定义POSIX标准, linux和windows实现基于POSIX标准,提供同样的接口,例如定义创建进程的接口为posix_fork(示例名/非真实名字), 且linux和windows都把各自创建进程的调用封装成posix_fork,都声明在unistd.h里。 这样程序员编写应用时,只需包含unistd.h, 调用这个POSIX标准中定义的API接口: posix_fork函数,即可实现源代码级别的可移植。
即实现了跨主机通信:为了运行在不同操作系统的应用程序提供统一的接口,实现者是不同的操作系统内核。
- System V
SystemV标准的进程间通信方式是在操作系统层面专门为进程间通信设计的一个方案。进程间通信的本质就是让不同的进程能够看到同一份资源。常见的system V结构的通信方式有如下几种:共享内存、消息队列、信号量。
管道
什么是管道
进程间通信层面,对于文件系统有基于文件系统的管道,那么管道是什么呢?
我们回顾进程地址空间,父进程会配有一个文件描述符表,表中有内存中的文件的虚拟地址进而可以找到内存中的文件,内存中的文件有磁盘上的物理地址也进而能找到进行IO流。当父进程创建子进程时,父进程会拷贝一份文件描述符表给子进程,那么子进程也能通过该表找到相同的虚拟地址进而找到相同的内存中文件,也能同磁盘上的文件进行IO。
两个进程通信必须要看到同一块资源,这块资源在文件系统中叫管道文件
- 前面提到的进程间通信,必然是会有数据的传输,而进程间进行数据传输必然要有介质在中间,即两个进程必须要看到同一块资源(空间)。由于进程具有独立性,进程提供的资源其他进程看不到,所以这块资源必然是由操作系统提供的。而在进程地址空间中,父进程和子进程通过文件描述符表能找到相同一份内存级文件,这份文件就是进程间通信需要的介质。该文件是由文件系统提供的,所以称为管道文件。
进程间通信不需要进行IO流
- 进程间进行数据传输,而进程都是内存级文件(操作系统中一切皆文件),管道文件也是内存级文件,若进程对管道文件进行写入读出,需要管道文件对磁盘上的文件进行IO更新的话,那么进程间通信会非常的慢。因此不需要关心磁盘上的文件是否打开或关闭,当有进程之间需要通信时,操作系统就会自动让文件系统提供管道文件。 实际上,内存中的文件上有相应的字段表示文件的特性。当操作系统创建管道文件时,会将该文件的字段表示为管道文件。
进程间通信具有不同的种类,种类类型由操作系统提供的模块决定。文件系统模块提供的资源,称为管道文件。内存中提供的资源成为共享内存等等。
- 进程间通信的本质就是让进程都看到同一份资源,其次才考虑怎么去通信
进程间怎么通信
根据上面的结论,不难知道,可以通过父进程创建子进程,让父进程和子进程通过相同的文件描述符表上的虚拟地址找到相同的管道文件进而完成通信。而该管道文件专门用来父子进程进行通信的,是没有名字的所以被称为匿名管道
匿名管道
pipe函数
- pipe函数用于创建匿名管道,原型如下:
intpipe(intpipefd[2]);
pipe函数的参数是一个输出型参数,数组pipefd用于返回指向管道读端和写端的两个文件描述符:
数组元素 | 含义 |
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
- pipefd[0]->👄 读端
- pipefd[1]->✏️ 写端
pipe函数调用时,若成功返回0,失败则返回-1
文件描述符012分别被标准输入输出流stdin、stdout、stderr占用,那么管道读端和写端是匹配哪个文件描述符呢?
#include<iostream>
#include<assert.h>
#include<unistd.h>
usingnamespacestd;
intmain()
{
intfds[2];
intn=pipe(fds);
assert(n==0);
cout<<"pipe[0]: "<<fds[0]<<endl;
cout<<"pipe[1]: "<<fds[1]<<endl;
return0;
}
- 通过实验证明,读端匹配fd[3],写端匹配fd[4]
创建管道通信
若父进程负责读取数据,子进程负责写入数据,则创建管道通信过程如下:
- 父进程创建管道
- 父进程fork出子进程
- 父进程关闭fd[1](关闭写端),子进程关闭fd[0](关闭读端)
- 该管道只用于单向通信,当父进程fork玩子进程后,需要确认谁来读,谁来写,然后关闭另一个作用端。
读写特征
写慢读快
#include<iostream>
#include<unordered_map>
#include<sys/wait.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
usingnamespacestd;
intmain()
{
//第一步,父进程创建管道
intfds[2];
intn=pipe(fds);
assert(n==0);//返回0保证匿名管道创建成功
//第二步,父进程创建子进程
pid_tid=fork();
assert(id>=0);//若fork成功,那么子进程返回0, 返回给父进程 子进程的id
if(id==0)//这里对子进程进行操作
{
close(fds[0]);//子进程关闭读端
constchar*s="i am child , i am sending message to father";
intcnt=0;
while(true)
{
cnt++;
charbuffer[1024];//创建缓冲区
snprintf(buffer,sizeofbuffer,"child say to parent:%s,childpid[%d],[%d]",s,getpid(),cnt);
write(fds[1],buffer,strlen(buffer));//子进程往写端进行写入
sleep(1);//子进程间断时间写
}
close(fds[1]);
exit(0);
}
//父进程操作
close(fds[1]);//父进程关闭写端
while(true)
{
charbuffer[1024];//创建缓冲区
cout<<"正在读取:......"<<endl;
ssize_ts=read(fds[0],buffer,sizeof(buffer)-1);//流一个位置给/0
cout<<"读取成功!"<<endl;
if(s>0)//写入成功
{
buffer[s]=0;//给字符串末尾添加上0
cout<<"father get message: "<<buffer<<"|fatherpid: "<<getpid()<<endl;
}elseif(s==0)//没写入
{
cout<<"read: nothing "<<endl;
}
}
cout<<"父进程关闭读端"<<endl;
n=waitpid(id,nullptr,0);
assert(n==id);//父进程等待子进程(回收子进程)
return0;
}
- 这里一份代码,完成创建完管道通信后,父进程负责读,子进程负责写,子进程写完睡眠1秒即间断时间写,父进程不断的读
可以看到子进程写一段父进程读一段,明显感觉到父进程在等待子进程写入。对代码稍加修改,让子进程睡眠50秒
管道内没有了数据,读端会阻塞等待写端
- 可以看到先是父进程读到0个字符,然后在等待子进程写入。这种管道里没有了数据,读端在读,默认会直接阻塞当前正在读取的进程—读端在阻塞等待! 实际上,父进程在阻塞等待时,父进程的R状态会操作系统改为S状态,父进程被放到等待队列中。管道有数据了,父进程被唤醒,S状态改为R状态,从新进入运行队列中进行读取操作。
写快读慢
- 当我让子进程取消睡眠,一直往管道文件里写时,父进程睡眠1000秒即一直睡眠不读取管道里的数据
- 可以看到打印计数器几百次即子进程一直在写,而父进程没有读取管道文件里是数据,子进程(写端)直至写满管道文件才停止
- 而当父进程睡眠两秒时,即子进程一直往管道文件里写,父进程间隔性读取数据,间隔时间为2秒
- 可以看到写端是一直往管道文件里写,而读端并不是一次读取一个字符串,而是一次读取read规定的大小字节数。
写端关闭,读端读完
- 写端写了一段然后就break,读端若读完数据了也就退出了
读端关闭,写端?
这里我让写端一直写,读端读了一次然后直接break
在父进程休眠2秒期间,子进程往管道文件里写数据,然后读端读完管道文件里的数据后,退出循环
- 当操作系统知道读端退出,而写端还在写时,会以发送信号码给子进程的方式强制将写端杀掉,导致子进程异常退出,退出码是13,查表得知是SIGPIPE;即当操作系统知道有写端非法写入时,会发送13号信号码给该进程强制杀死写端。
综上可以得出
- 当没有数据可读时
- O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到
- O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
- 当管道被写满时
- O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
- O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
- 管道退出情况
- 如果所有管道写端对应的文件描述符被关闭,则read返回0
- 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
- 管道写入特征
- 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性
- 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性
管道特征
- 管道的生命周期依托于进程。
当父进程创建好管道时,管道文件被操作系统提供,当父进程退出时,管道文件也就被操作系统释放。
- 管道可以用来提供给具有血缘关系的进程之间进行通信
通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
- 管道是面向字节流的
- 字节流服务特点:数据没有明确分割(由底层做分割),不分一定的报文段。
与字节流服务相对应的是数据报服务
- 数据报服务特点:数据有明确分割,拿数据按报文段拿。
- 管道是半双工通信
- 半双工通信(Half-duplex Communication)可以实现双向的通信,但不能在两个方向上同时进行,必须轮流交替地进行。
在这种工作方式下,发送端可以转变为接收端;相应地,接收端也可以转变为发送端。但是在同一个时刻,信息只能在一个方向上传输。因此,也可以将半双工通信理解为一种切换方向的单工通信。
通信的方式另外还有单工通信和全双工通信
单工通信 | 全双工通信 |
单工通信(Simplex Communication)是指消息只能单方向传输的工作方式:在单工通信中,通信的信道是单向的,发送端与接收端也是固定的。基于以上,数据信号从一端传送到另外一端,信号流是单方向的。 | 全双工通信(Full duplex Communication)是指在通信的任意时刻,线路上存在A到B和B到A的双向信号传输。即允许数据同时在两个方向上传输,又称为双向同时通信,即通信的双方可以同时发送和接收数据。 |
- 同步与互斥机制
- 互斥:当一个进程正在临界区中访问临界资源时,其他进程不能进入临界区。
- 同步:合作的并发进程需要按先后次序执行,例如:一个进程的执行依赖于合作进程的消息或者信号,当一个进程没有得到来自于合作进程的消息或者信号时需要阻塞等待,直到消息或者信号到达后才被唤醒。
- 临界区:临界区则指的是一段代码,在这段代码中对临界资源的访问需要进行同步操作。进程访问临界资源的那段程序代码即一次仅允许一个进程在临界区中执行。
- 临界资源:临界资源指的是一些需要被多个进程或线程共享的资源。例如共享内存区、共享文件等。并且临界资源要通过互斥和同步的方式等来进行保护。
匿名管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。但想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它被成为命名管道。
命名管道
可以通过命令行指令创建命名管道,且默认文件位于当前目录
- mkfifo +文件名
mkfifoname_pipe
命名管道特性
- 命名管道文件文件类型为p,即文件前面属性的第一个字符为p
- 命名管道文件大小为0
- 命令行里一直把"hello world -> cnt"这段数据重定向到管道文件name_pipe里,但是管道文件大小依旧为0
现在我在另一个端口将通过cat数据读出来,那么数据从一个端口写入,从另一个端口读出,命令行是一个进程,cat将数据读出也是一个进程,数据在一个进程流通到另一个进程,命名管道也完成了进程间通信!并且在通信的过程中,命名管道文件大小依旧为0。
命名管道实质
- 文件只要不把数据刷新到磁盘上,也就是不进行IO,那么在内存层面上文件之间进行数据传输,这跟匿名管道的原理一样。所以具有路径+文件名且不刷新数据到磁盘上的文件是管道文件,也称命名管道。
- 命名管道是管道文件,本质上是文件,因为文件可以通过地址+文件名找到,而路径+文件名具有唯一性,那么也就满足进程的唯一性。
- 不刷新数据到磁盘上,那么该文件在磁盘上占用内存也就为0(不包括文件属性),所以管道文件上显示文件大小为0
mkfifo 函数
mkfifo函数用于创建命名管道文件,原型如下:
intmkfifo(constchar*filename,mode_tmode);
- 第一个参数filename 是指管道文件路径+文件名
- 第二个参数是管道文件的权限
一般权限设为0666(读写权限),但文件创建出来后权限会受umask影响
实际权限=(mode&~umask)
- 当然可以通过 umask=0把umask影响除掉
- 若文件创建成功返回0,创建失败返回-1,并且可以通过errno查到错误信息
pipehead.hpp
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
#include<errno.h>
#include<unistd.h>
#include<assert.h>
usingnamespacestd;
#define NAME_PIPE "/home/ljp/name_pipe/mypipe"
boolCreateFifo( conststring&path){
umask(0);
intn=mkfifo(path.c_str(),0600);
if(n==0) returntrue;
else{
cout<<"errno:"<<errno<<"err string: "<<strerror(errno)<<endl;
returnfalse;
}
}
voidremoveFifo(conststring&path){
intn=unlink(path.c_str());
assert(n==0);
(void)n;
}
- 在CreateFifo函数中,使用mkfifo创建管道文件。在路径/home/ljp/name_pipe/底下创建一个名为mypipe的管道文件。
- 在removeFifo函数中,使用unlink删除管道文件。
- (void)n这一行说明:assert在debug下生效,那么变量n就被使用了;而在release下是assert不生效,那么变量n接收了返回值而后续没有被使用,那么在编译期间编译器会报警告说该变量没有被使用,不想报警告就加这行代码。
server.cc
#include"pipehead.hpp"
usingnamespacestd;
intmain()
{
boolr=CreateFifo(NAME_PIPE);//创建管道文件
assert(r);
(void)r;
sleep(10);
removeFifo(NAME_PIPE);//删除管道文件
return0;
}
现在能够通过server.cc创建管道文件,也能让server.cc往管道文件里写数据,然后让client.cc读取管道文件中的数据,完成进程间通信。
命名管道的通信
server.cc
#include"pipehead.hpp"
using namespace std;
int main()
{
bool r= CreateFifo(NAME_PIPE);//创建管道文件
assert(r);
(void)r;
cout<<"server begin"<<endl;
int rfd=open(NAME_PIPE,O_RDONLY);//当读端打开了文件,然而写端没有打开,那么读端就会阻塞在这里等待写端打开,写端打开了才往后走
cout<<"server end"<<endl;
if(rfd<0) exit(1);
//sleep(10);
//读
char buffer[1024];
while(true)
{
ssize_t n=read(rfd,buffer,sizeof(buffer)-1);//读
if(n>0)
{
buffer[n]=0;
cout<<"server read: "<<buffer<<endl;//打印读到的内容
}
else if(n==0)
{
cout<<"client quit!,i quit either"<<endl;//写端退出,读端也退出
break;
}else
{
cout<<"err string: "<<strerror(errno)<<endl;//打印错误信息
break;
}
(void)n;
}
removeFifo(NAME_PIPE);//删除管道文件
return 0;
}
- server.cc负责创建管道文件,并以读的方式打开,open函数打开成功返回fd文件描述符,read函数把fd对应的文件里的数据读到buffer缓冲区中,若读成功加以打印。
client.cc
#include"pipehead.hpp"
using namespace std;
int main()
{
cout<<"client begin"<<endl;
int wfd=open(NAME_PIPE,O_WRONLY);
cout<<"client end"<<endl;
if(wfd<0) exit(1);
//写
char buffer[1024];
while(true)
{
cout<<"client says: ";
fgets(buffer,sizeof(buffer),stdin);//把输入流的内容写进buffer,fgets会把\n也输入,所以要把\n去掉
if(strlen(buffer)>0) buffer[strlen(buffer)-1]=0;
ssize_t n= write(wfd,buffer,strlen(buffer));//把buffer的内容写进文件描述符里
assert(n==strlen(buffer));
(void)n;
}
return 0;
}
- client.cc以写的方式打开管道文件,打开成功返回文件对应的文件描述符fd。通过fgets把标准输入流的内容写入缓冲区buffer中。
- 若标准输入成功,则缓冲区buffer大小不为零,通常点击enter键时fgets也会也会将\n录入,所以将\n换成\0。
- 将缓冲区buffer的内容按字符串形式写进fd对应的文件中
- server.cc创建好管道文件后,读端会阻塞等待写端打开管道文件
system V中的通信方式
system V共享内存
再谈进程的独立性
- 进程的task_struct、文件描述符表和页表等等都有独立性,且进程通过页表映射到的虚拟内存也具有独立性。意味着一个进程在内存申请到的空间,别的进程一般不能访问。两个进程不能访问同一块空间就不能完成进程间通信。
共享内存的原理
- 共享内存就是使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。
- 用户通过系统接口向操作系统申请一块内存空间,进程再通过系统调用将这块空间的地址通过页表映射到进程地址空间,那么该进程就能访问这块内存空间。且另一个进程也这样,那么这两个进程都能同时访问到这块内存空间,进而完成进程间通信。未来进程不需要通信,将取消和共享内存的映射关系,再将共享内存释放。
- 进程与共享内存的映射叫做进程与共享内存进行挂接
- 进程与共享内存取消映射关系叫做进程与共享内存进行去关联
malloc不能完成进程间通信
- malloc用于申请一块连续的指定大小的内存空间,实际上malloc是进程调用来向操作系统申请内存空间,这块空间也只能让该进程看到,因此malloc申请的内存空间也具有独立性,并不能用来进程间通信。
- 而共享内存是专门用来IPC(inter process communication)的方式,意味着会有许许多多的进程都用它来进行通信,操作系统中自然就会同时存在很多共享内存,那么共享内存也必须能有一定的条件加以区分彼此!即具有一定的标识性!让想通信的进程与特定的共享内存进行挂接!
shmget函数创建共享内存
用于创建共享内存
- 函数原型
int shmget(key_t key, size_t size, int shmflg)
- size:共享内存的大小,一般是以4kb(4096字节)为单位。若申请的内存为4097字节,那么操作系统会分配2*4kb大小的内存,但是具有使用权限的只有4097字节。
- shmflg:共享内存的标志位
一般有以下两个选择:
- IPC_CREAT:向操作系统申请共享内存,若存在则打开,若不存在则创建
- IPC_EXEL:向操作系统申请共享内存。单独使用无意义,需要IPC_EXEL|IPC_CREAT使用。若存在则错误返回-1。若不存在则创建,那么当前创建的共享内存必然是最新的
- key:共享内存的关键码,由函数ftok提供
- shmget函数返回值:若创建成功返回该共享内存标识符,用于给上层调用使用;创建失败返回-1,并用errno记录错误信息
ftok函数创建key值
用于创建key值
函数原型
key_t ftok(const char *pathname, int proj_id);
- 第一个参数pathname是共享内存的路径名
- 第二个参数proj_id是共享内存的id,这个参数由用户自己决定
ftok将pathname和proj_id两个参数用一定的算法整合成一个值,保证该值的唯一性。如果创建key成功,就将该值返回;创建失败返回-1,并用errno记录错误信息
key的作用
- 实际上,在ftok函数创建key值时,会拿着key值去到共享内存块中找到一份未被使用的共享内存,然后设置进共享内存属性中,也标识了该共享内存的唯一性。
- 其他进程也能拿到相同的key,去到共享内存块中去找到用该key标定好的共享内存并与其挂接,然后这两个进程就能通过共享内存进行通信了。
共享内存的特性
shme.hpp
#ifndef _SHME_HPP_
#define _SHME_HPP_
#include<iostream>
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<errno.h>
#define PATH_NAME "."//pathname
#define PROJ_ID 0x66//ID
#define MAX_SIZEE 4096//size
using namespace std;
int getshmhelper(key_t k,int flags)
{
int shmid=shmget(k,MAX_SIZEE,flags);
if(shmid<0)//创建失败
{
cerr<<errno<<"errno: "<<strerror(errno)<<endl;
exit(2);
}
return shmid;
}
int getshm(key_t k)//获取共享内存
{
return getshmhelper(k,IPC_CREAT);
}
int creatshm(key_t k)//创建新的共享内存
{
return getshmhelper(k,IPC_CREAT|IPC_EXCL| 0600);
}
key_t getkey()//获取key
{
key_t k= ftok(PATH_NAME,PROJ_ID);
if(k<0)
{
cerr<<errno<<"errno: "<<strerror(errno)<<endl;
exit(-1);
}
return k;
}
#endif
- getkey函数获取key值返回
- creatshm函数创建新的共享内存
- getshm函数获取共享内存
- getshmhelper用于调用共享内存
shm_server.cc
#include"shme.hpp"
int main()
{
key_t k=getkey();
printf("0x%x\n",k);
int shmid=creatshm(k);
cout<<"shmid: "<<shmid<<endl;
//cout<<"hello im shm_server.cc"<<endl;
return 0;
}
shm_client.cc
#include"shme.hpp"
int main()
{
key_t k=getkey();
printf("0x%x\n",k);
int shmid=getshm(k);
cout<<"shmid: "<<shmid<<endl;
// cout<<"hello im shm_client.cc"<<endl;
return 0;
}
- shm_server.cc用来创建新的共享内存,shm_client.cc用于与共享内存挂接
运行打印可以看到key值相同,shmid都是4
- 共享内存生命周期随内核
终止掉程序再次运行时,可以看到报错说明文件以存在。由于shmget函数标记位为IPC_CREAT|IPC_EXCL时,若共享内存已存在则报错返回,则不能再次创建新的共享内存了;说明共享内存的生命周期并不随进程,而是随内核。那么共享内存不使用时必须释放!
ipcs -m :查看共享内存属性
ipcrm -m shimd
- 释放shmid对应的共享内存
ipcs -q :查看消息队列属性
ipcs s:查看system V中的其他通信方式的各种属性
shmctl函数操作共享内存
用于操作共享内存
函数原型
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 第一个参数shmid是共享内存标识符
- 第二个参数cmd是控制的动作
- 第三个参数buf是指向一个保存着共享内存的模式状态和访问权限的数据结构,通常设置成nullptr
- 返回值,函数调用成功返回0,调用失败返回-1
控制动作常用的有三个
IPC_STAT | 获取共享内存的当前关联值,此时参数buf作为输出型参数 |
IPC_SET | 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值 |
IPC_RMID | 删除共享内存段 |
shmat函数用于挂接
使进程与共享内存挂接
函数原型
void *shmat(int shmid, const void *shmaddr, int shmflg);
- 第一个参数是shmid共享内存标识符;
- 第二个参数shmaddr为指定连接的地址,通常设置为nullptr,让核心自动选择一个地址
- 第三个参数是shmflg,它的两个可能取值是SHM_RND和SHM_RDONLY
- 返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1
shmdt函数用于去关联
使进程与共享内存去关联
函数原型
int shmdt(const void *shmaddr);
- 第一个参数shmaddr是由shmat函数所返回的指针
shme.hpp
#ifndef _SHME_HPP_
#define _SHME_HPP_
#include<iostream>
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<errno.h>
#include<unistd.h>
#define PATH_NAME "."//pathname
#define PROJ_ID 0x66//ID
#define MAX_SIZEE 4096//size
using namespace std;
int getshmhelper(key_t k,int flags)
{
int shmid=shmget(k,MAX_SIZEE,flags);
if(shmid<0)//创建失败
{
cerr<<errno<<"errno: "<<strerror(errno)<<endl;
exit(2);
}
return shmid;
}
int getshm(key_t k)//获取共享内存
{
return getshmhelper(k,IPC_CREAT);
}
int creatshm(key_t k)//创建新的共享内存
{
return getshmhelper(k,IPC_CREAT|IPC_EXCL| 0600);
}
key_t getkey()//获取key
{
key_t k= ftok(PATH_NAME,PROJ_ID);
if(k<0)
{
cerr<<errno<<"errno: "<<strerror(errno)<<endl;
exit(-1);
}
return k;
}
void* attachshm(int shmid)//进程与共享内存挂接
{
void*ret=shmat(shmid,nullptr,0);
if((long long)ret==-1L)
{
cerr<<errno<<"errno: "<<strerror(errno)<<endl;
exit(-1);
}
return ret;
}
void detech(void*start)//去关联
{
if(shmdt(start))
{
cerr<<errno<<"errno: "<<strerror(errno)<<endl;
exit(-1);
}
}
void detachShm(int shmid)//释放共享内存
{
int rm=shmctl(shmid,IPC_RMID,nullptr);
if(rm<0)
{
cerr<<errno<<"errno: "<<strerror(errno)<<endl;
exit(-1);
}
}
#endif
shm_server.cc
#include"shme.hpp"
int main()
{
key_t k=getkey();//获取key
printf("0x%x\n",k);
int shmid=creatshm(k);//创建共享内存
cout<<"shmid: "<<shmid<<endl;
sleep(2);
//挂接
char* atshm=(char*)attachshm(shmid);
printf("server attach address: %p\n",atshm);
sleep(1);
//进程与共享内存去关联
detech(atshm);
sleep(1);
//释放共享内存
detachShm(shmid);
return 0;
}
shm_client.cc
#include"shme.hpp"
int main()
{
key_t k=getkey();
printf("0x%x\n",k);
int shmid=getshm(k);//获取共享内存
cout<<"shmid: "<<shmid<<endl;
sleep(1);
//挂接
char* atshm=(char*)attachshm(shmid);
printf("client attach address: %p\n",atshm);
sleep(2);
//进程与共享内存去关联
detech(atshm);
return 0;
}
通过共享内存进行通信
shm_client.cc
#include"shme.hpp"
int main()
{
key_t k=getkey();
printf("0x%x\n",k);
int shmid=getshm(k);//获取共享内存
cout<<"shmid: "<<shmid<<endl;
//挂接
char* atshm=(char*)attachshm(shmid);
printf("client attach address: %p\n",atshm);
//通信:client作为写端
int cnt=0;
char bufffer[1024];
const char* message="hello server:im client,im talking to you";
while(true)
{
snprintf(atshm,MAX_SIZEE,"%s[pid: %d][消息编号: %d]",message,getpid(),cnt++);
sleep(5);
}
//进程与共享内存去关联
detech(atshm);
return 0;
}
- client做为写端,每5秒往共享内存里写一条数据
shm_server.cc
#include"shme.hpp"
int main()
{
key_t k=getkey();//获取key
printf("0x%x\n",k);
int shmid=creatshm(k);//创建共享内存
cout<<"shmid: "<<shmid<<endl;
//挂接
char* atshm=(char*)attachshm(shmid);
printf("server attach address: %p\n",atshm);
while(true)
{
printf("client says: %s\n",atshm);
sleep(1);
}
//进程与共享内存去关联
detech(atshm);
sleep(1);
//释放共享内存
detachShm(shmid);
return 0;
}
- server作为读端,每隔1秒把共享内存中的数据打印出来
通过现象可以看出
- 共享内存没有同步与互斥机制,即没有对数据进行保护(共享内存的缺点)
- 在写端还没开始写时,读端已经开始读了;然而在这种情况对于管道,读端会阻塞等待写端写入
- 写端写了一条信息,读端一直在读那条信息;然而对于管道,写端写多少,读端读多少,读到0会阻塞等待写端写入
另外还有另一条特性
- 基于相对其他进程间通信的方式,共享内存拷贝次数最少,因此是所有进程间通信方式中速度最快的
- 由于共享内存是两个进程所共有的,进程一只需把数据写进内存中,进程二就能看到
- stdin写入数据到缓冲区,缓冲区拷贝一次数据到进程一,进程一再拷贝一次数据到共享内存中;进程二从共享内存中拷贝一次数据,然后stdout再从进程二中拷贝一次数据加以打印,一共四次拷贝数据
- 相对于管道通信,进程一需将数据额外拷贝一次给缓冲区,再让缓冲区拷贝一次数据到管道中;相应的,管道中的数据需要拷贝一次到缓冲区,进程二才能从缓冲区中拷贝一次数据拿到。在共享内存中通信一次比管道中通信少了2次拷贝。
共享内存的内核结构
实际上操作系统中存在许多共享内存,那么操作系统需要去维护共享内存的内核数据结构,可以通过shmctl接口查看,该内核数据结构体内含一些共享内存内核的信息供用户去调用查看
再谈shmctl函数
函数原型
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 第二个参数cmd是控制的动作,当传参IPC_STAT时,可以获取共享内存内核结构里的信息
- 第三个参数buf是指向一个保存着共享内存的模式状态和访问权限的数据结构,可以设置为shmid_ds结构体。当第二个参数传参IPC_STAT时,操作系统会把共享内存内核信息设置进指向的结构体里。
- 建立结构体ds,通过ds查看共享内存的大小,pid等
system V消息队列
消息队列的定义
- 消息队列是一种先进先出的队列型数据结构(FIFO),实际上是系统内核中的一个内部链表。消息按顺序插入队列中,其中发送进程将消息添加到队列末尾,接收进程从队列头读取消息。
- 多个进程可同时向一个消息队列发送消息,也可以同时从一个消息队列中接收消息。发送进程把消息发送到队列尾部,接收进程从消息队列头部读取消息,消息一旦被读出就从队列中删除。
- 消息队列中消息本身由消息类型和消息数据组成,通常结构:
struct msgbuf {
long mtype; /* message type, must be > 0 */
char mtext[1]; /* message data */
};
- 实际上每个数据块都被认为是有⼀个类型,接收进程接收的数据块可以有不同的类型值,这样接收进程可以在队列中从头遍历,接收相应类型的消息数据中最靠近队列开头的那个。相应消息一旦被读取,就从队列中删除,其它消息维持不变。
- 消息队列提供了⼀个从⼀个进程向另外⼀个进程发送⼀块数据的⽅法。基于消息具有类型的属性,发送端可以也接收特定类型的消息,那么发送进程可以作为接收进程,相应的接收进程也可以作为发送进程。
消息队列的内核结构
实际上操作系统中会有存在很多的消息队列,系统也必须为消息队列维护内核数据结构
消息队列的数据结构如下:(注:结构体msqid_ds位于构 /usr/include/linux/msg.h中)
struct msqid_ds {
struct ipc_perm msg_perm;
struct msg *msg_first; /* first message on queue,unused */
struct msg *msg_last; /* last message in queue,unused */
__kernel_time_t msg_stime; /* last msgsnd time */
__kernel_time_t msg_rtime; /* last msgrcv time */
__kernel_time_t msg_ctime; /* last change time */
unsigned long msg_lcbytes; /* Reuse junk fields for 32 bit */
unsigned long msg_lqbytes; /* ditto */
unsigned short msg_cbytes; /* current number of bytes on queue */
unsigned short msg_qnum; /* number of messages in queue */
unsigned short msg_qbytes; /* max number of bytes on queue */
__kernel_ipc_pid_t msg_lspid; /* pid of last msgsnd */
__kernel_ipc_pid_t msg_lrpid; /* last receive pid */
};
- 可以看到该数据结构中的第一个参数是msg_perm结构体,类型是ipc_perm,让我们转到ipc_perm结构体的定义
struct ipc_perm {
key_t __key; /* Key supplied to xxxget(2) */
uid_t uid; /* Effective UID of owner */
gid_t gid; /* Effective GID of owner */
uid_t cuid; /* Effective UID of creator */
gid_t cgid; /* Effective GID of creator */
unsigned short mode; /* Permissions */
unsigned short __seq; /* Sequence number */
};
msgget函数创建消息队列
函数原型如下:
int msgget(key_t key, int msgflg);
- 第一个参数key与共享内存的key一样,用于标定唯一性
- 第二个参数msgflg由九个权限标志构成,用法和共享内存shmflg的一样
- 消息队列创建成功时,msgget函数返回的一个有效的消息队列标识符(用户层标识符),创建失败返回-1
msgctl函数用于操作消息队列
函数原型如下:
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
- 第一个参数是由msgget函数返回的消息队列标识符
- 第二个参数cmd是控制的动作
- 第三个参数buf是指向一个保存着消息队列的模式状态和访问权限的数据结构,通常设置成nullptr
控制的动作通常有三个:(其作用和共享内存的一样)
IPC_STAT | 获取消息队列的当前关联值,此时参数buf作为输出型参数 |
IPC_SET | 在进程有足够权限的前提下,将消息队列的当前关联值设置为buf所指的数据结构中的值 |
IPC_RMID | 删除消息队列 |
msgsnd函数用于发送消息
- 把⼀条消息添加到消息队列中
函数原型如下:
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
- 第一个参数msqid是由msgget函数返回的消息队列标识符
- 第二个参数msgp是⼀个指针,该指针指向准备发送的消息
- 第三个参数msgsz是msgp指向的消息长度,这个⻓度不含保存消息类型的那个long int⻓整型
- 第四个参数msgflg是控制消息发送的方式,有阻塞和非阻塞(IPC_NOWAIT)两种方式。
导致msgsnd函数阻塞的原因:
- 消息队列满:阻塞条件为:msg_cbytes + msgsz > msg_qbytes
- sg_cbytes:消息队列中已使用字节数;
- msg_qbytes:消息队列中可以容纳的最大字节数;
- 消息总数满:系统中所有消息队列记载的消息总数已达到系统上限值。
- msgsnd函数返回值:调用成功返回0,失败返回-1
msgrcv函数用于接收消息
- 从消息队列msgid中读取一条消息
函数原型如下:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);
- 第一个参数msqid是由msgget函数返回的消息队列标识符
- 第二个参数msgp是⼀个指针,该指针指向接收消息的内存缓冲区
- 第三个参数msgsz是msgp指向的消息长度,这个⻓度不含保存消息类型的那个long int⻓整型
- 第四个参数msgtyp是指定读取消息的类型
- 第五个参数msgflg是指定了消息的接收方式,一般有两种选项
- IPC_NOWAIT:非阻塞方式读取信息
- MSG_NOERROR:截断读取消息
- msgrcv函数调用成功返回获取mtext数组的字节数,失败返回-1
消息队列进行进程间通信
- 接下来通过消息队列,完成server端先接收client发送过来的消息,然后再发消息给client端,这样的来回发送消息完成进程间通信
- 定义消息结构
struct msggbuf{
long mtype;
char mtext[1024];
};
msg.hpp
#ifndef _MSG_HPP_
#define _MSG_HPP_
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
#include<stdio.h>
#include<errno.h>
#include<unistd.h>
#include<string.h>
#include<iostream>
#define PATH_NAME "."//消息队列的路径
#define PROJ_ID 0x666//消息队列的自选id
#define SERVER_TYPE 1
#define CLIENT_TYPE 2
using namespace std;
struct msggbuf{
long mtype;
char mtext[1024];
};
int sendmsg(int msgid,int sendtype,char* msg)
{
struct msggbuf buf;
buf.mtype=sendtype;//消息队列的类型
strcpy(buf.mtext,msg);
if(msgsnd(msgid,&buf,sizeof(buf.mtext),0)<0)
{
perror("msgsnd error");
exit(-1);
}
return 0;
}
int receivemsg(int msgid,int receivetype,char out[])
{
struct msggbuf buf;
if(msgrcv(msgid,&buf,sizeof(buf.mtext),receivetype,0)<0)
{
perror("msgrcv error");
exit(-1);
}
strcpy(out,buf.mtext);
return 0;
}
int getmsghelper(int key,int flags)//总的获取消息队列函数
{
int msgid=msgget(key,flags);
if(msgid<0)//创建失败
{
perror("getmsghelper error");
}
return msgid;
}
int creatmsg(int key)//创建消息队列--发送端调用
{
getmsghelper(key,IPC_CREAT|IPC_EXCL| 0600);
}
int Getmsg(int key)//获取消息队列--接收端调用
{
getmsghelper(key,IPC_CREAT);
}
key_t getkey()//获取key值
{
key_t keynum=ftok(PATH_NAME,PROJ_ID);
if(keynum<0)
{
perror("ftok error");
exit(-1);
}
return keynum;
}
void deletemsg(int msgid)//删除消息队列
{
if(msgctl(msgid,IPC_RMID,nullptr)<0)
{
perror("msgctl error");
exit(-1);
}
}
#endif
msg_server.cc
#include"msg.hpp"
int main()
{
cout<<"hello im msg_server.cc"<<endl;
int key=getkey();
int msgid= creatmsg(key);
sleep(1);
cout<<"server create msgqueue success\n"<<endl;
//发送消息
char buf[1024];
while(true)
{
buf[0]=0;
receivemsg(msgid,CLIENT_TYPE,buf);
printf("client# %s\n",buf);
printf("please enter# ");
fflush(stdout);
ssize_t s=read(0,buf,sizeof(buf));
if(s>0)
{
buf[s-1]=0;
sendmsg(msgid,SERVER_TYPE,buf);
printf("send done,wait recieve:...\n");
}
}
sleep(1);
deletemsg(msgid);
cout<<"server delete msgqueue success\n"<<endl;
return 0;
}
- 由于server端先等待的client端,所以要先运行server
msg_client.cc
#include"msg.hpp"
int main()
{
cout<<"hello im msg_client.cc"<<endl;
cout<<"hello im msg_server.cc"<<endl;
int key=getkey();
int msgid= Getmsg(key);
sleep(1);
cout<<"server create msgqueue success\n"<<endl;
//接收并打印消息
char buf[1024];
while(true)
{
buf[0]=0;
printf("please enter# ");
fflush(stdout);
ssize_t s=read(0,buf,sizeof(buf));
if(s>0)
{
buf[s-1]=0;
sendmsg(msgid,CLIENT_TYPE,buf);
printf("send done,wait recieve ...\n");
}
receivemsg(msgid,SERVER_TYPE,buf);
printf("server says#: %s\n",buf);
}
sleep(1);
deletemsg(msgid);
cout<<"server delete msgqueue success\n"<<endl;
return 0;
}
浅谈system V信号量
信号量相关概念
- 由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥
- 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
- 在进程中涉及到互斥资源的程序段叫临界区,其余程序段叫非临界区
- 原子性指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。
- IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
信号量是什么
信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。
- 通俗理解信号量本质是一个计数器
进程互斥
进程具有独立性,那么在进程间通信时就需要一份共享资源,但如果没有对该共享资源做相应保护的话,会造成各个进程从该共享资源获取的数据不一致问题。
- 保护该共享资源的代码叫做临界区,该被保护的共享资源叫做临界资源,信号量就是用来保护临界资源
信号量模型
信号量结构体
struct semaphore
{
int value;
pointer_PCB queue;
}
- 信号量除了一个计数器value外,还有一个管理想要占用临界资源的进程的队列queue
计数器含义
- value>0:value表⽰当前可⽤的临界资源的个数
- value=0:临界资源都被占用,可用资源数为0
- value<0:临界资源都被占用,并且还有value个进程正在队列queue中排队
P操作:申请资源
P(s)
{
s.value = s.value--;
if (s.value < 0)
{
// 该进程状态置为等待状状态
//将该进程的PCB插⼊相应的等待队列s.queue末尾
}
}
- 不难理解,当有进程申请资源时,信号量value减减,若value<0,意味着在此之前就有value个进程在队列中等待占用资源,那么这个进程需要尾插到队列中,阻塞等待
V操作:释放资源
V(s)
{
s.value = s.value++;
if (s.value < =0)
{
// 唤醒相应等待队列s.queue中等待的⼀个进程
// 改变其状态为就绪态
// 并将其插⼊就绪队列
}
}
- 这里也不难理解,当有进程使用完资源后离开,信号量value加加,若value<0意味着队列中还有阻塞等待使用资源的进程,那么操作系统就会唤醒队列中优先度高的进程,将其阻塞状态改为就绪状态,将该进程插入就绪队列即准备使用资源;当value=0意味着队列中没有阻塞等待的进程,那么有进程要使用临界资源时就不需要进入阻塞队列中而直接去使用资源
信号量集结构
实际上操作系统中有许多信号量,那么就需要操作系统去维护信号量的内核结构
结构如下:
struct semid_ds {
struct ipc_perm sem_perm; /* Ownership and permissions */
time_t sem_otime; /* Last semop time */
time_t sem_ctime; /* Last change time */
unsigned short sem_nsems; /* No. of semaphores in set */
};
- 注意一下,信号量内核结构的第一个参数是ipc_perm类型的结构体,这点与共享内存和消息队列无差异
信号量集函数
semget函数用于创建和访问⼀个信号量集
函数原型如下:
int semget(key_t key, int nsems, int semflg);
- 第一个参数key与共享内存的key一样,由ftok函数返回给出,用于标定唯一性
- 第二个参数nsems表示创建信号量的个数
- 第三个参数semflg和共享内存那里的使用无差别
- 返回值:调用成功时,返回的一个有效的信号量集标识符;调用失败返回-1
semctl函数用于控制信号量集
函数原型如下:
int semctl(int semid, int semnum, int cmd, ...);
- 第一个参数semid为semget函数的返回值
- 第二个参数semnum为信号集中信号量的序号
- 第三个参数cmd的使用与消息队列那里的使用无差异,当传入的参数为IPC_RMID时,操作为删除信号量集
semop函数用于创建和访问⼀个信号量集
函数原型如下:
int semop(int semid, struct sembuf *sops, unsigned nsops);
- 第一个参数semid为semget函数的返回值
- 第二个参数sops为是个指向⼀个结构数值的指针
- 第三个参数nsops为信号量的个数
- 返回值:调用成功返回0,失败返回-1
对信号量的介绍就到这,后续我会分享更为深入的信号量知识
system V IPC联系
- 通过对system V系列进程间通信的学习,可以发现共享内存、消息队列以及信号量,虽然它们内部的属性差别很大,但是维护它们的数据结构的第一个成员确实一样的,都是ipc_perm类型的成员变量。
- 这样设计的好处就是,在操作系统内可以定义一个struct ipc_perm类型的数组,此时每当我们申请一个IPC资源,就在该数组当中开辟一个这样的结构。
- 也就是说操作系统会将所有IPC资源的ipc_perm成员组织成数组,当需要调用其中一个类型的ipc资源时,就通过切片的方式获取该ipc成员的起始地址,也就能够访问相应类型的ipc成员啦!