深入理解 Linux 进程间通信
文章目录
- 一、进程间通信介绍
- 1. 目的
- 2. 发展
- 3. 分类
- 3.1 管道
- 3.2 System V IPC
- 3.3 POSIX IPC
- 4. 总结
- 二、管道
- 1. 什么是管道
- 2. 匿名管道
- 2.1 实例代码
- 2.2 用 fork 来共享管道原理
- 2.3 站在文件描述符角度 — 深度理解管道
- 2.4 站在内核角度 — 管道本质
- 2.5 修改实例代码
- 2.6 管道读写规则
- 2.7 管道特点
- 2.8 小思考
- 3. 命名管道
- 3.1 创建一个命名管道
- 3.2 匿名管道与命名管道的区别
- 3.3 命名管道的打开规则
- 3.4 用命名管道实现 server&client 通信
- 三、System V 共享内存
- 1. 共享内存的原理
- 2. 共享内存示意图
- 3. 共享内存函数
- 3.1 shmget 函数
- 3.2 ftok 函数
- 3.3 key 和 shmid 的区别
- 3.4 shmat 函数
- 3.5 shmdt 函数
- 3.6 shmctl 函数
- 3.7 snprintf 函数
- 4. 实例代码
- 4.1 同步机制分析
- 5. 共享内存的特点
- 四、System V 消息队列
- 五、System V 信号量
- 1. 进程互斥
一、进程间通信介绍
1. 目的
什么是通信呢?
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
为什么要有通信???
- 有时候我们是需要多进程协同的,来完成某种业务内容!
2. 发展
- 管道:基于文件系统的,分为匿名管道和命名管道。
- System V 进程间通信:聚焦在本地通信。
- POSIX 进程间通信:让通信过程可以跨主机。
3. 分类
3.1 管道
- 匿名管道 pipe
- 命名管道
3.2 System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
3.3 POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
4. 总结
进程具有独立性!我们既然要通信,说明通信的成本一定不低!换言之,你要通信就要打破独立性!
如何理解通信的本质问题呢?
- OS 需要直接或者间接给通信双方的进程提供 “内存空间”。
- 要通信的进程,必须看到一份公共的资源。
该公共资源由 OS 提供,我们会学到不同的通信种类,本质就是:上面所说的 资源,是 OS 中的哪一个模块提供的!
那为什么说通信的成本不低呢?
- 首先,你需要先让不同的进程看到同一份资源。
- 其次,再让它们进行通信。
二、管道
1. 什么是管道
管道是 Unix 中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个 “管道”。
如下图所示:
我们先来学习匿名管道。
2. 匿名管道
如下图所示,我们让两个进程能够看到同一份资源。只要能看到同一份资源,父进程就可以向对应的文件缓冲区里写入数据,而子进程则能够从文件缓冲区当中读取数据。
这不就是一个进程往文件里写数据,另一个进程从文件里读数据嘛。
如此一来,我们就相当于完成了父进程把数据传递给子进程的过程,这个过程其实就是进程间通信呀。
我们把通过使用文件的方式来实现父子进程之间通信的这种方式,对于操作系统所提供的内核及相应文件,称之为 管道文件。管道文件从本质上来说就是文件。
我们之前提到过,要是一个文件被打开了,简单来讲,就是从磁盘里把文件加载进来。
在加载进来的时候,对应的操作系统会为该文件创建 struct file 对象,并且和打开这个文件的进程建立起某种关联。
而在这时,如果我们往文件里写数据,那就需要把数据刷新到磁盘当中。
思考这样一个问题:当两个进程需要进行通信时,一种可行的方法是一个进程先将数据写入文件,再将文件刷新到磁盘,另一个进程再从磁盘中读取数据到内存,最后再将数据读取到自己的进程上下文中。这种方法虽然能够实现进程间的通信,但是在实际应用中,你会选择使用它吗?答案显然是否定的。
这是因为进程间通信所涉及的文件和数据并不需要刷新到磁盘,将数据刷新到磁盘反而会大大降低通信的速度。既然如此,我们能不能不访问磁盘,直接在内核中创建struct file对象,并为其申请访问区呢?答案是肯定的。操作系统完全可以做到这一点,而且实现起来并不复杂。我们这里所说的管道,就是一种内存级文件。
简单来说,管道并不关心文件在磁盘上的具体路径,也不关心是否会被写入磁盘。它只需要在内核中创建 struct file 对象,为其创建对应的缓冲区,然后将该对象的地址填入打开该文件的进程的文件描述表中,这样进程就可以看到这个文件了。同样的方法,当父进程通过 fork 创建子进程时,子进程会继承父进程的文件描述表,从而也能够看到同一个文件。这样,父子进程就可以基于这个内存级文件进行通信了。
接下来的问题是,如何让两个进程看到同一个管道文件呢?具体的做法是,父进程先打开这个文件,然后通过 fork 创建子进程。由于子进程会继承父进程文件描述符表中的内容,这样一来,父子进程就都能够看到同一个文件了。
我们通过父进程创建子进程,并让子进程继承文件地址的方式,使得两个进程能够看到同一个管道文件。由于这个文件是内存级文件,没有具体的名称,因此我们称之为 匿名管道。
函数原型
#include <unistd.h>int pipe(int pipefd[2]);
参数
pipefd[2]
:输出型参数,用于存储管道的文件描述符:pipefd[0]
:管道的读端(read end),用于从管道读取数据。pipefd[1]
:管道的写端(write end),用于向管道写入数据。
返回值
- 成功:返回
0
,并通过pipefd
输出两个文件描述符。 - 失败:返回
-1
,并设置errno
表示错误原因(如 EMFILE 表示进程已达到文件描述符上限)。
如图所示:
2.1 实例代码
匿名管道目前能用来进行父子进程之间进行进程间通信!
代码示例
#include <iostream>
#include <string>
#include <vector>
#include <unistd.h>
#include <cassert>using namespace std;int main()
{// 第一步:创建管道文件,打开读写端int fds[2];int n = pipe(fds);assert(n == 0);// [0]:读取(把0想象成嘴巴)// [1]:写入(把1想象成钢笔)cout << "fds[0]: " << fds[0] << endl; // 3cout << "fds[1]: " << fds[1] << endl; // 4return 0;
}
运行结果:
代码示例
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <cassert>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;// 父进程进行读取, 子进程进行写入
int main()
{// 第一步:创建管道文件,打开读写端int fds[2];int n = pipe(fds);assert(n == 0);// 第二步:forkpid_t id = fork();assert(id >= 0);if (0 == id){// 子进程进行写入, 关闭fd[0]close(fds[0]);// 子进程的通信代码const char *s = "我是子进程, 我正在给你发消息";int cnt = 0;while (true) // 让子进程不断的去给父进程写入{cnt++;char buffer[1024]; // 目前缓冲区的数据只有子进程能看到snprintf(buffer, sizeof buffer, "child-->parent say: %s[%d][%d]", s, cnt, getpid());// 往文件写入// 不需要strlen(buffer)+1, 向文件当中写入时'\0'时,只有你C语言认,我们今天使用这个接口的时候不考虑write(fds[1], buffer, strlen(buffer)); sleep(1); // 我每隔1s 写一次}// 子进程退出以后, 那么打开的相关的文件描述符也一定会被自动关闭的close(fds[1]);exit(0);}// 父进程进行读取, 关闭fd[1]写入close(fds[1]);// 父进程的通信代码while (true){char buffer[1024];// 读取到的字符串放到buffer中// 读取到的信息我们把它当成字符串来处理, 故减1ssize_t s = read(fds[0], buffer, sizeof(buffer)-1);if (s > 0)buffer[s] = 0;cout << "Get message# " << buffer << " | 我是父进程,我正在接收消息[" << getpid() << "]" << endl;// 细节: 父进程没有进行sleep}// 等待n = waitpid(id, nullptr, 0);assert(n == id);close(fds[0]);return 0;
}
运行结果
在上述代码里,对应的子进程是没有任何输出的。也就是说,子进程并不会往显示器上输出任何消息,它根本就没有写入任何消息,而父进程则负责读取消息,并将读取到的消息提取出来。像这样的通信方式,我们称之为管道通信。
2.2 用 fork 来共享管道原理
如下图所示:
我们之前提到过,write
函数属于系统调用,简单来讲,它的作用就是把数据拷贝到内核里面;而 read
函数则是用于从内核当中读取数据。
就像图中【fork 之后各自关掉不用的描述符】所展示的那样,子进程进行写入操作时,实际上就是通过 write
函数将数据写入了管道,换个说法,也就是把数据写给了操作系统。
之后呢,父进程会借助对应的操作系统,把数据读取到父进程自身的上下文空间当中。
那为什么我们要调用 read
或者 write
接口呢?这是因为这两个接口本身就是系统调用呀。
这也进一步证明了,两个进程之间要想进行通信,首要的前提条件就是得让它们能够看到同一份资源。而这份资源呢,虽然是基于文件的,但归根结底,它仍然是操作系统直接或者间接为我们提供的内存空间。
2.3 站在文件描述符角度 — 深度理解管道
图 1:父进程创建管道
我们可以通过,分别以读和写这两种方式去打开同一个文件。
内存级文件支持以读的方式打开,也支持以写的方式打开,而且在打开操作完成后,会分别向你返回对应的读文件描述符和写文件描述符。
如此一来,我们只需进行一次这样的打开操作调用,就能直接获取到这两个不同用途的文件描述符了。
图 2:父进程 fork 出子进程
父进程是以读写的方式打开与之对应的管道的,而子进程同样能够以读写的方式看到这同一个管道。
这是因为子进程会继承父进程所能看到的文件,并且也能知晓对该文件的访问方式。原因其实并不复杂,当我们对这个文件进行操作时,实际上可以通过特定的文件描述符来标定其读写方式,比如文件描述符为 3 时表示读,为 4 时表示写。
总结一下就是,它之所以被称作管道,肯定是由于其原本的技术设计就符合管道所具备的特点,所以我们才将它命名为管道,而并非是先想要设计一个管道,然后再去实现相应的技术来打造它。
图 3:父进程关闭 fd[0]
,子进程关闭 fd[1]
通常情况下,我们所使用的管道仅仅能够用于单向的数据通信。
所以,到了第三步的时候,我们就需要把父子进程中那些它们并不需要关注的文件描述符关闭掉。
父子进程都是具备对文件进行读写操作的能力的。我们可以安排父进程负责写入数据,让子进程负责读取数据。既然父进程负责写入,那么我们就把父进程这边的读端关闭掉;而子进程负责读取,我们就关闭子进程的写端。
通过这样的操作,我们就能构建出一条从父进程通向子进程的信道,进而完成一个通信的过程。
当然了,反过来操作也是可行的,也就是让子进程负责写入数据,父进程负责读取数据。
2.4 站在内核角度 — 管道本质
为什么父进程必须要以读和写这两种方式,同时去打开对应的一个文件呢?
原因其实很简单,要是父进程只采用读或者只采用写的方式去打开文件,那么子进程在继承的时候,就只能继承到读端或者写端了。而父子双方打开文件的方式是一样的话,就没办法满足单向通信的需求了。
所以,只有父进程以读写方式打开文件,相应的读写文件描述符才会被子进程继承下来,之后,我们再根据实际需要选择具体的通信方向,把特定的文件描述符关闭就可以了。
由此可见,以读写方式打开文件,本质上就是为了让子进程也能够看到读写两端,进而方便我们后续可以自由地去选择通信方向。
如下图所示:
简单来讲,管道就是 父进程通过调用特定的管道系统调用,以读和写这两种方式打开一个内存级文件,然后借助通过 fork
创建子进程的方式,让子进程继承相关的文件描述符,之后父子进程各自关闭对应的读写端,这样就形成了一条通信信道。由于这条通信信道是基于文件构建的,所以我们把它称作管道。
所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了 “Linux一切皆文件思想”。
2.5 修改实例代码
我们上面的代码是子进程每 1 秒写一次,那如果我改成每 10 秒写一次,父进程在读取完一次之后,又在干什么呢?
代码示例
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <cassert>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;// 父进程进行读取, 子进程进行写入
int main()
{// 第一步:创建管道文件,打开读写端int fds[2];int n = pipe(fds);assert(n == 0);// 第二步:forkpid_t id = fork();assert(id >= 0);if (0 == id){// 子进程进行写入, 关闭fd[0]close(fds[0]);// 子进程的通信代码const char *s = "我是子进程, 我正在给你发消息";int cnt = 0;while (true) // 让子进程不断的去给父进程写入{cnt++;char buffer[1024]; // 目前缓冲区的数据只有子进程能看到snprintf(buffer, sizeof buffer, "child-->parent say: %s[%d][%d]", s, cnt, getpid());// 往文件写入// 不需要strlen(buffer)+1, 向文件当中写入时'\0'时,只有你C语言认,我们今天使用这个接口的时候不考虑write(fds[1], buffer, strlen(buffer)); sleep(10); // 我每隔10s 写一次}// 子进程退出以后, 那么打开的相关的文件描述符也一定会被自动关闭的close(fds[1]);exit(0);}// 父进程进行读取, 关闭fd[1]写入close(fds[1]);// 父进程的通信代码while (true){char buffer[1024];cout << "AAAAAAAAAAAAAAAAAAAAA" << endl;ssize_t s = read(fds[0], buffer, sizeof(buffer)-1);cout << "BBBBBBBBBBBBBBBBBBBBB" << endl;if (s > 0)buffer[s] = 0;cout << "Get message# " << buffer << " | 我是父进程,我正在接收消息[" << getpid() << "]" << endl;}// 等待n = waitpid(id, nullptr, 0);assert(n == id);close(fds[0]);return 0;
}
运行结果
我们可以发现,要是管道里面已经没有数据了,而读端却还在进行读取操作的话,默认情况下,就会直接阻塞当前正在执行读取操作的进程。
所谓的阻塞等待,简单来说,就是让当前的父进程暂停运行,将它的状态从 “R(运行)” 状态变更为 “S(睡眠)” 状态,然后把它放置到等待的地方,这个时候它就在等待文件中有新的数据出现。
所以呢,在文件内部必然也会存在类似 等待队列 这样的一种结构,如此一来,我们就可以把进程的 PCB(进程控制块)放进这个等待队列当中。等到缓冲区被写入数据,有了新的数据之后,操作系统一旦识别到这种情况,就会把这个进程的 PCB 从等待队列中取出来,再放入到运行队列里面,同时将其状态从对应的 “S” 状态改回 “R” 状态,这样该进程就能够接着被系统调度,继续运行了。
那如果我们让子进程不断去写,让父进程别读取,会有什么现象呢?
代码示例
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <cassert>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;// 父进程进行读取, 子进程进行写入
int main()
{// 第一步:创建管道文件,打开读写端int fds[2];int n = pipe(fds);assert(n == 0);// 第二步:forkpid_t id = fork();assert(id >= 0);if (0 == id){// 子进程进行写入, 关闭fd[0]close(fds[0]);// 子进程的通信代码const char *s = "我是子进程, 我正在给你发消息";int cnt = 0;while (true) // 让子进程不断的去给父进程写入{cnt++;char buffer[1024]; // 目前缓冲区的数据只有子进程能看到snprintf(buffer, sizeof buffer, "child-->parent say: %s[%d][%d]", s, cnt, getpid());// 往文件写入write(fds[1], buffer, strlen(buffer)); cout << "count: " << cnt << endl;}// 子进程退出以后, 那么打开的相关的文件描述符也一定会被自动关闭的close(fds[1]);exit(0);}// 父进程进行读取, 关闭fd[1]写入close(fds[1]);// 父进程的通信代码while (true){sleep(1000); char buffer[1024];ssize_t s = read(fds[0], buffer, sizeof(buffer)-1);if (s > 0)buffer[s] = 0;cout << "Get message# " << buffer << " | 我是父进程,我正在接收消息[" << getpid() << "]" << endl;}// 等待n = waitpid(id, nullptr, 0);assert(n == id);close(fds[0]);return 0;
}
运行结果
我们可以发现,管道其实就是一个有着固定大小的缓冲区。当这个缓冲区被写满了的时候,如果还继续往里写数据,那么写操作就会被阻塞。简单来讲,出现这种情况时,就需要等待对方进行读取操作,腾出缓冲区的空间后才能继续写入。
要是我一次性往里面写入了大量的数据,之后进行统一读取时,它会一次性把在缓冲区内能够读取到的所有数据都读取出来。
我现在让子进程写一次就退出来,然后关闭写端,父进程读完一遍以后,会读到 0
代码示例
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <cassert>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;int main()
{// 第一步:创建管道文件,打开读写端int fds[2];int n = pipe(fds);assert(n == 0);// 第二步:forkpid_t id = fork();assert(id >= 0);if (0 == id){// 子进程进行写入, 关闭fd[0]close(fds[0]);// 子进程的通信代码const char *s = "我是子进程, 我正在给你发消息";int cnt = 0;while (true) // 让子进程不断的去给父进程写入{cnt++;char buffer[1024]; // 目前缓冲区的数据只有子进程能看到snprintf(buffer, sizeof buffer, "child-->parent say: %s[%d][%d]", s, cnt, getpid());// 往文件写入write(fds[1], buffer, strlen(buffer));cout << "count: " << cnt << endl;break; // 写一次就退出来}// 子进程退出以后, 那么打开的相关的文件描述符也一定会被自动关闭的close(fds[1]);cout << "子进程关闭自己的写端" << endl;exit(0);}// 父进程进行读取, 关闭fd[1]写入close(fds[1]);// 父进程的通信代码while (true){sleep(2);char buffer[1024];ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1);if (s > 0){buffer[s] = 0;cout << "Get message# " << buffer << " | 我是父进程,我正在接收消息[" << getpid() << "]" << endl;}else // s == 0{// 读到文件结尾cout << "read: " << s << endl;break;}}// 等待子进程退出n = waitpid(id, nullptr, 0);assert(n == id);close(fds[0]);return 0;
}
运行结果
可以看到,子进程把写端关闭了,那么父进程最终会把管道里剩余数据读完。如果父进程再去读的话,就会读到 0,那么父子双方就会和谐退出。
再来思考一下:如果读端关闭,那么写端会怎么样呢?
很简单,因为读端已经关闭了,所以再写的话不就是浪费操作系统的资源吗?所以对于这种情况,OS 会给进程发送信号,终止写端!
代码示例
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <cassert>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;int main()
{// 第一步:创建管道文件,打开读写端int fds[2];int n = pipe(fds);assert(n == 0);// 第二步:forkpid_t id = fork();assert(id >= 0);if (0 == id){// 子进程进行写入, 关闭fd[0]close(fds[0]);// 子进程的通信代码const char *s = "我是子进程, 我正在给你发消息";int cnt = 0;while (true) // 让子进程不断的去给父进程写入{cnt++;char buffer[1024]; // 目前缓冲区的数据只有子进程能看到snprintf(buffer, sizeof buffer, "child-->parent say: %s[%d][%d]", s, cnt, getpid());// 往文件写入write(fds[1], buffer, strlen(buffer));cout << "count: " << cnt << endl;}// 子进程退出以后, 那么打开的相关的文件描述符也一定会被自动关闭的close(fds[1]);cout << "子进程关闭自己的写端" << endl;exit(0);}// 父进程进行读取, 关闭fd[1]写入close(fds[1]);// 父进程的通信代码while (true){sleep(1);char buffer[1024];ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1);if (s > 0){buffer[s] = 0;cout << "Get message# " << buffer << " | 我是父进程,我正在接收消息[" << getpid() << "]" << endl;}else{// 读到文件结尾cout << "read: " << s << endl;break;}break;}close(fds[0]);cout << "父进程关闭读端" << endl;// 等待子进程退出int status = 0;n = waitpid(id, &status, 0);assert(n == id);cout << "pid->" << n << " : " << (status & 0x7F) << endl;return 0;
}
运行结果
可以看到,当我们对应的进行非法写入的时候,操作系统会直接给写进程发送 13 号信号,称之为 single pipe
来终止对应的写入进程。
2.6 管道读写规则
当没有数据可读时:
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 将不再保证写入的原子性。
2.7 管道特点
- 一般而言,进程退出,管道释放,所以管道的生命周期随进程一样。
- 管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用
fork
,此后父、子进程之间就可应用该管道。 - 管道提供流式服务,即面向字节流的。
- 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
- 一般而言,内核会对管道操作进行同步与互斥,是一种对共享资源进行保护的方案。
比如,利用两个管道实现双向通信,如下图所示:
2.8 小思考
在 Linux 下,我们写过下面这样一条命令
sleep 10000 | sleep 20000
实际上,这条指令如果要我们自己去实现的话,命令行解释器会先对这个字符串进行扫描,在扫描的过程中,它会查看是否存在竖划线。一旦发现有竖划线,就会把这条命令按照竖划线的位置拆分成左右两侧的部分,然后分别创建子进程去执行左右两侧对应的命令。
而且,在通过 fork
创建子进程之前,会先使用管道把这两个子进程连接起来,这样一来,这两个子进程就都能够访问同一个管道了,进而它们之间便可以借助这个管道来进行通信。
对于这个管道来说,左侧的进程(sleep 10000
)会把它所有的输出结果都写入到管道里面,这就相当于进行了标准输出重定向到管道文件的操作。而右侧的进程(sleep 20000
)呢,则是从管道当中把数据读取进来,也就是从标准输入读取管道文件的操作。
那么,这条命令里的竖划线,我们可以把它称作 “管道”,不过严格意义上来说,它应该叫做 “匿名管道”。
所以,现在是不是豁然开朗?
3. 命名管道
回顾一下:
由于进程具有独立性,这就导致进程中的很多资源相互之间是无法被看到的。那么,要是想让不同的进程实现通信,首要的前提就是得先让这些不同的进程能够看到同一份资源。换种说法来讲,让不同的进程看到同一份资源,这是进行进程间通信的必要前提条件。
其次,当进程已经能够看到公共资源之后,接下来面临的问题就是如何把数据从 A 进程存放到公共资源当中,然后再从公共资源里将数据取回,放入 B 进程。
管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
如果我们想在不相关的进程之间交换数据,可以使用 FIFO 文件来做这项工作,它经常被称为 命名管道。
命名管道是一种特殊类型的文件。
3.1 创建一个命名管道
命名管道可以从命令行上创建,命令行方法是使用下面这个命令
mkfifo filename
运行结果
以 p
开头的文件叫做管道,它最大的特点就是:两个进程,一个向管道文件里去写入,另一个呢可以从管道文件做读取,我们就可以直接完成进程间通信。
我们可以编写一个脚本
cnt=0; while :; do echo "hello world -> $cnt"; let cnt++; sleep 1; done > named_pipe
此时另外一个终端就可以看到变化了
说白了,就是下边进程它把自己输出的所有的结果重定向到这个 named_pipe
管道文件里,而上边这个 cat
命令跑起来,它也变成进程了,所以它就会从这个管道文件里再把数据读出来,并且给我们打印到终端。此时,我们就完成了命令行式的俩进程之间通信。
命名管道也可以从程序里创建,相关函数有:
#include <sys/stat.h>int mkfifo(const char *filename, mode_t mode);
参数
filename
:必选参数,用于指定要创建的命名管道的路径名(例如:"/tmp/myfifo"
)。mode
:必选参数,用于指定创建的命名管道的访问权限(例如:0666
表示所有用户都有读写权限)。
返回值
- 成功:返回
0
,表示命名管道创建成功。 - 失败:返回
-1
,并设置errno
表示错误原因(如EEXIST
表示文件已存在,EACCES
表示权限不足)。
创建命名管道:
int main()
{mkfifo("./p2", 0644);return 0;
}
如下图所示,这里有一个普通文件,也就是我们所说的 named_pipe
。当进程 1 打开这个文件时,系统会把它加载到内存里,并且会随之创建出 struct file
文件对象。
接着,进程 2 也来打开同一个文件 named_pipe
。那么从操作系统的角度来考虑,在进程 2 打开这个文件的时候,是否还需要在内核当中再次为其重新创建 struct file
结构体对象呢?
答案是不需要这么做,因为即便创建了也没有实际意义。
操作系统在资源管理方面是很 “精打细算” 的。当不同的进程打开同一个名为 named_pipe
的文件时,如果每次都要重新创建该文件的属性,还要去维护相应的缓冲区等,这无疑会造成资源浪费。所以,操作系统具备识别能力,当它察觉到某个文件已经被打开了,就不会再重复进行创建相关对象的操作了。
而且,此时如果操作系统判定打开的这个目标文件是个管道文件,那么此后任何进程往这个文件里写入的内容,都不会被刷新到磁盘上,而是会存放在内存里,以供另一个进程去读取。这不就和我们之前所讲的命名管道的特性一模一样嘛,它不需要进行常规的输入输出(IO)操作。
那么命名管道究竟是怎样让不同的进程看到同一份资源的呢?
实际上,不同的进程可以通过打开指定名称(路径 + 文件名)的同一个文件来实现这一点。这是因为路径和文件名的组合具有唯一性。
匿名管道没有名字,它是通过继承的方式来标定文件的唯一性的。struct file
作为一个内核级对象,本质上是通过 malloc
分配的内存空间,每个 struct file
对象都有唯一的地址,只是这个地址没有名字,所以称为匿名管道。
而命名管道之所以要命名,正是通过名字来标定其唯一性的。
3.2 匿名管道与命名管道的区别
- 匿名管道由
pipe
函数创建并打开。 - 命名管道由
mkfifo
函数创建,打开用open
。 - FIFO(命名管道)与 pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。
3.3 命名管道的打开规则
如果当前打开操作是为 读 而打开 FIFO
时:
O_NONBLOCK disable
:阻塞直到有相应进程为写而打开该FIFO
。O_NONBLOCK enable
:立刻返回成功。
如果当前打开操作是为 写 而打开 FIFO
时:
O_NONBLOCK disable
:阻塞直到有相应进程为读而打开该FIFO
。O_NONBLOCK enable
:立刻返回失败,错误码为ENXIO
。
3.4 用命名管道实现 server&client 通信
如下图所示,这三个文件实现了一个基于命名管道(Named Pipe,也称为 FIFO)的进程间通信(IPC)系统。
Makefile
代码如下
.PHONY:all
all:server clientserver:server.cppg++ -o $@ $^ -std=c++11 -g
client:client.cppg++ -o $@ $^ -std=c++11 -g.PHONY:clean
clean:rm -f server client
comm.hpp
- 公共头文件
#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <unistd.h>using namespace std;// 把这个管道建立在/tmp目录下
#define NAMED_PIPE "/tmp/mypipe.88"// 创建管道文件
bool createFifo(const string &path)
{umask(0);int n = mkfifo(path.c_str(), 0666);if (n == 0) // 创建管道文件成功{return true;}else // 创建管道文件失败{cout << "errno: " << errno << " err string: " << strerror(errno) << endl;return false; }
}// 删除管道文件
void removeFifo(const string &path)
{int n = unlink(path.c_str());assert(n == 0); // 移除成功为0(void)n;
}
功能:
- 定义了命名管道的路径(
/tmp/mypipe.88
) - 提供了创建和删除命名管道的工具函数
- 包含必要的系统头文件(如
sys/stat.h
、fcntl.h
等)
server.cpp
- 服务器端程序
#include "comm.hpp"// server进行管道的管理
// server从文件中读取int main()
{// 创建管道bool ret = createFifo(NAMED_PIPE);assert(ret);(void)ret;// 开始通信, 打开文件 cout << "Server begin" << endl;int rfd = open(NAMED_PIPE, O_RDONLY); // 以读方式打开cout << "Server end" << endl;if (rfd < 0){exit(1);}// readchar buffer[1024];while (true){ssize_t s = read(rfd, buffer, sizeof(buffer)-1);if (s > 0) // 读取成功{buffer[s] = 0;cout << "Client ---> Server# " << buffer << endl;}else if (s == 0){cout << "Client quit, me too!" << endl;break;}else // 读取出错了{cout << "error string: " << strerror(errno) << endl;break;}}close(rfd);// 删除管道// sleep(10); removeFifo(NAMED_PIPE);return 0;
}
功能:
- 创建命名管道(如果不存在)
- 以只读模式打开管道,等待客户端连接
- 持续读取客户端发送的消息并打印到控制台
- 当客户端关闭连接时(读取返回 0),自动退出并清理管道文件
client.cpp
- 客户端程序
#include "comm.hpp"// client也打开同一份文件
// client 向文件中写入int main()
{// 开始通信, 打开文件 cout << "Cerver begin" << endl;int wfd = open(NAMED_PIPE, O_WRONLY); // 以写方式打开cout << "Cerver end" << endl;if (wfd < 0){exit(1);}// writechar buffer[1024];while (true){cout << "Please Say# ";// 按行读取fgets(buffer, sizeof(buffer)-1, stdin);// hello\n --> helloif (strlen(buffer) > 0){buffer[strlen(buffer) - 1] = 0; //把字符串末尾的字符去除, 一般是用于删除换行符}ssize_t n = write(wfd, buffer, strlen(buffer));assert(n);(void)n;}close(wfd);return 0;
}
功能:
- 以只写模式打开已存在的命名管道
- 从标准输入(键盘)读取用户输入
- 将用户输入的每一行数据发送到服务器
- 客户端退出时,自动关闭管道连接(但不删除管道文件)
运行结果如下:
三、System V 共享内存
共享内存区是最快的 IPC 形式。
一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。
1. 共享内存的原理
如图所示,其原理是进程先将磁盘中的数据读取到内存里,然后开展相关操作。
我们现在有两个进程,它们涉及到的相关结构有【task_struct】【进程地址空间】【页表】【物理内存】。
这时会发现,当 CPU 对进程 1 或者进程 2 进行调度时,由于它们的数据结构是相互独立的,代码和数据也各自独立,所以彼此之间并不会产生任何相互干扰的情况,而这正是进程具有独立性的原因所在。
不过,在此情况下需要做三件工作:
第一件工作(申请一块空间,TODO):为了能让这两个原本毫无关联的进程实现通信,操作系统会在对应的内存中申请一块空间。需要注意的是,这块空间的申请并非由操作系统主动进行,而是操作系统向用户提供一些系统调用,由用户驱使操作系统帮忙申请一块内存空间。
第二件工作(将创建好的内存映射到进程的地址空间):进程 1 调用操作系统为其设置的接口,通过页表把申请好的物理内存空间映射到自身对应的进程地址空间里的某一段区域,然后将该区域的起始地址返回给用户,如此一来,用户便能通过访问这个起始地址的方式来访问这块内存了。这就如同我们调用 malloc 或者 new 在物理内存上申请一块堆空间,并将其映射到自己的堆区是一样的道理。
同样地,我们也可以依照上述方式,把创建好的内存映射到进程2的地址空间,然后将这个虚拟空间的起始地址返回给进程2对应的上层用户。
经过上述第一步和第二步操作后,就能够让不同的两个进程看到同一份资源了。
第三件工作:要是之后进程 1 和进程 2 不想再通信了,那么首先要取消进程和内存之间的映射关系,然后释放掉这块内存。
对于我们来说,把第一件工作中申请的这块空间称作“共享内存”;把进程和共享内存建立映射关系的工作叫做“进程和共享内存进行挂接”;把取消进程和内存映射关系的工作称之为“去关联”;而把释放内存的工作叫做“释放共享内存”。
针对上述这套模型,还需要理解以下几个要点:
- a、进程间通信是专门设计用来进行 IPC(进程间通信)的。
- b、共享内存是一种通信方式,所有有通信需求的进程都可以使用。
- c、在操作系统中,很可能会同时存在许多共享内存。
2. 共享内存示意图
让不同的进程能够看到同一个内存块的方式,就叫做共享内存。
如下图所示:
3. 共享内存函数
我们要首先按照刚才讲的原理,第一步是不是得先创建出一份内存?第二步,然后再把这个内存和我们不同的进程再关联起来,所以如何创建出来一个内存呢?
3.1 shmget 函数
函数定义
#include <sys/ipc.h>
#include <sys/shm.h>int shmget(key_t key, size_t size, int shmflg);
参数说明
- key:共享内存段的键值,用于标识共享内存。可以用
ftok()
生成,或设为IPC_PRIVATE
创建私有段。 - size:请求的共享内存大小(字节)。若内存已存在,此参数会被忽略,但通常需设为非零值。
- shmflg:标志位,包含权限和控制标志:
- 权限位:如
0666
表示读写权限。 - 控制标志:
IPC_CREAT
:若不存在则创建,若存在则获取。IPC_EXCL
:与IPC_CREAT
联用,若已存在则报错;若不存在则创建。
- 权限位:如
返回值
- 成功:返回共享内存标识符(非负整数)。
- 失败:返回
-1
,并设置errno
(如EEXIST
、EACCES
等)。
这里我们需要使用 ftok
函数来形成上面的 key。
3.2 ftok 函数
ftok()
是 Linux 系统中用于生成共享内存、消息队列等 IPC(进程间通信)机制所需键值(key_t 类型)的函数。
函数定义
#include <sys/types.h>
#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id);
参数说明
- pathname:
- 必须是已存在的文件或目录的路径(需有访问权限)。
- 函数会根据该文件的 inode 编号 和 设备 ID 来生成键值。
- proj_id:
- 一个非零整数(建议使用单字符,如 ‘a’)。
- 用于区分不同的 IPC 对象(同一文件路径配合不同 proj_id 可生成不同键值)。
返回值
- 成功:返回生成的 key_t 类型键值(通常是整数)。
- 失败:返回 (key_t) -1,并设置 errno(如 EACCES、ENOENT 等)。
3.3 key 和 shmid 的区别
那么,key 和 shmid 之间存在着怎样的区别呢?编写如下代码
#include <iostream>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>// key_t ftok(const char *pathname, int proj_id);
#define PATHNAME "." // 文件路径
#define PROJ_ID 0x66 // 项目标识符
#define MAX_SIZE 4096key_t getKey()
{key_t k = ftok(PATHNAME, PROJ_ID);if (k < 0){ // cin --> stdin ---> 0// cout --> stdout ---> 1// cerr --> stderr ---> 2std::cerr << errno << " : " << strerror(errno) << std::endl;exit(1); // 终止进程}return k;
}int getShmHelper(key_t k, int flags)
{// k是要通过shmget调用设置进共享内存属性中的!// 用来标识该共享内存在内核中的唯一性!// shmid 和 key 的区别// 相当于 fd 和 inode 的区别int shmid = shmget(k, MAX_SIZE, flags); // 创建共享内存if (shmid < 0) // 如果创建失败{std::cerr << errno << " : " << strerror(errno) << std::endl; // 输出失败的原因exit(2);}return shmid;
}// 获取共享内存
int getShm(key_t k)
{return getShmHelper(k, IPC_CREAT);
}// 创建共享内存
int createShm(key_t k)
{return getShmHelper(k, IPC_CREAT | IPC_EXCL | 0600); // 0600 --> 拥有者具有读写权限, 其他人没有
}// server端
void server()
{key_t k = getKey();printf("server's key: 0x%x\n", k); // keyint shmid = createShm(k);printf("server's shmid: %d\n", shmid); // shmid
}// client端
void client()
{key_t k = getKey();printf("client's key: 0x%x\n", k); // keyint shmid = getShm(k);printf("client's shmid: %d\n", shmid); // shmid
}
int main()
{server();client();return 0;
}
运行结果如下,可以看到 server
端和 client
端的 key 与 shmid 都是一样的。
这个 key 具体是多少并不重要,关键在于它能够起到唯一性标识的作用,这才是最重要的!
共享内存同样需要被操作系统管理起来,管理的方式是 “先描述再组织”。
具体来说,共享内存其实是由物理内存块以及共享内存的相关属性共同构成的。当你申请共享内存的时候,实际上操作系统除了帮你申请相应的内存之外,还会为共享内存申请一个数据结构对象。要是你申请了 10 个共享内存,那么相应地就会有 10 个共享内存对象。
所以,操作系统要对所有的共享内存进行管理,不过它管理的并非是内存块本身,而是把与各个共享内存块对应的对象逐个管理起来,也就是采用列表的形式来管理它们。这样一来,最终当你申请一个空间、释放一个空间或者查找一个空间的时候,就变成了针对这个共享内存所对应的特定数据结构对象所形成的数据结构去做相应的增删查改操作了。
在创建共享内存的时候,怎样确保共享内存在操作系统(OS)中是唯一的呢?方法很简单,我们直接使用 key 来进行标识就可以了。
那又如何保证对应的客户端另一方也能够访问到同样的共享内存呢?也很简单,只要另一个进程同样能看到这个 key 就行。
还有最后一个问题:这个 key 在操作系统中处于什么位置呢?
其实,这个 key 是在我们创建共享内存,描述共享内存相关属性的时候,其中的一个字段就是 key。
比如说在内核当中,有一个叫做 struct_shm
的数据结构,它里面包含了很多字段,其中就有一个字段是 key。
struct_shm
{key_t key;
}
所以,当我们在上层调用 ftlok
创建一个 key 之后,紧接着再调用 shmget
来创建共享内存(像这样:shmget(k, MAX_SIZE, flags)
,把这个 key 传进去),从本质上来说,就是把我们对应的这个 key 设置进了创建好的共享内存中它所对应的某一个属性当中。
而后,当另一个人去获取共享内存的时候,他并不是去查找这个共享内存的物理内存块,而是去遍历这个共享内存所对应的相关属性,查看他所带来的 key 和共享内存的 key 是否相等。
这就是 key 的含义所在了!
总结起来就是一句话:这个 key 被创建出来之后,后续是要被设置进共享内存对应的描述属性当中的。
接着,可以使用 ipcs -m
查看共享内存资源:
使用 ipcrm -m [shmid]
可以删除共享内存资源:
3.4 shmat 函数
功能:将共享内存段连接到进程地址空间。
函数原型
#include <sys/types.h>
#include <sys/shm.h>void *shmat(int shmid, const void *shmaddr, int shmflg);
参数:
- shmid:共享内存标识
- shmaddr:指定连接的地址
- shmflg:它的两个可能取值是
SHM_RND
和SHM_RDONLY
返回值:
- 成功返回一个指针,指向共享内存第一个节;
- 失败返回 -1
3.5 shmdt 函数
功能:将共享内存段与当前进程脱离。
原型:
#include <sys/types.h>
#include <sys/shm.h>int shmdt(const void *shmaddr);
参数:shmaddr
由 shmat
所返回的指针。
返回值:成功返回 0;失败返回 -1。
注意:将共享内存段与当前进程脱离不等于删除共享内存段。
3.6 shmctl 函数
功能:用于控制共享内存。
原型:
#include <sys/ipc.h>
#include <sys/shm.h>int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:
- shmid:由 shmget 返回的共享内存标识码。
- cmd:将要采取的动作(有三个可取值)
- buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回 0;失败返回 -1。
3.7 snprintf 函数
这里需要说一下 snprintf 接口。
snprintf()
是 C 语言中用于格式化字符串输出的安全函数,它将格式化数据写入指定缓冲区,并确保不会导致缓冲区溢出。
函数定义
#include <stdio.h>int snprintf(char *str, size_t size, const char *format, ...);
参数说明
str
:目标缓冲区,用于存储格式化后的字符串。size
:- 缓冲区的最大容量(包括终止符
'\0'
)。 - 如果格式化后的字符串长度超过
size-1
,则会截断并以'\0'
结尾。
- 缓冲区的最大容量(包括终止符
format
:格式化字符串,包含普通字符和格式说明符(如%d
、%s
等)。...
:可变参数列表,与format
中的格式说明符对应。
返回值:
- 成功:返回原本应该写入的字符串长度(不包含终止符),无论是否发生截断。
- 若返回值 ≥ size,说明发生了截断。
- 失败:返回负数(极少发生,通常由无效参数导致)。
代码分析:正常情况下,需要写成下面这样
const char* message = "hello server, 我是二号进程, 正在和你通信!";
pid_t id = getpid(); // 二号进程的pid
int cnt = 1;
char buffer[1024];
while (true)
{// pid, cnt, message --> 发给客户端snprintf(buffer, sizeof(buffer), "%s[pid:%d][消息编号:%d]", message, id, cnt);memcpy(start, buffer, strlen(buffer)+1);
}
但是没必要,因为我们的共享内存本来就是当作字符串看待的 char *start = (char*)attachShm(shmid);
所以我们可以直接将我们对应要格式化的内容,直接写入到我们对应的共享内存当中。
snprintf(start, MAX_SIZE, "%s[pid:%d][消息编号:%d]", message, id, cnt);
此时,客户端它是直接把数据显示到共享内存,而服务端它也是直接将对应的共享内存当中的数据直接从共享内存里读取出来。
4. 实例代码
编写 Makefile
.PHONY:all
all:shm_client shm_servershm_client:shm_client.cppg++ -o $@ $^ -std=c++11
shm_server:shm_server.cppg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f shm_client shm_server
头文件 comm.hpp
#ifndef _COMM_HPP_
#define _COMM_HPP_#include <iostream>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>// key_t ftok(const char *pathname, int proj_id);
#define PATHNAME "." // 文件路径
#define PROJ_ID 0x66 // 项目标识符
#define MAX_SIZE 4096key_t getKey()
{key_t k = ftok(PATHNAME, PROJ_ID);if (k < 0){ // cin --> stdin ---> 0// cout --> stdout ---> 1// cerr --> stderr ---> 2std::cerr << errno << " : " << strerror(errno) << std::endl;exit(1); // 终止进程}return k;
}int getShmHelper(key_t k, int flags)
{// k是要通过shmget调用设置进共享内存属性中的!// 用来标识该共享内存在内核中的唯一性!// shmid 和 key 的区别// 相当于 fd 和 inode 的区别int shmid = shmget(k, MAX_SIZE, flags); // 创建共享内存if (shmid < 0) // 如果创建失败{std::cerr << errno << " : " << strerror(errno) << std::endl; // 输出失败的原因exit(2);}return shmid;
}// 获取共享内存
int getShm(key_t k)
{return getShmHelper(k, IPC_CREAT);
}// 创建共享内存
int createShm(key_t k)
{return getShmHelper(k, IPC_CREAT | IPC_EXCL | 0600); // 0600 --> 拥有者具有读写权限, 其他人没有
}// 把共享内存贴到程地址空间上
void *attachShm(int shmid)
{void *mem = shmat(shmid, nullptr, 0); // 64位系统指针大小占8字节(window一般是4字节)if ((long long)mem == -1L){std::cerr << "shmat:" << errno << " : " << strerror(errno) << std::endl; // 输出失败的原因exit(3); // }return mem;
}// 去掉进程和共享内存之间的映射关系
void detachShm(void* start)
{if (shmdt(start) == -1){std::cerr << "shmdt:" << errno << " : " << strerror(errno) << std::endl; // 输出失败的原因}
}// 删除共享内存
void delShm(int shmid)
{if (shmctl(shmid, IPC_RMID, nullptr) == -1){std::cerr << errno << " : " << strerror(errno) << std::endl; // 输出失败的原因}
}#endif
核心功能:提供获取、创建、连接、分离和删除共享内存的功能函数。
关键技术点:
- 使用 ftok 生成系统唯一的 key_t 标识符。
- 通过 shmget 创建或获取共享内存段,使用 IPC_CREAT 和 IPC_EXCL 确保唯一性。
- 使用 shmat 和 shmdt 实现共享内存的挂载和分离。
- 通过 shmctl 实现共享内存的控制,包括删除操作。
错误处理:对每个系统调用都进行了错误检查,并输出标准错误信息。
客户端程序 shm_client.cpp
(客户端负责向共享内存写入数据)
#include "comm.hpp"int main()
{// 1. 创建key_t k = getKey();printf("key: 0x%x\n", k); // keyint shmid = getShm(k);printf("shmid: %d\n", shmid); // shmid//sleep(5);// 2. 挂接关联char *start = (char*)attachShm(shmid);printf("attach success, address start: %p\n", start);// 3. 开始通信const char* message = "hello server, 我是二号进程, 正在和你通信!";pid_t id = getpid(); // 二号进程的pidint cnt = 1;while (true){ sleep(1); // 客户端每隔1s写一条// pid, cnt, message --> 发给客户端snprintf(start, MAX_SIZE, "%s[pid:%d][消息编号:%d]", message, id, cnt++); // 这个接口默认添加'\0'if (cnt == 10)break;}// 4. 去掉关联detachShm(start);printf("detach success\n");// client不需要删除return 0;
}
通信流程:
- 通过 getKey 获取共享内存键值
- 使用 getShm 获取共享内存标识符
- 通过 attachShm 挂载共享内存
- 循环写入消息,格式为消息内容[pid:进程ID][消息编号:X]
- 通过 detachShm 分离共享内存
特点:
- 每 1 秒写入一条消息
- 写入 10 条消息后退出
- 不负责删除共享内存
服务器程序 shm_server.cpp
(服务器负责从共享内存读取数据)
#include "comm.hpp"int main()
{ // 1. 创建key_t k = getKey();printf("key: 0x%x\n", k); // keyint shmid = createShm(k);printf("shmid: %d\n", shmid); // shmid//sleep(5);// 2. 挂接关联char *start = (char*)attachShm(shmid);printf("attach success, address start: %p\n", start);// 3. 开始通信int cnt = 0;while (true){printf("client say# %s\n", start);struct shmid_ds ds;shmctl(shmid, IPC_STAT, &ds);printf("获取属性 --> size: %d, pid: %d, myself: %d, key: 0x%x\n", ds.shm_segsz, ds.shm_cpid, getpid(), ds.shm_perm.__key);sleep(1); // 服务端每隔1s读一条cnt++;if (cnt == 10) // 读10条消息就退出break;}// 4. 去掉关联detachShm(start);printf("detach success\n");sleep(10);// 5. 删除delShm(shmid);return 0;
}
通信流程:
- 通过 getKey 获取共享内存键值
- 使用 createShm 创建共享内存(确保唯一性)
- 通过 attachShm 挂载共享内存
- 循环读取共享内存内容并打印
- 通过 shmctl 获取共享内存状态信息并打印
- 通过 detachShm 分离共享内存
- 等待 10 秒后删除共享内存
特点:
- 每 1 秒读取一条消息
- 读取 10 条消息后退出
- 负责删除共享内存(确保资源释放)
4.1 同步机制分析
运行结果如下:
时间同步:通过双方的 sleep(1)
实现每秒一次的读写操作。
数据同步:
- 客户端主动写入,服务器被动读取。
- 客户端写入覆盖原有内容。
- 没有显式的同步机制(如信号量),依赖时序保证数据完整性。
5. 共享内存的特点
优点: 在所有进程间通信方式中,共享内存的速度是最快的。
具体来说,倘若客户端向服务端发送消息,或者进程 1 向进程 2 发送消息,由于共享内存是被双方共同享有的,只要把数据写入到共享内存空间里,对方马上就能看到相应的数据,如此一来,就能减少进程 1 到进程 2 的数据拷贝次数。
缺点: 共享内存不会进行同步与互斥操作,对数据也未做任何保护措施。
我们刚刚提到,操作系统中可能同时存在多组进程使用共享内存进行通信,因此操作系统必须对共享内存进行管理。而管理的方式通常是先描述再组织,所以对共享内存的管理最终转化为对共享内存对象的管理,主要包括增删查改等操作。
不过,这里存在一个关于属性的问题。还记得我们之前提到的 key 吗?
在内核层面,为了让不同的进程能够找到同一个共享内存,我们会在上层生成一个 key,并将其设置到内核中共享内存的属性里。这样,当其他进程获取共享内存时,就可以通过读取其属性来判断是否与目标进程所访问的是同一个共享内存。但奇怪的是,在这个结构中我并没有看到任何关于 key 的信息。
实际上,key 的信息就存在于 struct shmid_ds
的第一个字段中。
最后是关于共享内存大小的问题:为什么建议将共享内存大小设置为 4096(即 4KB)的整数倍呢?
这是因为系统分配共享内存是以 4KB 为基本单位的,4KB 其实就是内存划分内存块的基本单位。如果你设置的共享内存大小为 4097 字节,内核会进行向上取整,最终分配给你 2 个 4096 字节(即 8KB)的空间。
四、System V 消息队列
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法。
每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值。
特点:IPC 资源必须删除,否则不会自动清除,除非重启,所以 system V 的 IPC 资源的生命周期随内核一样。
五、System V 信号量
信号量的本质是一个计数器,通常用于体现公共资源中资源数量的情况。
所谓公共资源,指的是能够被多个进程同时访问的资源。要是对没有保护措施的公共资源进行访问,就容易出现数据不一致的问题。
那为什么要让不同的进程看到同一份资源呢?这是因为我们希望实现进程间的通信,让进程之间能够协同合作。而由于进程本身具有独立性,会导致无法通信,所以我们引入了通信机制。引入通信机制的核心要点,就是要让进程能够看到同一份资源。
大多数资源是相互独立的,我们把受到保护的公共资源称作临界资源。资源可以理解为像内存、文件、网络等这些,既然是资源,那就需要被使用,那进程是如何使用它们的呢?必然是该进程有对应的代码来访问这部分临界资源,而这部分用于 访问临界资源的代码就叫做临界区,与之相对的,不访问这部分资源的代码就叫做非临界区。
多进程在通信时,本质上就是要看到一份公共资源,若这份公共资源后续受到了保护,那它就被称为临界资源。
访问该临界资源的代码称为临界区,不访问该临界资源的代码则称为非临界区。
那要如何对临界资源进行保护呢?有同步和互斥这两种方法。
原子性指的是一个操作或者一系列操作,要么全部执行成功,要么全部不执行,不会出现部分完成或者被中断的情况。
那么这些内容和信号量有什么关联呢?
信号量是用于实现多进程或者多线程之间协同合作的,也就是用来达成原子式的互斥或者同步操作的。
为什么要有信号量呢?又该如何理解这个计数器呢?
举个例子来说,我们都知道看电影买票,其本质就是对放映厅里的座位进行预订。也就是说,当我们想要获取某种资源时,可以采用预订的方式。所以就定义了一个计数器,像下面这样的代码示例:
int count = 100;
票号 = 1; count--;
票号 = 2;count--;
......
if (count == 0)不能再卖了!
从第一层含义来看,这个 count 数据所表示的就是当前放映厅里剩余座位的数量。从第二层含义来讲,只要你买了票,哪怕你暂时还没到,也一定会给你预留对应的座位。
在这里,放映厅就好比是共享资源,而每一个座位相当于把资源划分成的一个个小部分,人就如同进程,我们买的票就是信号量。可以保证的是,只要你买到了票,那你肯定能看这场电影,座位一定会给你留着。
这样一来,当每个进程想要访问某些公共资源时,可以先申请信号量,申请信号量成功就相当于预订了共享资源当中的某一小部分资源,然后就允许你进入这部分小资源里进行访问;要是申请信号量不成功,那就不允许这个进程进入对应的共享资源,通过这种方式来达到保护共享资源以及其他进程的目的。
所以,我们把这种 通过计数器的形式来对临界资源进行保护的计数器,称作信号量。示例如下:
信号量 sem = 20;
sem--; // 预订资源 ---> P操作
开始访问公共资源;
sem++;// 释放资源 ---> V操作
所有的进程在访问公共资源之前,都必须先申请 sem 信号量,而要先申请 sem 信号量的前提是,所有进程必须先看到同一个信号量,所以信号量本身就是公共资源,那它也就必须要保证自身的安全。换句话说,信号量必须确保自身的 ++
和 --
操作是安全的,也就是要保证这些操作是原子性的。
结论就是: 信号量其实就是一个计数器,在多进程环境(system V版本)中,它能够被多个进程同时看到,并且必须搭配上两种操作,一种是 P 操作,一种是 V 操作,然后借助信号量来对临界资源进行特定的访问。
另外,如果一个信号量的初始值为 1,意味着该共享资源是作为一个整体来使用的,这时就称之为二元信号量,它能够提供对共享资源的互斥访问。
1. 进程互斥
由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥。
系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
在进程中涉及到互斥资源的程序段叫临界区。
特性方面:IPC 资源必须删除,否则不会自动清除,除非重启,所以 system V 的 IPC 资源的生命周期随内核一样。