Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)。
在前面我们分析过进程的虚拟地址空间结构图,实际上进程的0-3G用户区是相对独立,进程之间要想通信,是通过内核提供的一块缓冲区实现的,而IPC就是进程间通过内核提供的缓冲区进行数据交换的机制。
在进程间完成数据传递需要借助操作系统提供特殊的方法,比如:文件、管道、信号、共享内存、消息队列、套接字、命名管道等。随着计算机的发展,一些方法由于自身设计缺陷被淘汰或者弃用。现今常用的进程间通信方式有:
下面介绍最常用的pipe、fifo、mmap。
管道是一种最基本的IPC机制,作用于有血缘关系的进程之间,完成数据传递。调用pipe()系统函数就可以创建一个管道。管道具有下面的特点:
管道的实现原理是这样的,实际上管道是内核使用环形队列机制,借助内核缓冲区(4K)来实现的。管道在使用时也具有一定的局限性:
#include <unistd.h>
int pipe(int pipefd[2]);
#define _GNU_SOURCE
#include <unistd.h>
int pipe2(int pipefd[2], int flags);
一般来说,要在子进程创建之前使用pipe()来创建管道,这样子进程才能共享这两个文件描述符fd[1]和fd[2]。pipe()函数创建一个管道就相当于打开了一个伪文件(这个伪文件实际上是内核缓冲区,像管道文件读写数据其实是在读写内核缓冲区,因为这个缓冲区只能单向流通数据,所以形象的称为管道),所以调用成功会返回两个文件描述符给参数pipefd[2],其中fd[0]代表读端,fd[1]代表写端,就像0代表标准输入1代表标准输出一样作为一种规定。并且这两个文件描述符在使用的时候不需要open()打开,但是需要我们手动的close()关闭。
管道创建成功后,父进程同时拥有读写两端,因为子进程是对父进程的复制,所以子进程也会拥有读写两端。下面通过图示来说明进程间是如何通过管道通信的。
示例1:父子进程读写管道
/************************************************************
>File Name : pipe_test.c
>Author : Mindtechnist
>Company : Mindtechnist
>Create Time: 2022年05月21日 星期六 17时53分56秒
************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char* argv[])
{
int fd[2];
pipe(fd);
pid_t pid = fork();
if(pid == 0)
{
/*子进程向管道写*/
/*sleep(3); read读设备的时候,默认是会阻塞等待的,写进程睡眠的时候,读进程会阻塞等待,直到读取到数据*/
char str[] = "hello pipe...\n";
write(fd[1], str, sizeof(str));
}
if(pid > 0)
{
char buf[15] = {0}; /*创建一个缓冲区来缓存读出的数据*/
/*read读设备的时候,默认是会阻塞等待的*/
int ret = read(fd[0], buf, sizeof(buf));
if(ret > 0)
{
write(STDOUT_FILENO, buf, ret);
}
}
return 0;
}
由于resd()函数读设备时默认阻塞等待的特性,即使写进程没有立即写,读进程也能读到数据,因为它会阻塞等待。
❀示例2:使用管道实现 ps | grep 命令
/************************************************************
>File Name : mpsgrep.c
>Author : Mindtechnist
>Company : Mindtechnist
>Create Time: 2022年05月21日 星期六 18时08分56秒
************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char* argv[])
{
int fd[2];
pipe(fd);
pid_t pid = fork(); /*一个进程执行ps一个进程执行grep来实现 ps | grep*/
if(pid == 0) /*子进程执行ps*/
{/*把ps的执行结果传给grep,所以子进程写,父进程读*/
/*首先把ps命令的执行结果重定向到管道的写端(默认将执行结果输出到stdout)*/
dup2(fd[1], STDOUT_FILENO);
/*拉起ps进程*/
execlp("ps", "ps", "aux", NULL);
}
if(pid > 0) /*父进程执行grep*/
{
/*把grep读取重定向到fd[0],因为默认grep是在stdin获取输入的*/
/*如果在shell命令行使用grep,模式是在标准输入中匹配*/
dup2(fd[0], STDIN_FILENO);
/*拉起grep进程*/
execlp("grep", "grep", argv[1], NULL);
}
return 0;
}
上面的程序执行后,可以看到输出结果,确实显示了bash相关的进程信息
我们再起一个终端,使用 ps aux 命令查看进程会发现,子进程中拉起的ps进程变成了僵尸进程,并且父进程没有退出。(实际上,如果父进程退出了,子进程就会被init进程收养并回收)
ps进程变成僵尸进程是因为,我们在父进程中并没有回收子进程,因为execlp()函数拉起一个进程后,如果执行成功,就不会再返回了,那么我们也没办法去回收这个子进程ps。但是我们知道,如果父进程终止了,子进程就会被init进程收养并回收,所以我们只要让父进程(也就是程序中的grep进程)退出,就可以解决子进程回收问题了。
下面,我们分析下父进程为什么没有退出,正常情况下,父进程执行完grep命令就应该正常退出的。实际上,这是管道的特性引起的,我们知道,pipe()创建管道后会在内核分配一个缓冲区,并返回两个文件描述符,父进程和子进程都持有读写这两个文件描述符。我们在进程间通信的时候,因为管道是单向数据流通,所以只有一个进程写一个进程读,比如上面的程序,我们让子进程写,让父进程读,但这并不代表父进程不持有写端文件描述符。问题就在这里,虽然子进程已经变成了僵尸进程,但是父进程依然持有写端文件描述符,所以父进程就会认为还存在其他进程来写入管道,于是父进程就会等待写入,而不退出。
解决方法就是,我们在进程间通信时,要保证数据单向流通,在读进程中关闭管道的写端文件描述符,在写进程中关闭管道的读端文件描述符。我们依据这个原则来改造一下上面的程序即可。
/************************************************************
>File Name : mpsgrep_02.c
>Author : Mindtechnist
>Company : Mindtechnist
>Create Time: 2022年05月21日 星期六 18时08分56秒
************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char* argv[])
{
int fd[2];
pipe(fd);
pid_t pid = fork(); /*一个进程执行ps一个进程执行grep来实现 ps | grep*/
if(pid == 0) /*子进程执行ps*/
{/*把ps的执行结果传给grep,所以子进程写,父进程读*/
/*关闭读端文件描述符,保证数据单向流通*/
close(fd[0]);
/*首先把ps命令的执行结果重定向到管道的写端(默认将执行结果输出到stdout)*/
dup2(fd[1], STDOUT_FILENO);
/*拉起ps进程*/
execlp("ps", "ps", "aux", NULL);
}
if(pid > 0) /*父进程执行grep*/
{
/*关闭写端文件描述符,保证数据单向流通,防止读进程阻塞*/
close(fd[1]);
/*把grep读取重定向到fd[0],因为默认grep是在stdin获取输入的*/
/*如果在shell命令行使用grep,模式是在标准输入中匹配*/
dup2(fd[0], STDIN_FILENO);
/*拉起grep进程*/
execlp("grep", "grep", argv[1], NULL);
}
return 0;
}
这样,父进程就不会阻塞等待,而是直接退出,而子进程也不会产生僵尸进程。
使用管道进行进程间通信的时候,假设没有设置O_NONBLOCK标志(也就是说都是阻塞I/O操作),有以下几种特殊情况
其实,总的来说可以分为读管道和写管道两种的情况
/************************************************************
>File Name : pipe_test2.c
>Author : Mindtechnist
>Company : Mindtechnist
>Create Time: 2022年05月21日 星期六 17时53分56秒
************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(int argc, char* argv[])
{
int fd[2];
pipe(fd);
pid_t pid = fork();
if(pid == 0)
{
sleep(3);
close(fd[0]); /*关闭读端*/
char str[] = "hello pipe...\n";
write(fd[1], str, sizeof(str));
close(fd[1]); /*关闭写端*/
while(1)
{
sleep(1);
}
}
if(pid > 0)
{
close(fd[1]); /*关闭写端*/
close(fd[0]); /*关闭读端*/
char buf[15] = {0};
int status;
wait(&status);
if(WIFSIGNALED(status))
{
printf("kill: %d\n", WTERMSIG(status));
}
while(1)
{
int ret = read(fd[0], buf, sizeof(buf));
if(ret > 0)
{
write(STDOUT_FILENO, buf, ret);
}
}
}
return 0;
}
使用命令查看
ulimit -a
管道大小是8个512byte的大小。
也可以使用函数fpathconf()查看
#include <unistd.h>
long fpathconf(int fd, int name);
/*fd可以是fd[0]或fd[1],name是一个选项*/
实际上使用 ulimit -a 看到的是内核给管道的大小,但是管道的容量实际上可能要比这个值大。
FIFO命名管道,也叫有名管道,来区分管道pipe。管道pipe只能用于有血缘关系的进程间通信,但通过FIFO可以实现不相关的进程之间交换数据。FIFO是Linux基础文件类型中的一种,但是FIFO文件在磁盘上没有数据块,仅仅用来标识内核中的一条通道。各进程可以打开这个文件进行read/write操作,实际上是在读写内核通道,这样就实现了进程间通信。
创建FIFO的方式:
实际上,创建一个FIFO命名管道的时候,内核会为FIFO(伪)文件开辟一个缓冲区,操作FIFO文件就相当于操作这个缓冲区,以此来实现进程间的通信,这种通信实际上就是文件读写的操作来实现的。(可以把FIFO理解为一个文件,一个进程向该文件写数据,另一个进程从该文件中读书数据,前提是两个进程读写的是同一个FIFO文件才能实现通信)
示例:使用FIFO实现进程间通信
创建两个进程,一个进程向FIFO写数据,一个进程从FIFO读数据。
/************************************************************
>File Name : write_fifo.c
>Author : Mindtechnist
>Company : Mindtechnist
>Create Time: 2022年05月21日 星期六 22时38分08秒
************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main(int argc, char* argv[])
{
if(argc < 2)
{
printf("not fount fifoname\n");
return -1;
}
/*打开一个fifo文件*/
int fd = open(argv[1], O_WRONLY);
/*写FIFO文件*/
char buf[256];
int count = 1;
while(1)
{
memset(buf, 0, sizeof(buf));
/*循环写入*/
sprintf(buf, "count %04d", count++);
/*写入FIFO*/
write(fd, buf, strlen(buf));
sleep(1);
}
close(fd);
return 0;
}
/************************************************************
>File Name : read_fifo.c
>Author : Mindtechnist
>Company : Mindtechnist
>Create Time: 2022年05月22日 星期日 09时54分37秒
************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main(int argc, char* argv[])
{
if(argc < 2)
{
printf("not found fifoname\n");
return -1;
}
int fd = open(argv[1], O_RDONLY);
char buf[256] = {0};
int ret;
while(1)
{
memset(buf, 0, sizeof(buf));
ret = read(fd, buf, sizeof(buf));
if(ret > 0)
{
printf("read buf: %s\n", buf);
}
}
close(fd);
return 0;
}
编译两个程序生成可执行文件,并使用命令mkfifo创建一个FIFO
测试的时候,我们在SecureCRT中克隆一个会话(相当于在Linux中打开两个shell终端),一个运行写进程,一个运行读进程。要注意的是,应该先运行写进程再运行读进程。
同时我们也可以打开多个进程去读写这同一个FIFO缓冲区,当多个进程去读的时候,被读进程1读走的数据就不会再被进程2读取了,比如说下面图中所示,这个进程读到的是36 38 40,而另一个进程读到的是37 39 41。(也可以开启多个写进程去写,读者可自行测试)
我们在读写FIFO的时候都使用了open()函数,使用open函数的时候有一个注意事项,使用open打开FIFO文件的时候,read端会阻塞等待write端open打开文件,直到write进程也使用open打开FIFO的时候,read进程中的open才会返回,反过来也是一样。我们可以在open函数的前后分别打印一句话来测试,只有读端写端都open了FIFO,第二个printf()语句才能打印。实际上只要有一个write和一个read打开了FIFO,就可以,不管是同一个进程还是多个进程。
printf("hello open...\n");
int fd = open(argv[1], O_WRONLY);
printf("bye open...\n");
附:通过一个makefile编译多个程序
.PHONY:all clean
CC=gcc
CFLAGS=-Wall -g
EXE=write_fifo read_fifo
all:$(EXE)
%.o:%.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
-@rm -f *.o $(EXE)