背景:Linux 2.6
所谓通信,就是让一方能够看到另一方发送的数据,也就是,两个进程间要通信,首先要让这两个进程能够访问同一份空间,而访问的空间的不同也就决定了通信方式的不同
匿名管道,是一种半双工的通信机制。可以把管道想象成水管,而水在水管中的流动都是单向的,管道也一样
并且匿名管道只能用于具有父子关系或者具有共享父进程的进程之间的通信。
先来认识创建匿名管道的接口 int pipe(int pipefd[2])
int pipefd[2]
是一个输入输出型参数,使用的时候,创建一个 int[2]
参入,当函数执行完成的时候,返回值不为 0 说明管道创建失败int[2]
会被赋值,pipe[0]
表示该文件读端的文件描述符,pipe[1]
表示该文件写的端文件描述符。⭐下标 0 固定表示读端,下标 1 固定表示写端pipe[0]
和 pipe[1]
使用的时候通常是用于父子进程间的通信,父进程创建完管道之后,创建子进程,这时候会发生子进程的拷贝,而子进程也会拷贝父进程的文件描述符表(Important
)
所以父子间匿名管道通信的时候,只需要让一方关闭读端,另一方关闭写端,就可以实现进程的单向通信
放一张图片理解一下匿名管道的大致(来源:Bing 图片)
以下是简单的父子进程利用匿名管道通信的代码,
任务:子进程给父进程发送三次 “阿巴阿巴阿巴”
代码加了详细注释:
int main()
{
// 进程间通信 —— 匿名管道
// 场景:子进程一直给父进程发送 : “阿巴阿巴阿巴”
// 1. 创建管道
int pipefd[2] = {0};
if (pipe(pipefd) != 0) { // 不为 0 说明创建管道失败
return 1;
} // 否则创建成功
// 2. 创建子进程
int pid = fork();
if (pid == 0) { // 子进程
close(pipefd[0]); // 子进程要写,那么关闭读端
char* input = "阿巴阿巴阿巴";
int cnt = 3; // 发送 3 次
while (cnt -- > 0) {
write(pipefd[1], input, strlen(input));
}
cout << "son write over over" << endl; // 发送完成
close(pipefd[1]); // 执行完毕,关闭写端口
exit(0);
}
else { // 父进程
close(pipefd[1]); // 父进程要读,那么关闭写端
char output[1024]; // 接收收到的数据
while (true) {
// 向该文件中读取, 第二个参数表示读取到的数据放在哪,
// 第三个数据表示预期读取多少个字, 返回值表示实际读取到的字节
ssize_t rd = read(pipefd[0], output, sizeof(output) - 1);
if (rd > 0) { // 说明有读取到数据, 直接打印
output[rd] = '\0'; // 人工添加结束符
cout << output << endl;
}
else if (rd == 0) { // 写端关闭,读取结束
// 读取结束
cout << "father read over over" << endl;
break;
}
}
close(pipefd[0]); // 执行完毕,关闭读窗口
}
waitpid(pid, nullptr, 0); // 等待子进程, 回收相关资源
return 0;
}
执行程序,执行结果如下:
read
读取接口是阻塞式等待,有数据就读取,没数据就阻塞,而当文件对应的写端关闭的时候,read
就会返回 0 ,表示该文件不会再写入数据了,那么读端也就可以关闭了接下来讲讲匿名管道在底层是如何实现通信的
pipe(int pipefd[])
接口会创建一个 " 管道文件 ",但是这个文件并不是实体文件,在该文件中进行通信的数据更不会写入到磁盘中,可以理解成内存级的,并且在程序结束之后,空间就会被释放pipe(int pipefd[])
的时候,会在操作系统内核创建两个 struct file
对象,一个是用于读端,一个用于写端,然后在该进程的文件描述符表中分配两个文件描述符,这也是 pipefd[]
大小为 2 的原因struct file
中有个结构体指针 struct file_operations* f_op
,其中包含了很多对文件进行操作的函数指针,也可以对管道进行读写操作(铺垫)pipe(int pipefd[])
的时候,还会创建个结构体 struct pipe_inode_info
,这个结构体就是匿名管道的关键数据结构struct pipe_buffer bufs[]
,这个就是缓冲区了,可以看到这里实际上是缓冲区数组,很重要struct file
,实际上都引用 / 绑定了同一个管道的缓冲区,他们最终都是调用读写函数来对 struct pipe_inode_info
中的缓冲区数组进行写入和删除操作的,一方来写,另一方来读,最终完成了匿名管道的通信总结一下:
有匿名管道,当然有命名管道,匿名管道只能 [ 亲戚间 ] 单向通信,而命名管道用于任何进程之间通信,当然,单向的
这个和匿名管道是很相似的,只是有没有创建实体文件而已
认识接口 int mkfifo(const char* pathname, mode_t mode)
这个接口的任务就是创建一个特殊的文件 —— 管道文件,并且指定这个文件的权限,这个接口创建的是实体文件
比如我们就只执行 mkfifo("./.fifo, 0600")
,如图
然后命名管道的使用也是很简单的,对于读端和写端就只需要以读和写的方式分别打开这个 管道文件 就好了
比如实现一个服务进程和一个客户进程进行通信:客户进程给服务进程发送信息,服务进程打印出收到的信息,代码如下
客户端:
int main()
{
// 写端打开
int writefd = open("./.fifo", O_WRONLY);
char tosend[1024] = {0};
while (true)
{
cout << "客户端输入数据:";
fflush(stdout); // 刷新数据到屏幕上
// 从键盘中获取数据发送给服务器
if (fgets(tosend, 1023, stdin) == NULL) { // 如果读取失败,返回 NULL
return 1;
}
int len = strlen(tosend);
tosend[len] = '\0'; // 手动添加结束符
write(writefd, tosend, len); // 写入数据
}
return 0;
}
服务端:
int main()
{
// 打开管道的读端
int readfd = open("./.fifo", O_RDONLY);
char data[1024] = {0};
while (true)
{
int len = read(readfd, data, 1024); // len 为实际读取到的数据
if (len > 0) { // 读取到数据了
data[len] = '\0';
cout << "服务端收到数据:" << data << endl;
}
else if (len == 0) { // 写端关闭, 那么读端关闭
cout << "客户端跑路了,我也跑路了" << endl;
return 1;
}
}
return 0;
}
执行结果如下:
命名管道的实现原理和匿名管道是很相似的,除了创建文件的方式不同
pipe()
接口来直接创建内核的数据机构mkfifo()
来创建实体文件,但是这个实体文件并不会将数据写入磁盘,然后后续有两个进程再创建写端和读端的 struct file
之后,原理基本就和匿名管道差不多了共享内存,就是在物理内存上开辟一块空间,然后进程之间可以直接对这块物理内存进行访问,那就可以进行通信了
而多个进程要想访问同一块物理内存,直接访问地址肯定行不通,操作系统不允许外界直接对物理内存作访问。所以想要多个进程看到同一个物理内存,就需要依靠虚拟地址空间
进程的虚拟地址空间都是独立,互不干扰的,所以只需要保证每个进程都可以将某个虚拟地址映射到同一个物理内存上就好了,这也就是实现原理了,画图如下:
共享内存的接口比较多,一个一个看看
key
:用来表示共享内存端的唯一值,不同的进程可以通过相同的 key
来获取同一块共享内存
size
:申请的共享内存的大小,物理内存被 4KB 划分成了一个个单位,所以这里 size
尽量是 4KB 的倍数
shmflg
:标志位,主要有两个标志位:IPC_CREAT
和 IPC_EXEL
① IPC_CREAT
:如果 key
对应的共享内存存在,那么就获取;不存在,那么创建
② IPC_EXEL
:一般都是和 IPC_CREAT
搭配使用,如果不存在 key
共享内存,那么创建;如果存在,那么表示申请出错,直接返回。说人话就是,一定要申请到一个全新的物理内存
③ 同时也可以指定这个共享内存对 Linux
用户的访问权限,比如 0666
,表示这个共享内存对所有用户都允许读取和写入
返回值称为 shmid
,如果申请成功,那么就返回shmid
,也就是共享内存的 id
需要区分一下 key
和 shmid
,key
是标识共享内存端的唯一标识,可以用来确保多个进程都获取到同一个物理内存;而 shmid
是申请成功共享内存后的标识符,它可以保证多个进程对同一个共享内存的访问
key
,便于多个进程来获取同一块物理内存将共享内存挂靠到当前进程的虚拟地址空间上(shared memory attach
)
shmid
:上边说过了,这里换个简单的说法:已经申请成功的共享内存的 id
shmaddr
:想要将共享内存挂靠到虚拟地址的哪个位置,一般填为 nullptr
就够了shmflg
:可以设置共享内存的挂靠方式和访问权限,填 0 表示有读写权限void*
删除当前进程和共享内存的关系(shared memory detach
)
shmaddr
:共享内存的虚拟地址shmid
:和上面一样cmd
:要对这个共享内存作什么操作,IPC_RMID
表示立即删除,如果删除,那么 buf
为空就好了注意了,shmdt
只是让当前进程的虚拟地址空间和共享内存取消映射,但是共享内存还在,IPC_RMID
才是彻底删除
代码任务:客户端给服务器发送消息,服务端将收到的消息打印出来,代码都加了详细注释
服务端
int main()
{
// 1. 生成 Key , 并且参数和客户端一样, 就可以保证两者可以获取到同一份共享内存
key_t key = ftok("/home/shit", 8888888);
// 2. 申请共享内存的空间, 并且一定要申请一个新的共享内存,并设定共享内存的权限是 0666
int shmid = shmget(key, 2048, IPC_CREAT | 0666 | IPC_EXCL);
// 3. 挂靠到自己的虚拟地址空间上, 并返回这个虚拟地址
char* address = (char*) shmat(shmid, nullptr, 0); // 0 表示默认读写权限
// 4. 开始通信,接收客户发送的数据
while (true) {
// 写端是直接往这个虚拟地址上写入数据的, 服务端也就直接从这个地址开始读取数据了
if (address != NULL && address[0] != '\0') {
cout << "收到客户发送的数据:" << address << endl;
}
sleep(1);
}
// 5. 取消挂靠
shmdt((void*) address);
// 6. 服务器即将关闭, 将共享内存释放了
shmctl(shmid, IPC_RMID, nullptr);
return 0;
}
客户端
int main()
{
// 实现贡献内存, 客户给服务端打印消息, 服务端打印收到的消息
// 1. 先获取一个共享内存段的唯一标识 key
key_t key = ftok("/home/shit", 8888888);
// 2. 再申请共享内存, 返回这块共享内存的 id
int shmid = shmget(key, 2048, IPC_CREAT); // 申请的大小尽量是 4KB 的倍数
// 3. 和当前进程的虚拟地址空间建立关系
// nullptr 表示不关心挂靠的虚拟地址的位置, 0 表示默认读写权限
// 转化成 char* 类型
char* address = (char*) shmat(shmid, nullptr, 0);
// 4. 开始通信
while (true) {
// 从键盘中获取数据, 也就是 0 号文件描述符
// 返回实际上读取到的数据, 参数的 2048 表示:读取到的数据最多不超过 2048 字节
cout << "请输入数据:" ;
fflush(stdout);
if (fgets(address, 2048, stdin) != NULL) { // 读取正常
cout << "客户写入数据成功" << endl;
}
}
// 5. 取消挂靠
shmdt((void*) address);
return 0;
}
需要注意的是,使用共享内存进行通信的时候,是直接在共享内存的地址上读取和写入数据的,不像管道,中间有一层缓冲区。现在只需要将拿到的地址address
来通信就好了
read / write
这样的阻塞式文件操作接口,实现了管道的同步机制。而共享内存并没有同步机制,需要使用其他手段来保证同步,比如锁,信号量…