命名管道与共享内存¶
约 5401 个字 556 行代码 14 张图片 预计阅读时间 25 分钟
命名管道介绍和基本使用¶
理解了匿名管道后,命名管道的理解就会变得容易。在前面使用匿名管道时可以发现,之所以可以匿名是因为由父进程创建,子进程拷贝所以子进程和父进程都可以看到这个管道。但是如果对于任意两个进程,因为进程之间是独立的,需要任意两个进程看到这个管道就需要借助进程通信,但是匿名管道本身就是用于进程通信,所以匿名管道无法用于任意的两个进程。对此,根据一个文件可以被任意一个进程打开并由任意多个进程共享,如果设计一个文件作为两个进程通信方式就可以解决这个问题,此时这个文件也被称为命名管道
在Linux中,创建命名管道的方式有两种:
- 终端命令
mkfifo 文件名
- 函数调用:
int mkfifo(const char *pathname, mode_t mode)
首先介绍终端命令,使用mkfifo
命令创建一个管道文件,这个文件的类型是p
,表示管道(pipe)类型的文件,在当前路径下创建一个命名管道如下图所示:
当使用一个指令向管道内写入数据,再使用另外一个指令从管道中读取数据,就可以看到下面的效果:
在匿名管道部分提到过,在终端中指向的指令实际上是一个进程,所以此时使用echo
的进程向命名管道中写入数据,使用cat
的进程从命名管道中读取数据,此时就是进程间通信
可以看到,如果两个进程要使用命名管道进行通信,就必须有一个进程先创建命名管道,另外一个进程获取命名管道,所以两个进程使用命名管道的方式为:
- 创建+使用
- 获取+使用
如果想要删除一个文件,就可以使用前面提到的unlink
命令删除管道文件,也可以使用rm
删除
命名管道的原理¶
之所以叫命名管道,本质就是因为命名管道就是一个文件,一个文件就存在自己的路径,在Linux中,要查找一个文件就会根据这个文件的路径进行查找,此时查出的结果一定是唯一的,所以任意两个进程要通过命名管道进行通信就必须通过路径打开命名管道,也就是打开文件,此时二者就构成了访问同一份资源的通信条件
既然命名管道是一个文件,那么在磁盘上一定有其对应的inode
编号与文件名映射,那么是否可以直接使用一个普通文件完成进程通信?实际上也是可以的,但是对于普通文件来说,其存在最大的问题就是会将文件中的内容刷新到磁盘上,而对于命名管道来说,之所以单独为他创建一个文件类型,就是因为他不进行内容刷新,完全是内存级别的文件,所以其在磁盘上的inode
编号和文件名映射也只是占个位置
使用函数调用完成两个进程通信¶
创建命名管道的函数调用为int mkfifo(const char *pathname, mode_t mode)
,其第一个参数传递路径名称,表示在哪个目录下创建命名管道(可以传递命名管道的名称),第二个参数传递命名管道的权限,其与文件权限一样。如果命名管道创建成功函数返回0,否则返回-1
前面已经介绍过使用命令如何创建命名管道,接下来主要介绍如何使用函数调用创建命名管道,基本上分为下面的步骤:
- 第一个进程创建命名管道并打开管道进行使用
- 第二个进程获取(打开)对应的命名管道并进行使用
所以此处需要用到两个可执行程序,首先创建对应的Makefile
:
Makefile | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
在上面的Makefile
中,为了同时生成出两个可执行程序,需要用到all
,其依赖关系为两个可执行程序,但是因为这两个可执行程序还不存在,Makefile
会向下执行直到all
的依赖全部存在为止。另外,Server
表示创建命名管道的一方,Client
表示使用获取命名管道的一方,本次演示Client
向命名管道中写入,Server
从命名管道中读取
在匿名管道部分实现了简单的进程池,当时也处理了从面向过程转向面向对象,所以本次直接使用面向对象的思路进行设计
在下面的两个类的设计中,有些内容是共用的,所以放在单独的一个头文件中:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
设计Server
类
因为要创建命名管道,所以考虑在Server
类对象创建时就创建命名管道。注意,因为命名管道是一个文件,有文件就会有对应的权限,所以命名管道也有对应的权限,也就是说一个进程创建的管道文件,其他进程需要向该管道读取或者从该管道输入都要有对应的权限,所以在创建管道时也需要给予管道文件的权限:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
接着,因为Server
需要从命名管道中读取,所以可以考虑实现一个函数用于打开对应的命名管道文件,因为打开文件会返回对应的文件描述符,所以可以考虑添加一个成员_fd
存储命名管道的文件描述符
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
在上面的代码中,_fd
就是成员变量,用于存储管道的文件描述符
因为Server
是读取数据,所以考虑在Server
类中提供读取方法,该方法返回读取到的字节数,如果为0,说明读取到文件结尾,可能是写端关闭,否则就是正常读取到的数据,本次以读取字符串为例,为了保证外部可以直到读取到的字符串,需要调用方传递一个实参,此时函数的形参应该应该作为输出型参数,下面有常见的三种写法分别代表不同类型的参数:
*
表示输出型参数const &
表示输入型参数&
表示输入输出型参数
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
最后就是关闭管道,关闭管道只需要关闭对应的文件并删除命名管道文件即可,在代码中删除命名管道文件可以使用unlink
系统调用:
C++ | |
---|---|
1 |
|
提供对应的函数如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
设计Client
类
设计Client
类的思路和Server
类的思路非常类似,只需要将Server
类中的「创建管道」改为「获取(打开)管道」,将「管道读取」改为「管道写入」,代码整体如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
至此,命名管道的用法就是上面的过程,上面两个类还可以对相同的代码进行简化,此处就不再赘述,下面是对应的主函数:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
运行结果如下:
共享内存介绍¶
除了前面提到的两个管道可以进行进程通信外,共享内存也是一种方式,但是共享内存是System V标准下的进程间通信方式。共享内存本质就是在内存上开辟一块空间并将其链接到两个进程的PCB中,从而让两个进程都能看到同一块资源,进而实现进程间通信,具体原理如下图所示:
在上面的原理图中,将共享内存通过页表链接到进程PCB的过程叫做挂接,当进程PCB与共享内存断开连接的过程叫做去关联
因为共享内存在操作系统中可以存在多个,所以操作系统也需要对开辟的共享内存进行管理,而两个进程要想确定找到的是同一个共享内存就必须通过唯一的标识符,所以共享内存就是通过实际的物理内存块+内核数据结构组成
共享内存有如下的特点:
- 通信速度最快,因为其不需要调用I/O接口,从而不需要内核级文件缓冲区,减少了通信内容的拷贝次数
- 共享内存没有任何保护机制,导致其不会出现一个进程正在写,另一个进程正在阻塞的现象,所以更加容易出现数据不一致的问题,这也就意味着需要通过加锁以及同步的方式对共享内存进行保护
共享内存的基本使用¶
使用共享内存与前面使用管道的思路是大致一致的,尤其与命名管道非常类似,同样需要一方先向内存中申请共享内存并使用,另一方再获取到共享内存并使用,所以同样需要两个可执行文件
基本步骤如下:
- 第一个进程申请共享内存并挂接进行使用
- 第二个进程获取对应的共享内存并挂接进行使用
首先创建对应的Makefile
:
Makefile | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
设计Server
类
本次实现时与命名管道类似,让Server
类申请共享内存,并从共享内存中读取数据,因为共享内存是由操作系统开辟的,所以进程只能向操作系统申请,此时就需要用到系统调用接口shmget
:
C | |
---|---|
1 |
|
对于shmget
函数来说,第一个参数表示共享内存的标识符,这个标识符由用户指定,但是一般情况下用户只需要调用ftok
函数即可获取到对应的key_t
值,ftok
函数如下:
C | |
---|---|
1 |
|
该函数传入两个参数,第一个参数表示路径,第二个参数表示项目ID,这两个参数没有固定的内容,但是一般使用有意义的路径和项目ID,该函数返回一个key_t
的值
shmget
函数的第二个参数传递共享内存需要开辟的空间大小,因为操作系统每一次读取是按照4kb进行,所以一般建议size
的值为4096
的整数倍
Note
需要注意的是,如果需要开辟的共享内存大小不足4096的整数倍,操作系统会开辟刚好大于需求的4096整数倍的共享内存,但是实际给使用方就只有需要的开辟大小
shmget
第三个参数为标记位,一般常用的有两个标记:
IPC_CREAT
:如果单独使用IPC_CREAT
,那么就代表如果指定的共享内存不存在就创建,否则就使用已有的共享内存IPC_EXCL
:单独使用无意义,但是一般配合IPC_CREAT
可以实现当指定的共享内存不存在时就创建,否则就报错
这两个标记一起使用的方式与open
函数中的打开模式一样,只需要按位或即可
shmget
函数申请成功会返回共享内存标识符,否则返回-1。注意,这个标识符并不是前面传入的key_t
的值,而是类似于数组下标的一个值,从0开始。后面使用的与共享内存相关的大部分操作都会使用共享内存标识符而不是key_t
值(尽管可以使用key_t
值,但是不推荐),例如管理共享内存的指令ipcs -m
和ipcrm -m 共享内存标识符
,其中ipcs -m
指令是查看当前用户创建的共享内存的个数,ipcrm -m 共享内存标识符
表示根据共享内存标识符释放对应的共享内存
Note
需要注意,共享内存与前面的两个管道不同,其生命周期跟随操作系统,而不是跟随进程,所以进程退出不会自动销毁共享内存,需要在代码层面或者指令关闭共享内存
所以,共享内存标识符和key_t
值的关系为:共享内存标识符是给用户使用的一个标识共享内存的标识符,便于用户更好的去管理共享内存,而key_t
值是提供给操作系统使用,用于区分不同的共享内存
同样,一些相同的内容可以放在一个公共的头文件中方便调用:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
有了上面的内容后,就可以开始设计Server
类,首先是申请共享内存,调用ftok
函数获取key_t
值,再通过该值申请共享内存,考虑在Server
对象创建时自动创建共享内存:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
如果此时创建Server
对象并执行对应的可执行程序就可以判断是否成功创建共享内存,如下图所示:
创建完成共享内存后,就需要考虑将共享内存挂接到指定的进程中,所以可以使用shmat
接口:
C | |
---|---|
1 |
|
该接口虽然有三个参数,但是最后一个参数和第二个参数暂时用不到,只需要传递0和NULL
即可,第一个参数就是共享内存唯一标识符。该接口返回一个void *
代表共享内存的起始地址,既然是void *
证明可以使用共享内存传递任何内容,如果挂接失败,该函数会返回void *
类型的-1
根据上面的描述,下面实现一个函数用于挂接,为了后面可以向调用层返回共享内存的起始地址,考虑使用一个成员变量接收shmat
的返回值:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 |
|
同样,为了测试挂接是否成功,可以创建Server
对象,调用挂接方法,如果使用ipcs -m
看到连接数(nattach
)不为0,说明挂接成功,需要注意,为了防止进程在查看挂接前退出,可以使用sleep
接口:
根据打印的结果可以判断挂接已经失败,对应的连接数也为0:
之所以挂接失败,本质上是因为在申请共享内存是并没有读写权限,这也就是为什么会有shm_mode
的原因,解决方案也很简单,只需要在申请共享内存时在shmflg
参数部分通过按位或添加读写权限即可:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
此时编译运行即可看到挂接成功:
对应的连接数从0变为1:
完成了挂接之后,接下来就可以让Server
从共享内存中读取数据了,但是因为共享内存是挂接到进程上而不是在其他位置,所以不需要对应的接口,进程只需要从自己的PCB空间中找到共享内存读取即可,所以为了方便处理,本次以读取字符串为例,并将读取过程设计在Server
主函数中,Server
类只需要返回挂接的共享内存起始地址即可:
C++ | |
---|---|
1 2 3 4 |
|
C++ | |
---|---|
1 2 3 4 5 6 7 |
|
读取完毕后就是去关联,同样,去关联可以使用对应的接口shmdt
:
C++ | |
---|---|
1 |
|
该函数参数只需要传递共享内存的起始地址即可,函数返回0代表去关联成功,否则失败,实现对应的接口如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
去关联结束后,就需要释放共享内存空间,防止内存泄漏,可以使用shmctl
接口:
C++ | |
---|---|
1 |
|
对于该接口来说,虽然有三个参数,但是实际上只需要使用前两个参数,第三个参数直接填入NULL
即可,第一个参数代表共享内存标识符,第二个参数代表一个操作标记,常用的标记为IPC_RMID
,表示标记共享内存段将被释放,利用该接口可以在Server类中实现,考虑到自动调用,可以考虑使用Server
对象的析构函数:
C++ | |
---|---|
1 2 3 4 5 6 |
|
至此,Server
类就设计完毕了,接下里就是考虑设计Client
类
设计Client
类
Client
类用于向共享内存中写入数据,所以依旧还是需要先获取到对应的共享内存,并将对应的共享内存起始地址挂接到Client
进程的PCB上,考虑在Client初始化对象时就获取共享内存,所以获取的步骤可以写在Client
类的构造函数中。对于获取共享内存来说,需要保证「存在时获取」,所以只需要使用IPC_CREAT
即可。同样,为了保存对应的共享内存标识符,可以使用一个成员变量接收shmget
的返回值:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
接着处理挂接,思路与Server
类一致:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
在Client
的主函数中创建Client
对象,为了可以看到共享内存的链接数,可以添加sleep
,编译运行后先运行Server
再运行Client
结果如下:
与Server一样,Client向上层返回共享内存的起始地址,上层只需要向该空间写入内容即可被Server端读取:
C++ | |
---|---|
1 2 3 4 |
|
C++ | |
---|---|
1 2 3 4 5 |
|
最后就是处理Client
去关联,但是Client
不需要处理释放共享内存,因为Server
端已经进行了处理,为了保证Client
一定可以断开连接,考虑单独写一个接口而不是放在Client
的析构函数中:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
最后,完善两个程序的主函数如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
编译运行代码,先运行Server
,再运行Client
,在Client
运行的窗口中输入内容就可以在Server
端看到输出,为了保证输出的效果,可以使用sleep
:
Client
输入:
Server
输出:
从上面Server
的输出结果可以看出,尽管Client
只输入了两次内容,但是Server
端在等待输入前会一直打印空,在第二次输入之前,会一直打印第一次输入的内容,根据这个特点也就可以看出共享内存没有阻塞等待的特点,这就可能会导致输入的内容和输出的内容不一致的情况
Note
上面的代码中存在一些共性的地方,比如创建或者获取共享内存时的标记可以通过参数传递、去连接的代码和获取共享内存的代码等,可以进行抽离
结合命名管道保护共享内存¶
因为共享内存没有任何保护机制,所以在使用过程中为了防止出现数据不一致的问题,需要对共享内存进行使用保护,常见的保护是进行加锁和同步,但是因为目前还没有提到锁机制,所以暂时用命名管道替代
本次使用命名管道保护共享内存的思路如下:
- 写端向共享内存中写入数据,写完后向命名管道中写入数据(相当于通知读端可以读取共享内存)
- 读端读取完命名管道的内容后就会读取共享内存中的数据,否则就会一直阻塞在命名管道
示意图如下:
Note
上面的做法只能保证写端写入时不会被读端读取到不完整的内容,但是会存在读端读取时写端还在写的情况,所以理论上来说读端和写端都需要两个命名管道对操作共享内存的代码进行包裹,本次只演示上面提到的情况
完善Server
类
因为Server
类需要向命名管道中读取,所以首先Server
类除了需要申请共享空间外,还需要创建命名管道,并且提供向命名管道中读取数据的接口,使用代码与前面命名管道的代码是一致的,细节不在赘述:
Note
本次规定向命名管道中写入一个整数代表信号
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
|
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
完善Client
类
因为Client
是写入端,所以需要通知Server
可以开始进行读取,即需要向命名管道中写入数据代表信号,因为是确保读取端在写入端写完后读取,所以需要再写入共享内存的步骤结束后向命名管道中写入,具体代码如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
编译运行上面的代码,就可以看到不会出现某一条相同的内容被共享内存读取端打印多次了:
本节彩蛋(获取时间的接口)¶
在Linux中,如果想通过代码看到当前的日期和时间可以使用localtime
接口,这个接口的作用是根据指定的时间戳转换为日期和时间,该接口如下:
C | |
---|---|
1 |
|
该接口可以传递一个参数,表示获取当前系统时间的时间戳,可以使用time
函数获取:
C | |
---|---|
1 |
|
localtime
返回一个struct tm
的结构体指针,而struct tm
结构体原型如下:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
所以此时就可以写出下面获取时间的代码:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|