进程间通信基本知识
进程间通信的定义
进程间通信方式分类
匿名管道(pipe)
匿名管道介绍
- 创建方式:使用
pipe
系统调用创建,返回一对文件描述符(读端和写端)。 - 生命周期:匿名管道的生命周期与创建它的进程有关。当进程结束时,匿名管道会被销毁。
- 作用域:通常用于有亲缘关系的进程(如父子进程)之间的通信。
匿名管道常见用法:
- 创建管道:
pipe(pipefd)
,pipefd是我们自定义的一个数组,将数组传递给pipe函数是为了存储pipe创建匿名管道时提供的两个文件描述符,我们的pipefd
数组中的pipefd[0]
是读端文件描述符,pipefd[1]
是写端文件描述符。 - 写入数据:
write(pipefd[1], data, size)
,将数据写入管道。 - 读取数据:
read(pipefd[0], buffer, size)
,从管道读取数据。 - 关闭管道:使用
close(pipefd[0])
和close(pipefd[1])
关闭读端和写端,释放资源。
创建匿名管道看起来似乎用到的是管道文件来进行通讯,但实际上并不是的,这里和管道文件时没有什么关联的,管道文件是真实存在于文件系统中的一种类型文件,我们可以用ls,rm这些命令对其进行操作。
匿名管道通过操作系统内核维护一个内存缓冲区和一对文件描述符(分别对应于管道的读端和写端)来实现进程之间的通信。当你创建一个匿名管道时,操作系统内核会分配和管理这个内存缓冲区,以支持数据的传输。数据从写端写入缓冲区,另一个进程从读端读取数据。虽然这里的缓冲区不是文件,但是我们可以将匿名管道视作一个文件来对其进行操作,使用read,write,和两个文件描述符来对文件进行读写。
匿名管道的特点
亲缘关系:通常用于有亲缘关系的进程(如父子进程)之间的通信。管道的读端和写端在父进程和子进程之间共享文件描述符。
为什么使用匿名管道通信的进程必须是亲缘关系???请看下面图文解释:
- 当一个进程通过
pipe()
系统调用创建匿名管道时,内核会返回两个文件描述符(一个用于读,一个用于写,对应匿名管道的读端和写端)。这些文件描述符是进程在内核中访问管道的唯一标识。- 继承机制:在UNIX和Linux系统中,当一个进程通过
fork()
创建子进程时,子进程会继承(复制)父进程的所有文件描述符。这意味着子进程可以使用与父进程相同的文件描述符来访问管道,从而实现进程间的通信。- 无文件系统接口:匿名管道不会在文件系统中创建实际文件,所以只能依靠文件描述符来访问。如果进程之间没有亲缘关系(如没有使用
fork()
),则它们无法直接共享这些文件描述符。
一对一通信:每个匿名管道只能实现两个进程之间的单向通信。
匿名管道的设计本质上是一种“点对点”通信方式,即两个进程之间直接传输数据。虽然可以让多个进程共享匿名管道的文件描述符,但这会导致数据读取的竞争或写入的混乱,所以并不推荐这样使用。
单向通信:匿名管道是半双工的,这意味着数据只能在一个方向流动。你需要两个匿名管道来实现双向通信,其中一个用于从进程A到进程B的数据流,另一个用于从进程B到进程A的数据流。
匿名管道确实是半双工的,这意味着在给定时间内,数据只能在一个方向上流动。然而,半双工并不禁止你在不同的时间段内切换通信方向——你可以在一个时间段内让进程A写、进程B读,之后再让进程B写、进程A读。
尽管切换通信方式在理论上可行,但在实际开发中,由于其复杂性和潜在的同步问题,通常不会这样操作。更直观、简单的方法是使用两个管道❤️或选择其他更合适的IPC机制。
匿名管道的使用示例
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <string.h>
int main(void)
{
// 创建匿名管道
int fd[2];//保存匿名管道的两个文件描述符
pipe(fd);
pid_t pid=fork();
// 子进程
if(pid == 0)
{
// 向父进程打招呼
char *msg = "hello parent!";
//sleep(2);
write(fd[1], msg, strlen(msg));
exit(0);
}
// 父进程
if(pid > 0)
{
char buf[50];
bzero(buf, 50);
// 静静地等待子进程的消息
read(fd[0], buf, 50);
printf("来自子进程: %s\n", buf);
exit(0);
}
}
看到这个代码和结果你可能觉得是偶然,确实,由于父进程和子进程并发执行,前提是子进程先从匿名管道写端写入数据,父进程才能正确读到并打印子进程传输的消息。
所以这里不妨让子进程在写之前sleep(2);结果你会发现父进程仍然成功收到并且打印出了子进程传递信息,只不过是延迟了两秒,证明父进程被阻塞等待子进程传递信息过来,接下来让我们详细说一下匿名管道的读写规则。
匿名管道的读写规则
清晰的理解:
读操作(直白+现实的人),我就看你在管道缓冲区中有没有数据。
有数据我就正常读出来,不论此时有写端还是无写端。
没有数据的话,如果有写端(即匿名管道写端对应的文件描述符被加载到了一个进程的文件描述符表中就可),相当于给予了读操作读到数据的希望,就会阻塞等待从管道读到数据。如果根本没有写端,不好意思,直接返回0,都不会等着期待一下。
read
操作的行为只与缓冲区中的数据状态以及是否存在写端密切相关,与当前是否有进程正在进行 write
操作无关。
写操作(卑微)
无读者,此时不论缓冲区是否已满,系统直接将返回一个信号给写操作所在的进程,让这个进程终止。
有读者,当缓冲区满了,写操作会受到阻塞,当缓冲区没满,写操作才能正常写入。
write
操作的行为只与缓冲区中的数据状态以及是否存在读端密切相关。与当前是否有进程正在进行 read操作无关。
读端和写端何时/如何创建
在程序运行时我们使用 pipe()
系统调用创建匿名管道,生成两个文件描述符,并且这两个文件描述符会被自动添加到进程对应的文件描述符表。此时就可以认为,匿名管道就有了读端和写端。所以对于匿名管道来说,当文件描述符被加载到进程文件描述符表中时,匿名管道就多了读端和写端。当然,将文件描述符使用close关闭就减少了对应匿名管道的一个读端/写端。
具体原理是:操作系统内核会为匿名管道维护读端和写端的引用计数。每当一个进程关闭它所持有的文件描述符时,内核会更新对应的引用计数。
检验读写规则
- 父进程首先关闭了这个匿名管道的写端,父进程执行到read读操作的时候,管道中没有数据,此时子进程对应有写端,所以读操作会阻塞等待。10s后,子进程结束退出,对应文件描述符表也释放,匿名管道写端消失,读操作停止阻塞等待,直接返回0,输出字符串。
- 假如去除父进程中close(fd[1]),其他代码保持不变,那么就算10s后,子进程结束退出,对应文件描述符表也释放,匿名管道写端消失,父进程这边匿名管道的写端仍然存在,所以读操作依然继续阻塞等待。
虽然说上面的读写规则我们介绍了各种情况,但是实际上基本遇到的都是读写端同时存在的情况,特殊情况下的read和white的行为只需要我们来了解就行,毕竟真实编程中我们都不会来为难自己,不会脑残地将子进程复制的有用的文件描述符关闭😊😊😊。
匿名管道无用的文件描述符及时关闭
一般而言,不需要用到的文件描述符都最好及时关闭,避免不必要的副作用或浪费系统资源。例如上述程序中,子进程只用到了管道的写端,因此它的fd[0]可以也应该要关闭,相反父进程只用到了管道的读端,因此它的fd[1]可以也应该关闭。代码可以改成:
int main(void)
{
// 创建匿名管道
int fd[2];
pipe(fd);// 子进程
if(fork() == 0)
{
// 关掉不必要的读端
close(fd[0]);...
}// 父进程
else
{
// 关掉不必要的写端
close(fd[1]);...
}
}注意:这里关闭无用的读端和写端的时候要选择合适的时机,不要在公共执行的代码块中关闭 🚫,应该在进程各自执行的代码中再关闭无用的文件描述符。
因为pipe函数在创建匿名管道之后,会将两个文件描述符添加到这个进程的文件描述符表中,且这两个文件描述符是唯一访问匿名管道读端和写端的方式,如果在fork前关闭了描述符,比如关闭读端对应描述符,fork复制产生的子进程的中件描述符表中,也只有写端对应文件描述符了😱,因为子进程的文件描述符表完全复制了父进程的,显然父子进程就通信不了了。
如果我们需要实现双向通信,则最好使用两个管道,然后将里面不必要的文件描述符都及时关闭。
命名管道(fifo)
上面我们已经介绍了匿名管道的使用方式,可以看到匿名管道的局限性还是挺多的,比如说匿名管道只能用于有亲缘关系的进程之间通信。那如果是两个不相关的进程交换数据呢?该怎么实现呢?
命名管道(Named Pipe),也称为FIFO,是一种用于进程间通信的机制。命名管道是一种特殊类型的文件,通过它,不相关的进程可以在同一台机器上进行数据交换。与匿名管道不同,命名管道在文件系统中拥有一个实际的路径名,并且在进程退出后仍然存在,直到它被显式删除。
下面我们来看看如何创建命名管道↓↓↓
命令行中创建并使用命名管道
创建一个默认权限的命名管道:
mkfifo my_pipe
这将在当前目录下创建一个名为 my_pipe
的命名管道,默认权限为 0666
,即所有用户都可以读写。由于所有的管道文件都不能被执行,所以对于管道文件最高的权限就是6 。
当然你也可以在指定路径下创建管道文件:
mkfifo /tmp/my_pipe
你可以使用 ls
-l 命令查看管道文件是否成功创建
文件创建成功时,输出将显示 my_pipe
文件,它的文件类型会标识为 p
,表示它是一个命名管道。忽然发现,mkfifo和mkdir命令格式好像。
现在我们打开两个终端,左侧终端向命名管道文件写入,右侧终端从命名管道文件中读取,这样就可以实现跨终端通信了!!!
写入数据:echo "Hello from another location" > /tmp/my_pipe
读取数据:cat /tmp/my_pipe
代码中创建并使用命名管道
创建命名管道
pathname
:要创建的命名管道的路径名。
mode
:创建的命名管道的权限模式示例:mkfifo("connect-fifo", 0666);
注意:命名管道创建时必须指定文件权限,不能省略。
命名管道的特点
mkfifo(fifo_path, 0666)
:创建命名管道,设置权限为0666
(所有用户可读写)。open(fifo_path, O_WRONLY)
:以写模式打开管道。write(fd, message, sizeof(message))
:向管道写入数据。open(fifo_path, O_RDONLY)
:以读模式打开管道。read(fd, buffer, sizeof(buffer) - 1)
:从管道读取数据,确保缓冲区末尾有终止符。unlink(fifo_path)
:删除命名管道。
单向通信
命名管道(FIFO)是一种单向通信机制。命名管道允许一个进程写入数据,而另一个进程从管道中读取数据。这种单向性质使得每个管道在数据流向上是固定的。虽然也可以采用更复杂的机制来用一个命名管道实现两个进程双向通信,但推荐用两个命名管道来实现双向通信。
所以可以看到,我们在一个进程中open这个命名管道文件,最好都是以只读或者只写的权限来open,就和匿名管道中及时关闭无用的文件描述符类似。
- 管道 1:用于进程 A 向进程 B 发送数据。fifo_a_to_b
- 管道 2:用于进程 B 向进程 A 发送数据。fifo_b_to_a
支持多路写入
多个进程可以同时向同一个命名管道写入数据。每个进程会将数据块写入管道的末尾,数据块会按写入顺序被存储。数据在管道中按照“先进先出”原则进行处理。第一个写入的数据将第一个被读取,保证了数据的顺序性。
命名管道能支持多路写入有一个很大的原因是命名管道的原子性,主要体现在写操作的完整性上,确保每次写入的数据块要么完全写入管道,要么完全失败,避免数据的不完整写入。
命名管道的使用示例
fifo_a
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include<string.h>
#include<sys/stat.h>
int main(void)
{
// 创建具名管道
if(access("fifo_a_to_b", F_OK)){
mkfifo("fifo_a_to_b", 0666);
}
// 向管道写入数据
int fd = open("fifo_a_to_b", O_WRONLY);
char *msg = "data from a,hello ";
write(fd, msg, strlen(msg));
close(fd);
return 0;
}
fifo_b
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include<string.h>
#include<sys/stat.h>
int main(void)
{
// 创建具名管道
if(access("fifo_a_to_b", F_OK)){
mkfifo("fifo_a_to_b", 0666);
}
// 从管道读取数据
int fd = open("fifo_a_to_b", O_RDONLY);
char buf[50];
bzero(buf,20);
read(fd,buf,sizeof(buf));
printf("%s\n",buf);
close(fd);
return 0;
}
代码分析 :
access函数
这里fifo_a.c 和 fifo_b.c代码中都使用了access函数来检查文件是否存在,这样做的原因是确保这个命名管道只被创建一次。
读写规则
这里我们先运行了fifo_a进程,后面运行了fifo_b进程,成功读取到了fifo_a进程传输过来的数据。
错误的理解:在命名管道没有读端时,对命名管道进行写操作write处受到阻塞。
实际的行为:这里先执行fifo_a进程,在open管道文件的时候,由于是以只写的方式打开的文件,并且没有其他的进程可以对这个管道文件读,所以在open操作上受到了阻塞,而不是在写操作write处收到了阻塞。
接下来我们详细说一下命名管道的读写规则。
命名管道的读写规则
首先我们要知道,在命名管道中并没有严格的读写端这个概念,命名管道是一个文件系统对象,可以被任何进程以读(O_RDONLY
)或写(O_WRONLY
)的方式打开和使用。然而,在实际使用中,根据进程如何打开和操作管道,我们通常会在逻辑上区分“读端”和“写端”。
当一个进程以只读模式(O_RDONLY
)打开命名管道时,我们称该进程为“读端”。
当一个进程以只写模式(O_WRONLY
)打开命名管道时,我们称该进程为“写端”。
当一个进程以读写模式(O_RDWR
)打开命名管道时,我们称该进程既为“读端”,又为“写端”。
在命名管道(FIFO)中,open
、read
和 write
操作都可能造成阻塞。
open的读写规则
- 如果一个进程以
O_RDONLY
模式打开命名管道,而没有其他进程以O_WRONLY
模式打开管道,那么open
调用会阻塞,直到有另一个进程以O_WRONLY
模式打开管道。 - 如果一个进程以
O_WRONLY
模式打开命名管道,而没有其他进程以O_RDONLY
模式打开管道,那么open
调用会阻塞,直到有另一个进程以O_RDONLY
模式打开管道。 - 如果进程以
O_RDWR
模式打开命名管道,那么open调用将不会受到任何阻塞。
read和white的读写规则
对于命名管道来说,必须有进程可以对管道进行读,也有进程可以对管道进行写的时候,open才会停止阻塞,那么我们在考虑命名管道中的read和write读写规则的时候就可以只考虑读写端都存在的情况,因为read和write肯定是在管道文件有读端和写端,open才会停止阻塞,然后执行read和write。其余情况了解即可,仅仅极少部分特殊情景用到。
FIFO模型的具体应用
命名管道实际运用介绍
由于命名管道可以让不同的进程进行通信,支持多路写入且保证了写入数据完整性和先进先出的顺序。我们可以将FIFO用在客户端和服务器端的通信,让若干客户端发送消息给命名管道所在的进程,该进程再将信息传递到服务器端进行存储。
实际例子就是我们的系统日志,对于系统日志来说电脑中的很多进程都需要记录信息到里面,但是我们需要避免同时记录的数据冲突,且要保证记录数据的完整性,这时候就可以用到FIFO。
代码例子及运行结果
server.c
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include<string.h>
#include<sys/stat.h>
int main(void)
{
int logfd=open("mylog_test", O_CREAT | O_WRONLY);//打开日志文件,如果没有则创建
// 创建具名管道
if(access("fifo_mylog", F_OK)){
mkfifo("fifo_mylog", 0666);
}
int fifo_fd=open("fifo_mylog",O_RDWR);//以读写方式打开
char buf[100];
while(1){
bzero(buf,sizeof(buf));
read(fifo_fd, buf, sizeof(buf));//从管道读出数据
write(logfd, buf, sizeof(buf));//将数据写入日志文件
}
return 0;
}
client.c
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include<string.h>
#include<sys/stat.h>
#include<time.h>
int main(void)
{
char message[100];
time_t t;//存储时间变量
int fd=open("fifo_mylog", O_WRONLY);
while(1){
bzero(message,sizeof(message));
time(&t);//保存当前时间,ctime将时间转变为可读形式时间
snprintf(message,sizeof(message),"[%d] %s", getpid(), ctime(&t));
write(fd,message,sizeof(message));
sleep(1);//每秒钟将一条记录写入到日志中
}
return 0;
}
得到的日志文件
代码分析
这里运行了两个客户端程序和一个服务器程序,且服务器程序server先执行。很粗糙地模仿了简单的客户机服务器模式。
服务器程序作用:将所有客户端写入管道文件信息转交给日志文件。
客户端:每秒钟产生一条日志文件,写入管道文件。
想要强调server.c程序中的一点(了解一下就行),这里使用了读写的方式打开了命名管道文件,为什么呢?
这里并不是为了客户端程序先启动且客户端未启动的时候,不受到open的阻塞(open阻塞没影响),而是为了让最后一个客户端程序执行结束时,服务器这里的程序不在while中死循环。假如使用只读的方式打开了命名管道文件,在最后一个客户端进程结束的时候,服务器进程只剩下读端,每次read管道文件操作,返回0,反复执行while里面几行代码。为了能在read这里阻塞,所以我们这里采用了读写模式。