【Linux】进程间通信的秘密通道:IPC机制详解
🎬 个人主页:谁在夜里看海.
⛰️ 道阻且长,行则将至
目录
📚前言
❓什么是进程间通信?
进程间通信(Inter-Process Communication,IPC)是指操作系统中不同进程之间交换数据和信息的方式。进程是操作系统中独立执行的基本单元,每个进程都有自己的内存空间和资源。
❓为什么需要进程通信?
在具体开发场景中,某些系统资源需要被多个进程同时访问,比如文件、设备等;一些大型任务,通常会被分解成多个子任务,每个子任务由不同的进程负责,这也要求进程之间能够互相传递数据,协调工作状态。
那么进程之间该怎么实现数据的共享呢?一个进程可以直接访问到另一个进程的资源吗?
答案是不可以,不同进程有各自的地址空间,一个进程的空间地址对于另一个进程来说没有任何意义,一个进程也无法直接通过另一个进程的空间地址进行数据访问。
一个进程不能通过另一个进程的空间地址访问到对方的资源,归根结底是因为我们用户层可见的空间地址是虚拟的,每个进程都有自己的一个页表,用于将虚拟地址映射到物理地址,对于不同的进程来说,虚拟地址并不是唯一的(例如:进程1和进程2都有“0xaabb”的虚拟地址),而我们不能保证不同进程之间相同的虚拟地址通过各自的页表可以映射到同一块物理地址(例如:进程1的“0xaabb”映射到“0xaaaa”,进程2的“0xaabb”映射到“0xbbbb”),所以对于不同进程来说,虚拟地址不具备访问资源的能力,然而我们又不能直接获取到物理地址(处于安全性的考虑,操作系统不允许用户访问页表信息),此时就需要用到进程通信的机制,使得不同进程之间可以共享资源。
❓进程之间共享同一份资源,我们可以联想到什么?
没错,父子进程不就是天然地共享资源嘛,父子进程之间通过写时拷贝机制,既实现了资源的共享,又保证了进程间的独立。Linux下一切皆文件,父子进程共享资源,其实就是共享同一份文件,一个进程对文件进行写入,另一个进程可以对写入的数据进行读取,此时两个进程之间就实现了通信,我们把这个用于通信的文件称为管道文件(一端写入,一端接收,像管道一样)
📚一、匿名管道
📖1.原理
当一个进程从磁盘中打开一个文件时,这个文件的信息会被整合成一个数据结构存储到内核的全局文件表中,进程会分配一个文件描述符fd,用于指向全局文件表中对应的文件条目;子进程会继承父进程的文件描述符表,所以父子进程可以通过同一个文件描述符fd访问同一个文件,实现通信。
我们规定,父子进程通过一个管道文件进行通信时,一方只能对管道进行写入,而另一方只能对管道进行读取,这是为什么呢?
🔖单向通信
文件偏移量 f_pos 是记录文件当前读写位置的指针,每次对文件的读写操作都会更新这个偏移量
如果父子进程通过相同的文件描述符对同一文件进行写入的话,会引发并发冲突问题,例如:父进程向写入“aaaaa”时,子进程也同时向文件写入“bbbbb”,此时文件中真正写入的数据就可能是“ababab”,这并不是我们想要的。
那么父子进程通过不同的文件描述符打开同一个文件进行写入呢,此时确实不会引发并发冲突,因为它们的文件偏移量各自独立,但此时会有另一个问题,就是数据覆盖,比如:父进程向文件写入“aaaaa”后子进程再向文件写入时,由于偏移量还在文件开头,所以此时子进程的写入“bbbbb”会将“aaaaa”覆盖掉:
同样地,在读取同一份文件的数据时,也会发生类似的问题。为了规避这些问题,操作系统规定,父子进程对于同一个管道文件,一方只能进行写入,另一方只能进行读取。
但是上述操作该如何实现呢?父进程对管道文件的操作权限同样也会继承给子进程,我们该怎么限制父进程只读、子进程只写或者父进程只写、子进程只读呢?
确实,我们可以通过chmod函数在打开文件后对文件权限进行调整,但管道文件的单向通信并不是这样实现的:
我们先考虑一个问题,一个进程想对一个文件既读又写,是在打开的时候设置权限为O_RDWR(允许读写操作)吗,这样是有问题的!还是前面提到的文件偏移量 f_pos ,f_pos只有一个,它不会说为读写操作各分配一个,文件进行写入时偏移量会更新到写入的最新位置,这样就不能通过这个偏移量对数据进行读取了,除非手动地改变偏移量,但这样太麻烦了,所以一般我们方法是:
一个文件描述符负责读,一个负责写
这样每个文件描述符有独立的偏移量,读写互不干扰。根据这个思路,上面的管道单向通信该怎么实现呢?
首先,父进程通过文件描述符fd1、fd2以只读、只写的方式分别打开管道文件,然后创建子进程,将文件描述符信息继承给子进程:
之后将一方的读通道关闭,另一方的写通道关闭,这样就实现了一方只能读、另一方只能写的单向通信:
抽象成概念图如下:
🔖缓冲区数据共享
由于文件的数据要存储在磁盘中,进程在对文件进行写入时,系统为了减少写入磁盘的开销,会分配一个内核缓冲区,数据首先会被写入到内核缓冲区中,并在合适的时候将数据写入磁盘;
进程对文件进行读取时,也会优先从内核缓冲区中读取数据;如果数据不在缓冲区,操作系统就会从磁盘加载数据到缓冲区,然后再返回给进程。这种机制叫做缓存读取。
对于管道文件来说,文件内的数据只用于进程间通信,所以不需要存储到磁盘中,只需要保存在缓冲区中即可。所以对管道文件的写入其实是对缓冲区的写入;读取其实是对缓冲区进行读取。管道文件本质上就是一个内核缓冲区,其生命周期伴随着进程,当进程调用时,开辟缓冲区空间;当进程终止时,释放缓冲区空间。
🔖管道生命周期
❓什么时候开辟管道空间呢?
当父进程调用系统调用来创建管道时,操作系统会为该管道分配内存空间。
❓什么时候释放管道空间呢?
由于管道可以被多个进程使用(一个管道可以有多个进程进行写入,也可以有多个进程进行读取,尽管不推荐这样做),因此在释放管道的内存空间之前,需要确保没有进程再引用该管道。只有当所有与管道相关的文件描述符都被关闭时,操作系统才会回收管道的缓冲区和资源。操作系统怎么知道没有进程引用了呢?通过一个计数器,用于记录当前引用该管道的进程的个数,当计数器清零时,操作系统对管道进行回收。
🔖为什么叫匿名管道
通过上面的叙述,我们知道了用于父子进程间通信的管道本质上是一个内核缓冲区,其生命周期只在创建它的父子进程间有效,一旦进程结束,管道的资源就会被自动回收。它没有一个明确的名字或者文件路径,不可以通过路径在文件系统中被访问和操作,所以又称之为匿名管道。
了解了匿名管道的底层原理后,我们来介绍一下它的使用方法:
📖2.使用方法
在 Linux 中,匿名管道是通过 pipe()
系统调用创建的,用于父子进程之间的通信。
🔖函数原型
pipe()
系统调用原型:
#include <unistd.h>
int pipe(int pipefd[2]);
参数:
pipefd:是一个整数数组,大小为2,pipefd[0] 用于读取管道数据(读取端),pipefd[1] 用于写入管道数据(写入端)
返回值:
成功:返回0。
失败:返回-1,并设置errno为具体的错误码。
函数解析:
当调用 pipe()
时,操作系统会在内核中创建一个匿名管道,并返回一对文件描述符 pipefd[0]
和 pipefd[1]
。进程可以使用这两个描述符进行数据的读写操作。
❗️注意:匿名管道是单向的,即数据只能从一端流向另一端。如果需要双向通信,则需要创建两个管道。
🔖示例
下面用一个简单示例演示父子进程之间是如何通过匿名管道实现通信的:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
int fds[2];
char buf[100] = {0};
int len;
// 创建管道
if (pipe(fds) == -1) {
perror("make pipe");
exit(1);
}
// 创建子进程
pid_t pid = fork();
if (pid == 0) {
// 子进程
while (1) {
sleep(1); // 等待父进程写入数据
len = read(fds[0], buf, sizeof(buf)); // 从管道读取数据
if (len > 0) {
fputs("Receiving# ", stdout);
write(1, buf, len); // 打印接收到的数据
memset(buf, 0x00, len); // 清空缓冲区
}
}
} else {
// 父进程
while (1) {
fgets(buf, sizeof(buf), stdin); // 从标准输入获取数据
len = strlen(buf);
if (len > 0) {
write(fds[1], buf, len); // 将数据写入管道
memset(buf, 0x00, len); // 清空缓冲区
}
}
}
return 0;
}
运行结果:父进程输入数据写入管道,子进程对管道进行读取并打印到终端
📖3.管道读写规则
父子进程对管道的读写操作遵循一定规则:
① 当没有数据可读时
a.默认情况下:read调用会阻塞,即进程暂停执行,一直等到有数据来为止;
b.设置 O_NONBLOCK 非阻塞模式:read调用返回-1,errno值为EAGAIN。
② 当管道满时(不能再进行写入)
a. 默认情况下:write调用阻塞,直到有进程读走数据
b.设置 O_NONBLOCK 非阻塞模式:write调用返回-1,errno值为EAGAIN。
③ 所有管道写端对应的文件描述符被关闭:则read返回0
④ 所有管道读端对应的文件描述符被关闭:则write操作会产生信号SIGPIPE,进而可能导致write进程退出
🚫匿名管道的限制
进程通过匿名管道实现通信的前提是:进程之间具有亲缘关系。
如果我们想在不相关的进程间实现通信,进行数据的交换,系统提供了命名管道FIFO:
📚二、命名管道
📖1.原理
命名管道,又叫 FIFO(First In, First Out),是由操作系统提供的一种特殊类型的文件,它允许没有亲缘关系的进程进行通信。与匿名管道不同,命名管道有一个路径名,可以在文件系统中像普通文件一样被访问和操作。
与匿名管道的相同点:
不同进程之间通过文件描述符打开同一个管道文件,规定一个进程只能对管道写入,另一个进程只能对管道读取,以此实现管道的单向通信。
不同点:
命名管道与普通文件一样,具有文件名和存储路径,进程通过open函数调用根据存储路径打开管道并分配文件描述符,实现相应的读或写操作;
匿名管道没有存储路径,进程通过pipe系统调用创建管道并且获取文件描述符,实现对管道的访问
FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。
📖2.使用方法
在 Linux 中,命名管道的创建是通过 mkfifo
系统调用或者命令行工具 mkfifo
来实现的。一旦创建了命名管道,进程就可以通过普通的文件操作来进行通信。
🔖函数原型
命名管道的创建方式有两种:
① 使用命令行工具:可以通过 mkfifo
命令创建命名管道
mkfifo mypipe
② 通过程序调用 mkfifo
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int mkpipe(const char *pathname, mode_t mode);
参数:
pathname:命名管道的路径名。
mode:文件的权限设置,通常使用 S_IRUSR | S_IWUSR
来赋予读写权限。
返回值:
成功:返回0。
失败:返回-1,并设置errno指示错误。
🔖示例
下面是一个简单的示例,演示如何通过命名管道进行不相关的进程间的通信:
// recive.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
umask(0);
if(mkfifo("mypipe",0644) == -1)
perror("make pipe"),exit(1);
char buf[100];
int len;
while(1)
{
sleep(1);
printf("Please wait...\n");
int pipe_fd = open("mypipe",O_RDONLY,0644);
if(pipe_fd>0)
{
printf("TX said# ");
fflush(stdout);
int len = read(pipe_fd,buf,100);
write(1,buf,len);
}
}
exit(0);
}
// send.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
char buf[100];
int len;
int pipe_fd = open("mypipe",O_WRONLY,0644);
while(1)
{
printf("Please write# ");
fflush(stdout);
len = read(0,buf,100);
write(pipe_fd,buf,len);
sleep(1);
}
exit(0);
}
📚三、共享内存
📖1.管道通信的弊端
通过管道进行进程间通信时,无论是匿名管道还是命名管道,都会涉及到至少一次数据拷贝:当一个进程写入数据到管道时,数据首先被拷贝到内核空间的缓冲区。然后,当另一个进程从管道读取数据时,数据再次从内核缓冲区拷贝到读取进程的用户空间。
这种数据拷贝机制带来了以下问题:
① 性能开销:每次数据写入和读取都需要进行拷贝操作,尤其是在大量数据传输时,性能可能会受到影响。
② 中间存储:管道通过内核缓冲区作为中间存储区进行数据交换,因此进程之间的通信不能直接访问对方的内存,而是通过内核进行转发。
管道只是进程间通信的一个中介,数据需要通过内核缓冲区进行交换,进程无法直接访问对方的内存。
针对上述情况,操作系统提供了另一种进程通信方式:共享内存通信。
📖2.原理
回到文章最开头的讨论:进程之间为什么不能通过空间地址访问对方的资源?
这是因为进程在创建资源时首先根据存储规则给资源分配虚拟地址空间,然后通过页表将虚拟地址空间映射到物理地址空间完成资源的存储,由于页表对于用户空间是不可见的,因此我们无法得知资源在内核中存储的具体位置。
但如果我们反过来呢,先在内核申请一块物理内存,并为每个想访问这块内存的进程提供映射,映射到各自的虚拟地址空间,而虚拟地址对于用户层是可见的,于是我们就可以在不同进程中各自通过映射之后的虚拟地址访问同一块物理内存,这就是共享内存。
📖3.使用方法
🔖shmget
功能:用于创建共享内存
原型:
int shmget(key_t key, size_t size, int shmflg);
参数:
key:共享内存段的名字
size:共享内存的大小
shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1
🔖shmat
功能:将共享内存段连接到进程地址空间
原型:
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数:
shmid: 共享内存标识
shmaddr:指定连接的地址
shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1
✅说明:
① shmaddr为NULL,内核自动选择一个地址;
② shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址;
③ shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA);
④ shmflg=SHM_RDONLY,表示连接操作用来只读共享内存。
🔖shmdt
功能:将共享内存段与当前进程脱离
原型:
int shmdt(const void *shmaddr);
参数:shmaddr: 由shmat所返回的指针
返回值:成功返回0;失败返回-1
❗️注意:将共享内存段与当前进程脱离不等于删除共享内存段,只有当全部进程都与共享内存段脱离之后,并且共享内存当前模式为待删除时,内存空间才会释放。
🔖shmctl
功能:用于控制共享内存
原型:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:
shmid:由shmget返回的共享内存标识码
cmd:将要采取的动作(有三个可取值)
buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回0;失败返回-1
✅cmd可取值:
① IPC_STAT:把shmid_ds数据结构中的数据设置为共享内存的当前关联值;
② IPC_SET:在进程有足够权限的前提下,把共享内存的当前关联值设置为shmid_ds数据结构给出的值;
③ IPC_RMID:将共享内存段状态设置为待删除。
📖4.示例
下面一个简单的示例演示进程间如何通过共享内存通信:
// recive.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#define SHM_SIZE 1024
int main()
{
key_t _key = 111;
int shmid;
char *data;
shmid = shmget(_key,SHM_SIZE,IPC_CREAT|0666);
if(shmid < 0)
perror("shmget"), exit(1);
data = shmat(shmid,NULL,0);
int n = 0;
while(n++ < 5)
{
sleep(1);
printf("The %d data is:",n);
fflush(stdout);
printf("%s\n",data);
}
shmdt(data);
shmctl(shmid,IPC_RMID,NULL);
exit(0);
}
//send.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <string.h>
#define SHM_SIZE 1024
int main()
{
key_t _key = 111;
int shmid;
char *data;
shmid = shmget(_key,SHM_SIZE,IPC_CREAT|0666);
if(shmid < 0)
perror("shmget"), exit(1);
data = shmat(shmid,NULL,0);
int n = 0;
while(n++ < 5)
{
printf("Now Sending(%d/5)\n",n);
strcpy(data,"hello linux!");
sleep(1);
}
exit(0);
}
📚四、总结
进程间通信(IPC)是操作系统中不同进程之间交换数据和信息的方式。由于每个进程都有独立的内存空间,无法直接访问其他进程的资源,因此需要通过IPC机制实现数据共享。
管道是其中一种常用的通信方式,它通过文件描述符让父子进程共享资源,通过内核缓冲区进行数据交换。管道分为匿名管道和命名管道。匿名管道用于有亲缘关系的进程间通信,而命名管道则允许没有亲缘关系的进程通过指定路径进行通信。
共享内存是一种高效的进程间通信方式,它允许多个进程访问同一块内存区域,从而直接交换数据。与其他进程间通信方式相比,共享内存的速度更快,因为数据不需要通过内核复制。进程可以通过映射共享内存到各自的地址空间来访问这块内存。
以上就是【文件操作的艺术——从基础到精通】的全部内容,欢迎指正~
码文不易,还请多多关注支持,这是我持续创作的最大动力!
原文地址:https://blog.csdn.net/dhgiuyawhiudwqha/article/details/144432524
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!