文章目录
- 前言
- 一、管道通信
- 1、进程间通信目的
- 2、进程间通信分类
- 3、匿名管道通信
- 3.1 匿名管道通信介绍
- 3.2 匿名管道通信
- 3.3 匿名管道读写规则
- 3.4 匿名管道特点
- 3.5 站在文件描述符角度-深度理解管道
- 3.6 站在内核角度-管道本质
- 4、进程池练习
- 5、命名管道
- 6、匿名管道与命名管道的区别
- 二、共享内存
- 1、共享内存介绍
- 2、共享内存使用
- 三、消息队列
- 1、消息队列介绍
- 2、消息队列操作
前言
一、管道通信
1、进程间通信目的
当程序为单进程时,那么也就无法使用并发能力,就更加无法实现多进程协同了。而要是想要程序变为多进程的话,那么各个进程之间需要传输数据、同步执行流、消息通知等。所以进程间通信的目的就是解决这些问题。
数据传输:一个进程需要将它的数据发送给另一个进程。
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
因为进程是具有独立性的。虚拟地址空间+页表保证进程运行的独立性(每个进程都有自己的进程内核数据结构+进程的代码和数据),所以想要进行进程间通信的话成本会比较高,因为进程间通信的前提,首先需要让不同的进程看到同一块"内存"(特定的结构组织的),然后这样才能通过这块"内存"来互相交换数据。并且这块不同进程都能看到的"内存"不能隶属于任何一个进程,而应该强调共享。
2、进程间通信分类
管道:
(1). 匿名管道pipe
(2). 命名管道
System V IPC:
(1). System V 消息队列
(2). System V 共享内存
(3). System V 信号量
POSIX IPC:
(1). 消息队列
(2). 共享内存
(3). 信号量
(4). 互斥量
(5). 条件变量
(6). 读写锁
3、匿名管道通信
3.1 匿名管道通信介绍
在现实世界中,管道都有一个入口和一个出口,管道都是单向传输内容的。而在计算机通信领域的设计者,设计了一种单向通信的方式,即单向传输数据的方式,并且将这种通信方式命名为管道。
管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
下面是一个进程和它打开的内存文件的关系图,此时如果这个进程使用fork创建一个子进程时,操作系统会为子进程创建一份这些进程内核数据结构,并且会这些内核数据结构里面的数据也会拷贝父进程中的。
但是fork之后并不会为子进程创建被打开文件相关的内核结构。所以父子进程都指向同一个被打开文件相关内核结构。所以子进程的文件描述符表中指向的struct file和父进程中指向的相同,此时我们就看到父子进程都可以访问这些打开的内存文件。即这就解决了让不同的进程看到同一份内存的问题了。
此时父子进程的通信就可以通过双方进程各自关闭自己不需要的文件描述符来进行写入和读取的单向通信。即父进程写入就将读取文件描述符3关闭,只保留写入的文件描述符4,而子进程读取就将写入的文件描述符4关闭,只保留读取的文件描述符3。这样就做到了让两个进程看到同一份空间,并且父进程可以发数据给子进程。这个就叫做管道通信。是Linux天然支持的一种通信方式。并且管道通信不会将数据写到磁盘中,它是一种纯内存级的通信方式。 所有的通信方式都是内存级别的。即不会将数据写到磁盘中,因为进程通信的数据一般都是内存级别的临时数据,所以不需要写到磁盘中进行永久保存。
3.2 匿名管道通信
匿名管道通信可以通过系统调用pipe来实现。调用pipe就可以创建一个匿名管道,该函数的形参pipefd为一个输出型参数,即pipefd[0]表示读端,pipefd[1]表示写端。如果创建管道成功就会返回0,失败就会返回-1。
我们在下面的代码中创建一个管道,然后查看pipefd[0]和pipefd[1],发现pipefd[0]中存的是文件描述符3,pipefd[1]中存的是文件描述符4。而pipefd[0]表示读端,pipefd[1]表示写端,所以此时通过pipefd[0]就可以读取管道中的数据,通过pipefd[1]就可以向管道中写数据。
下面我们给这两句语句加上条件编译判断,即如果我们想在运行时显示这两句打印时,我们就可以定义DEBUG,可以在执行编译语句时在后面定义DEBUG这个宏。
g++ -o $@ $^ -std=c++11 -DDEBUG
如果我们不想让这两句语句被编译时,就可以在执行编译语句时在后面注释掉DEBUG这个宏。
g++ -o $@ $^ -std=c++11 #-DDEBUG
下面我们使用fork创建一个子进程,然后构建单向通信的信道,实现父进程向管道写入,子进程从管道中读取数据。首先我们就需要先将子进程的写的文件描述符关闭,将父进程的读的文件描述符关闭。
下面我们就在子进程中调用read系统调用来读取文件描述符为pipefd[0]的文件中的数据,即管道中的数据。
下面我们在父进程中使用snprintf生成不同的字符串,然后调用write系统调用,每1秒向父进程中文件描述符为pipefd[1]的文件中写入数据。
然后我们编译并运行程序,我们看到在子进程中成功读取到了父进程写到管道中的数据。管道是用来让具有血缘关系的进程之间进行通信的一种进程通信方式,常用于父子通信。
然后我们将父进程每5s向管道中写入一次数据,但是子进程中是一直循环从管道中读取数据的,但是我们看到子进程中并没有一直打印管道中的数据,而是也每5s打印一次数据。这是因为管道为了让进程间协同,提供了访问控制。
当一个进程尝试从一个空的管道中读取数据时,read接口会被阻塞直到管道内有数据为止,当一个进程尝试向一个满的管道中写入时,write接口会被阻塞直到足量的数据从管道中被读取走为止.
下面我们让父进程一直向管道中写数据,子进程每5s从管道中读取一次数据,我们看到会出现下面的情况,这是因为当父进程向管道中一直写数据时,如果管道中的数据满了,此时父进程的write接口会被阻塞直到足量的数据从管道中被读取走为止,即子进程从管道中将数据取走后,父进程才能接着向管道中写数据。
下面我们让父进程每1s向通道中发送一次数据,当发送5次数据后,父进程将向管道文件写入数据的文件关闭。然后子进程一直向管道中读取数据。我们看到当管道的写的一方关闭了后,读的一方最后一次读完管道中的数据后也会随之关闭。那么子进程是如何感知父进程关闭管道了呢?这是因为每当一个进程打开一个文件的时候,该文件的引用计数会加一;每当一个进程关闭一个文件的时候,该文件的引用计数会减一。当一个文件的引用计数减为0时,表明没有进程打开这个文件,那么这个文件才会被真正被关闭。当管道文件的引用计数为1时,表明父进程已经关闭管道文件,子进程读完当前消息就可以作为文件的结尾而退出了。因此子进程是可以感知父进程是否关闭写端的。
3.3 匿名管道读写规则
综上我们就知道了管道读写的规则。
a. 写快,读慢。写满不能再写了。
b. 写慢,读快。管道没有数据的时候,读必须等待。
c. 写关,读0,标识读到了文件结尾。
d. 读关,写继续写,OS终止写进程。
当没有数据可读时
O_NONBLOCK disable:read调用阻塞,一直等到有数据来到为止
O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN
(使用 fcntl 函数设置非阻塞选项)
当管道满的时候
O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
如果所有管道写端对应的文件描述符都被关闭,则read返回0表示读到文件结尾
如果所有管道读端对应的文件描述符都被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
当要写入的数据量不大于PIPE_BUF(Linux下为 4096 字节)时,linux将保证写入的原子性。否则将不保证。
3.4 匿名管道特点
(1). 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
(2). 管道提供流式服务。
(3). 管道为了让进程间协同,提供了访问控制。
(4). 一般而言,进程退出,管道释放,所以管道的生命周期随进程。
(5). 一般而言,内核会对管道操作进行同步与互斥。
(6). 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
3.5 站在文件描述符角度-深度理解管道
3.6 站在内核角度-管道本质
4、进程池练习
下面我们写一个进程池练习,用来更好的理解管道通信。
进程池的作用就是当父进程收到要执行的任务后,会从进程池中挑选子进程来执行这个任务。
我们需要先创建多个管道文件和多个子进程,每一个子进程通过一个管道与父进程通信,然后父进程随机分配任务,即指定哪个进程执行什么任务,然后子进程处理任务。
下面我们使用for循环创建5个管道和5个子进程。
然后我们创建一个vector容器,该容器里面的每一个元素都是pair类型的元素,即存子进程的pid和对应的管道的写的文件描述符,这样父进程才能分辨出和每个子进程对应的管道的写的文件描述符。
然后我们让子进程中调用waitCommand函数阻塞式等待命令,为什么说是阻塞式等待呢,这是因为在waitCommand函数中调用了read系统调用向管道中读取数据,而当管道中父进程没有写入数据时,此时管道就为空,所以read就会一直阻塞在这里读取管道数据,直到管道中被父进程写入数据为止。
下面我们再创建一个Task.hpp文件,里面实现了一些函数用来模拟任务,以后我们就可以将这些函数换成真正的业务代码。并且我们创建了一个存储函数类的vector容器callbacks,里面存放了这些函数。
然后我们再创建一个unordered_map容器,里面存对应的函数描述信息。
然后我们写一个showHandler函数用来打印各个函数的信息。写一个handlerSize函数用来返回有多少种任务。
然后再次回到ProcessPool.cc文件中,引入Task.hpp头文件,并且执行load函数,即将我们实现的模拟任务的函数都加载到容器中。
然后我们判断子进程中调用waitCommand函数从管道中读取的数据,如果从管道中读取的命令的编号正确,我们就让子进程执行对应的命令。
然后在父进程中通过用户输入命令,父进程随机选取一个子进程来执行这个命令。子进程通过sendAndWakeup方法来向子进程和对应的管道中写命令,当管道中有数据后,
然后我们再判断子进程读取管道数据时,父进程是否已经关闭了写管道,如果父进程关闭了写管道,那么子进程也关闭读管道,然后子进程退出。
下面让父进程将所有子进程都关闭,并且回收所有子进程信息,完成资源的回收工作。
我们可以看到当程序运行时,父进程随机选取了子进程来执行命令。
如果我们不想手动输入执行第几个命令,我们可以将代码改为随机选出命令编号,随机选子进程执行命令。下面就为父进程随机选取任务,随机选取子进程执行命令。我们的父进程采用随机数的方式,来选择子进程完成任务,即随机数方式的负载均衡。
我们可以看到用户不用输入,此时父进程随机选取任务,随机分配给子进程执行。
5、命名管道
匿名管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
命名管道是一种特殊类型的文件。
我们从上面介绍匿名管道时知道了当A进程打开一个文件后,如果B进程也要打开这个文件,那么操作系统并不会再重新打开一份这个文件并且创建file内核数据结构,而是直接将B进程也指向这个file内核数据结构。这样两个进程就看到了同一个文件。但是我们知道普通的文件在磁盘中都有映射,当缓冲区满时都会将数据更新到磁盘中,这个过程是很慢的。但是命名管道文件是一个特殊的文件,命名管道文件不会将内存中的数据刷新到磁盘中,这样就不用和硬件磁盘建立IO了。
我们可以通过下面的命令在命令行中创建一个命名管道。
mkfifo filename
下面为左边的进程循环向命名管道文件filename中写数据,右边的进程一直从命名管道文件中读取数据。
命名管道文件就类似于一个文件,当我们不使用这个文件时也可以删除这个文件,我们可以使用rm命令或unlink命令来删除命名管道文件。
unlink filename
rm filename
我们也可以在程序中通过mkfifo函数来创建一个命名管道文件。
mkfifo函数的第一个参数为命名管道文件创建的路径,第二个参数为命名管道文件的起始权限,如果创建命名管道文件成功就返回0,如果创建命名管道文件失败就返回-1,并且将错误码进行设置。
下面我们通过命名管道文件来实现两个没有血缘关系的进程进行通信。
我们需要让这两个程序都包含同一个comm.hpp头文件,这个头文件中规定了命名管道文件的路径、权限等一些公共信息,以便两个程序都能找到命名管道文件。
然后我们再来写好项目的makefile文件。
下面我们在server.cc中使用mkfifo函数创建一个命名管道文件。我们看到命名管道文件fifo.ipc被成功创建。
下面我们就可以进行一些正常的文件操作,即打开命名管道文件,从命名管道文件中读取数据或者写入数据等。我们将server进程实现从命名管道文件中读取数据并打印。
然后我们在client进程中打开管道文件,然后调用write系统调用向命名管道文件中写数据。
这样就能实现client进程向命名管道文件中写数据,server进程从命名管道文件中读取数据了,我们需要注意的是,因为是server进程创建的命名管道文件,所以当server进程退出时不只是需要将命名管道文件关闭,还需要将命名管道文件删除。下一次打开server进程时会重写创建一个新的命名管道文件。在程序中删除命名管道文件我们可以使用unlink系统调用。unlink系统调用的参数就是文件的路径,返回值为0就表示删除文件成功,返回值为-1就表示删除文件失败,并且会设置errno错误码。
我们看到此时client进程向命名管道文件中写的数据都被server进程读取到了。
下面我们再来写一个文件用来打印进程的执行情况。我们在Log.hpp文件中实现了一个Log函数用来打印信息,我们可以规定这个信息的级别为Debug或Notice或Warning或Error。这样就方便了程序的后期维护。
然后我们在comm.hpp头文件中引用这个Log.hpp这个头文件。
下面我们将server程序和client程序中都添加上程序运行状态的信息。
我们看到当先运行server进程时,该进程会直接创建好命名管道文件,但是并不会打开命名管道文件,因为此时命名管道文件的引用计数为0,所以server进程知道此时并没有进程向命名管道文件中写入数据,即命名管道文件的写端没有打开,所以不会打开命名管道文件的读端。
然后我们运行client进程,client进程会以写的方式打开命名管道文件,此时也可以看到server进程中打开了命名管道文件,因为命名管道文件的写端已经打开,所以server进程会打开命名管道文件的读端。
当我们将client进程关闭后,即命名管道的写端关闭了,此时可以看到server进程也关闭了命名管道的读端,并且将命名管道文件也删除了。
下面我们在server进程中创建3个子进程,然后每个子进程都调用getMessage函数阻塞在read中等待命名管道文件中写入数据,如果命名管道文件中被client进程写入数据后,此时server进程中创建的3个子进程都就会竞争式的从命名管道文件中读取数据,如果一个子进程读取到数据就会将数据打印出来,然后继续和其它两个进程还阻塞在read中等待命名管道文件中写入新的数据。
当我们运行server进程和client进程后,我们看到server进程中成功创建了3个子进程。
我们看到在server进程中3个子进程竞争式的从命名管道文件中读取数据,这样我们就使用一个管道实现了进程池。这样实现的话我们就不能指定哪一个子进程来执行任务了,而是子进程竞争式的执行任务。
6、匿名管道与命名管道的区别
匿名管道由pipe函数创建并打开。
命名管道由mkfifo函数创建,打开用open。
FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。
二、共享内存
1、共享内存介绍
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。
我们通过前面的管道通信知道了管道其实就是特殊的文件,操作系统管理管道文件和管理普通文件类似,所以Linux中可以将管道当作普通文件来管理,但是共享内存的通信方式是操作系统特意提供的进程通信方式,所以操作系统创建出共享内存后就需要管理共享内存,而管理共享内存的方式和管理进程、管理文件类似,都是先建立内核数据结构,然后通过管理这些内核数据结构来管理共享内存。
下面就是共享内存的内核数据结构,每当创建一个共享内存时,操作系统都会建立一个对应的共享内存内核数据结构。所以当使用共享内存的方式来进行进程间通信时,我们首先需要先建立一个共享内存。
struct shmid_ds {struct ipc_perm shm_perm; /* operation perms */int shm_segsz; /* size of segment (bytes) */__kernel_time_t shm_atime; /* last attach time */__kernel_time_t shm_dtime; /* last detach time */__kernel_time_t shm_ctime; /* last change time */__kernel_ipc_pid_t shm_cpid; /* pid of creator */__kernel_ipc_pid_t shm_lpid; /* pid of last operator */unsigned short shm_nattch; /* no. of current attaches */unsigned short shm_unused; /* compatibility */void *shm_unused2; /* ditto - used by DIPC */void *shm_unused3; /* unused */
};
共享内存是如何实现进程间通信的呢?我们知道每个进程都有一个自己的地址空间,即虚拟内存,然后通过页表将虚拟内存映射到物理内存中。如果我们修改两个进程的页表,即重新建立映射,将两个进程的一部分虚拟内存映射到同一片物理内存,那么就实现了进程间通信的第一个条件,让两个进程看到同一片空间。
2、共享内存使用
我们可以通过shmget函数来创建共享内存。
通过上面的介绍我们知道了进程间通过共享内存的方式通信,其实就是两个进程同时映射到了共享内存这片空间中,但是操作系统中可能创建了很多这样的共享内存,那么两个想要通信的进程是怎么确定它们之间映射的是同一个共享内存的呢?这就需要通过shmget函数的第一个参数key了,这个key值是多少不重要,只要两个想要通信的进程使用同样的算法形成一个相同的并且唯一的key值就可以,然后两个进程就可以通过这一个唯一的key值来映射同一片共享内存,key值会存在共享内存内核数据结构中。我们可以通过ftok函数来生成key值,ftok函数的第一个参数为一个路径,第二个参数可以写一个整数,然后ftok内部就会通过某种算法,根据这两个形参来生成一个唯一的key_t类型的值,key_t就是一个有符号整型。需要注意的是,ftok的第一个参数的路径需要保证用户具有访问权限才可以。
这样我们就明白了两个进程想要进行共享内存通信,需要两个进程有一个相同的key,这样两个进程才可以链接到同一个共享内存。
下面我们就来使用共享内存来实现两个进程间的通信。
我们先写好项目的makefile文件。
然后我们在comm.hpp头文件中定义两个进程通信时需要遵守的规范,即两个进程通过comm.hpp文件中定义的宏调用ftok函数时可以生成同样的key值,这样才能保证两个进程链接到同一个共享内存。
然后我们在server程序和client程序中调用ftok函数传入comm.hpp头文件中定义好的参数,可以看到两个进程中生成了同样的key值。
接下来我们就可以创建共享内存了。我们先在comm.hpp文件中规定共享内存的大小。
然后我们在server进程中创建一个全新的共享内存,我们测试时发现第一次执行server程序没有问题,也成功的创建了共享内存,但是第二次运行server程序时报出了文件已存在的错误,这其实是因为第二次运行server程序创建共享内存时,此时共享内存已经在底层存在了,而我们为了保证每一次创建的都是全新的共享内存,我们调用shmget函数时使用了IPC_CREAT | IPC_EXCL选项,所以当发现底层已经有共享内存存在时,才会报错。
其实当我们的进程运行结束时,共享内存并没有随着进程的结束而删除,因为创建一个共享内存时操作系统会为这个共享内存创建一个内核数据结构,所以共享内存的生命周期随内核。这样我们就需要每次手动来删除共享内存了。
我们可以通过下面的命令来查看系统中的共享内存。
//查看共享内存
ipcs -s
我们也可以通过ipcrm -m + 共享内存的shmid命令来删除共享内存。然后我们就可以再次运行server程序了,当我们再次运行了server程序后查看共享内存时,可以看到又新建立了一个共享内存。
//删除共享内存
ipcrm -m 1
通过上面的分析我们知道了需要每次在使用完共享内存后将共享内存删除,我们在代码中可以通过shmctl系统调用来删除共享内存。shmctl系统调用不只是可以删除共享内存,还可以通过传入的第二个参数的选项不同,来得到共享内存的信息等。第三个参数为共享内存内核数据结构,即可以通过这个参数来获取或设置共享内存的属性。我们只使用删除功能就好,所以我们给第二个参数传入IPC_RMID,第三个参数传入nullptr即可。
我们看到此时每一次运行server进程就不会报错了。因为共享内存创建后,当server进程结束时就删除创建的共享内存了,这样就不会出现共享内存已存在的错误了。并且我们看到每一次生成的共享内存的shmid都不同。
我们在上面使用ipcs命令查看共享内存时可以看到共享内存的key,但是我们发现server进程打印的key和ipcs命令查看到的共享内存的key不同,这是因为我们使用server进程打印的是key的十进制形式,而ipcs命名显示的是key的十六进制。所以下面我们将server中使用十六进制打印key。当共享内存被创建成功后我们将程序sleep10s,然后我们再写一个每秒打印共享内存的信息的脚本,然后我们看到两个打印中的key的值相同了。
我们在查看共享内存的信息时,发现还会有一个perms选项的信息,perms其实为共享内存的权限,权限为0说明任何进程都不能读写共享内存。
所以在创建共享内存时也需要指定权限。这样其它进程访问共享内存就有读写权限了。我们可以通过shmget函数的第三个参数来为创建的共享内存设置权限。然后我们就看到共享内存的权限为666了。
我们创建好了共享内存后,那么进程之间该怎么通过共享内存来通信呢?我们前面说到了当一个进程需要共享内存时,就将共享内存映射到该进程的页表中,即将指定的共享内存挂接到自己的进程空间,这个过程叫做attach。当一个进程不需要共享内存时,就将共享内存从页表的映射中去掉,这个过程叫detach。
我们可以通过shmat系统调用,将指定的共享内存挂接到自己的进程空间。第一个参数为共享内存的shmid,第二个参数为要将共享内存挂接到进程的虚拟地址(不推荐手动设置,直接设置为0,让操作系统自行选择),第三个参数为挂接方式。shmat返回值:成功时返回共享内存挂接在进程中的虚拟地址。失败时返回-1,并且错误码被设置。类似于malloc函数,malloc函数申请空间成功就会将空间的地址返回。失败就返回NULL。
我们看到当server进程挂接到创建的共享内存上时,此时共享内存的nattch从0变为1。其实nattch显示的就是当前共享内存被进程挂载的数量。并且我们看到删除共享内存时,即使有进程和当前的共享内存正在挂接,依旧会删除共享内存。这其实和调用shmctl的选项有关,IPC_RMID选项就规定了即便是有进程和当下的shm挂接,依旧删除共享内存。
上面我们将共享内存挂接到了自己的地址空间之中,那么当我们不再使用共享内存时,我们就需要将指定的共享内存从自己的地址空间中去关联。我们可以使用shmdt系统调用来进行进程和共享内存之间的去关联,shmdt的参数就是成功调用shmat函数返回的共享内存挂接在进程中的虚拟地址。即将shmat的返回值传入shmdt中,shmdt就会将这个共享内存与当前进程进行去关联。如果去关联成功shmdt会返回0,如果失败就会返回-1,并且设置错误码。
然后我们就完成了(1)共享内存的创建,(2)共享内存的挂接,(3)共享内存的去关联,(4)共享内存的删除。我们可以在2、3步骤之中写进程间通信的逻辑。即要使用共享内存来进行进程间通信,需要先创建共享内存 -> 然后进程挂载共享内存 -> 然后向共享内存中读写数据,进行通信 -> 然后进程和共享内存去掉关联 -> 然后关闭共享内存。
下面就演示了创建共享内存、挂接共享内存、共享内存去关联、删除共享内存这几个步骤后,共享内存的nattch的变化。
下面我们在client进程中挂载server进程创建好的共享内存,然后使用完了共享内存之后再将共享内存去关联,client进程不需要删除共享内存,因为client只管用,创建和删除共享内存是server进程维护的。
我们看到下面的测试中当server和client都挂接共享内存后,此时共享内存的nattch为2,此时两个进程就可以通过共享内存进行通信了。
到这我们就完成了进程间通过共享内存通信的准备工作,通过上面的实验我们了解了共享内存的建立中出现了两个唯一值,分别为key和shmid,那么这两个唯一值有什么区别呢?
我们知道当建立一个共享内存时,操作系统就会在内核空间中建立一份共享内存的内核数据结构,而key就是存在共享内存的内核数据结构中的,是在内核层面上用来标定共享内存唯一性的,想要让多个进程看到同一份共享内存就通过key。当使用shmget创建共享内存时使用同样的key,并且选项为IPC_CREAT | IPC_EXCL,那么第二次创建就会失败,因为此时操作系统的内核空间中已经有一份共享内存内核数据结构的key为这个值了,所以操作系统不会再创建一份key相同的共享内存内核数据结构了。
shmid是在用户层标定共享内存的唯一性,后期挂接、去关联、删除共享内存都使用shmid。
并且我们也知道了匿名管道的生命周期是随进程的,即使用匿名管道通信的进程,当进程结束时,匿名管道也会随之删除。而命名管道和共享内存的生命周期是随内核的,即创建命名管道相当于创建了一个文件,操作系统在内核空间中会为该文件创建一份文件内核数据结构;创建共享内存时操作系统也会在内核空间创建一份共享内存内核数据结构,所以这两种通信方式只有将内核空间中的内核数据结构删除了才算是真的删除了。
下面我们来接着实现两个进程间使用共享内存通信。
我们知道每一个进程都有一个虚拟地址空间,在虚拟地址空间中又分为堆区、栈区、静态区、代码段、共享区等等,在堆区和栈区之间有一片空间被称为共享区,该进程的共享内存、动态库等的虚拟地址空间就在这片区域。我们看到下面的两个进程的虚拟地址空间中的堆区、栈区、代码段区域等都通过页表映射在各自的物理内存中,而两个进程的虚拟地址空间中的共享区通过页表映射在了同一片物理内存中。这其实就是共享内存的实现原理,让两个进程的共享区映射到同一片物理内存,这样两个进程就看到了同一片内存空间。
我们知道当一个进程想要获取一个栈区中的变量的值时,需要知道这个变量的虚拟地址空间,然后通过这个虚拟地址空间根据页表的映射去物理内存中拿取数据,那么一个进程想要获取共享区的变量时,也是拿到共享区这个变量的虚拟地址空间,然后通过这个虚拟地址空间根据页表的映射去物理内存中拿取数据,那么我们从共享内存中获取数据不就和程序从变量中获取数据一样了嘛,这就是为什么共享内存是最快的进程间通信方式的原因,因为从共享内存中拿取数据和从进程自己的栈区、堆区中拿取数据一样。而我们之前讲的pipe、fifo需要调用read和write等系统调用,这是因为管道属于文件,操作系统通过为每个文件建立内核数据结构来管理文件,而这些内核数据结构是在1G的内核空间中的,用户无权直接访问,所以访问文件必须要调用系统接口,通过操作系统来进行访问,因为修改的是内核空间中的内核数据结构,只能通过操作系统来修改。而共享内存是创建在堆栈之中的,属性3G用户空间,所以用户可以直接访问,所以访问共享内存中的数据不需要进行系统调用。
我们使用malloc函数在堆区申请空间时,malloc函数会返回申请的空间的虚拟地址,那么我们使用shmat函数挂接共享内存时,shmat函数会返回挂接的共享内存的虚拟地址。这两个函数就有些类似了,都是返回进程的虚拟地址空间中的空闲的虚拟地址,所以我们使用共享内存的空间时就和使用malloc申请的堆区的空间一样。
下面我们在client进程中将共享内存这片区域当作一个大字符数组,然后我们向这个字符数组中存入数据。我们在server进程中将共享内存这片区域当作存的一个大字符串,我们直接将这个空间中的字符串打印出来。
我们测试时发现当还没有执行client进程向共享内存中写数据时,执行server进程后该进程就已经开始打印共享内存中的数据了,并且打印的是空字符串,这是因为共享内存在被创建出来后,默认会将这片空间清为全0。
当执行了client进程后,client进程就开始向共享内存中存储数据了,然后server进程就可以读取到共享内存中client进程存储的数据了。
下面我们将client进程每5秒向共享内存中存储一次数据,最后向共享内存中存储quit,然后server每1秒向共享内存中读取数据,当server进程读取到quit就不再读取共享内存中的数据。可以看到向共享内存中写入和读取数据没有调用任何系统调用接口。
我们看到当client向共享内存中写入数据后,server进程马上就从共享内存中看到了client进程写入的数据。并且我们看到当client进程每5秒向共享内存中写入一次数据时,server每1秒都从共享内存中读取数据并打印出来,而对比我们之前的管道通信,当管道的写端每5秒写一次数据时,管道的读端也是每5秒读取一次数据,这是因为管道通信提供了访问控制,当管道中没有数据时读端就会进行阻塞式的等待,直到管道中有数据了再进行读取;当管道中写满数据时,写端就会进行阻塞式的等待,直到管道中的数据被清空。但是共享内存的通信方式我们看到并没有访问控制。
下面我们将client进程从键盘中读数据,然后直接将从键盘读取到的数据存入共享内存中,server进程还是从共享内存中读取数据并进行打印。
我们看到此时client输入数据,server就马上读取到数据。并且我们是直接将从键盘读取到的数据存入到了共享内存当中,然后server从共享内存中读取数据显示到显示器中。
看了上面的演示后我们再从数据拷贝的方面分析为什么共享内存的进程间通信方式是最快的。
下面是从键盘中获取数据存到buffer中,然后将数据从buffer中写入到管道文件,然后将数据从管道文件读取到buffer数组中,然后从buffer数组再到显示器的过程。可以看到数据经过了4次拷贝。
下面是从键盘中读取数据直接存到共享内存中,然后从共享内存中读取数据直接显示到显示器中,可以看到只发生了两次数据拷贝。所以共享内存是进程通信之中最快的。
我们通过分析知道了共享内存为什么是最快的进程间通信方式,但是共享内存没有访问控制,而缺乏访问控制,会带来并发问题。读取一方只需要读,写入一方只需要写入,读取和写入都不知道对方的存在,那么当写入一方退出时,读取一方也不知道,还是在读取共享内存中的数据。我们下面使用管道文件来进行一定程度上的访问控制,管道文件提供了访问控制,那么我们可以创建一个管道文件,在server进程从共享内存中读取数据之前,我们先进行管道文件的读取操作,我们知道如果管道文件中没有数据,那么进程server就会在这里阻塞式的等待管道文件的写端写入数据,这样我们就实现了阻塞server进程从共享内存中读取数据。然后我们在client进程向共享内存中写入数据之后,再进行一次向管道文件中写数据的操作,此时因为管道文件中有数据了,那么server进程就会被唤醒而读取管道文件和共享内存中的数据了,当管道文件中的数据被读取后,server进程又会阻塞式的等待管道文件中写入数据。这样我们就通过管道文件的访问控制让共享内存也具有了访问控制功能。
下面我们先写一个Init类,该类的构造函数中创建一个命名管道文件。该类的析构函数中将命名管道文件删除。然后我们在server程序的最开始创建一个全局的Init类实例化的对象init,那么在server进程刚运行时,就会执行这一句语句,然后就会实例化一个Init类类型的对象init,实例化init对象时会自动调用Init类的构造函数,所以就创建了命名管道文件。然后当server进程退出时,init对象销毁之前会自动调用Init类的析构函数将命名管道文件删除。
然后我们再定义这四个方法,以便在client进程和server进程中执行管道文件相关的操作。OpenFIFO函数就是打开命名管道文件;Wait其实就是从命名管道文件中读取数据,如果命名管道文件中没有数据,则这个函数就会阻塞式的等待命名管道文件中的数据更新。Signal函数就是向命名管道文件中写数据。CloseFifo函数就是关闭命名管道文件。Signal函数中向命名管道文件中写的数据是随意的,因为我们不是通过命名管道文件来进行进程间通信,只要Signal函数向命名管道文件中写一次数据即可,同理Wait函数中从管道文件中读取的数据也是没有用的,我们并不会用命名管道文件中的数据,只要读取命名管道文件中的数据让进程不再阻塞在Wait函数中就行。
然后我们在server进程的开始实例化一个init对象,并且在从共享内存中读取数据之前先调用Wait函数将server进程先进行一次命名管道文件的读操作,如果此时命名管道文件中没有数据,那么进程就会阻塞在这里,等待命名管道文件中的数据更新,而client进程中要想向命名管道文件中写入数据,必须先从键盘中获取数据到共享内存后,才会调用Signal函数进行向命名管道文件中写数据的操作。
下面是我们在client进程中的操作,即在向共享内存中存入数据之后,再调用Signal函数向命名管道文件中写入数据,即将server进程唤醒,进行一次从命名管道文件中读取数据和从共享内存中读取数据的操作。
我们看到当运行server进程和client进程后,server进程不会再自己打印空字符串,而是进行阻塞式的等待。
我们看到只有当client进程输入数据后,server进程才会被唤醒一次,然后将共享内存中的数据打印一次后又进入阻塞式等待的状态。当client进程输入quit后,两个进程都成功的退出,并且共享内存和命名管道文件都被成功的删除了。
三、消息队列
1、消息队列介绍
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法。
每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值。
特性方面:
IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核。
2、消息队列操作
当查看消息队列时使用下面的命令。
//查看消息队列
ipcs -q
当删除消息队列时使用下面的命令。
//删除消息队列
ipcrm -q msqid //消息队列的msqid
当查看信号量时使用下面的命令。
//查看信号量
ipcs -s
当删除信号量时使用下面的命令。
//删除信号量
ipcrm -q semid //信号量的semid
文章链接