进程间通信
- 1. 进程间通信的概念
- 2. 匿名管道pipe
- 3. 命名管道FIFO
- 4. 内存映射区
1. 进程间通信的概念
在Linux的环境下,进程地址空间是相互独立的,每个进程有着各自不同的用户地址空间。一个进程不能访问另一个进程中的内容,要进行数据交换必须要通过内核,在内核中开辟块缓冲区,一个进程将数据从用户空间拷贝到内核缓冲区中,另一个进程从内核缓冲区中将数据读走。内核提供的这种机制称为进程间通信(IPC)。
在进程间完成数据传输需要借助操作系统提供的特殊方法,比如文件、管道、FIFO、内存映射区、消息队列、信号、套接字等。如今常用的进程间通信主要有管道(最简单)、信号(开销最小)、内存映射区(无血缘关系)、本地套接字(最稳定)。
2. 匿名管道pipe
管道是一种最基本的进程间通信机制,也称为匿名管道,应用于有血缘关系的进程间进行通信。管道的本质是一块内核缓冲区,内部使用的环形队列实现,由两个文件描述符进行引用,其中一个表示读端,另一个表示写端。管道的数据从管道的写端流入管道,从读端流出。当两个进程都死亡的时候,管道也会自动消失。管道不管读端还是写端默认都是阻塞的。管道的默认缓冲区大小为4K,可以使用ulimit -a
命令获取大小。
管道的数据一旦被读走,便不在管道中存在,不可以反复读取。管道的数据只能在一个方向上流动,如果需要实现双向流动则需要使用两个管道。匿名管道只能在有血缘关系的进程中使用。我们用pipe
函数来创建管道。
pipe
函数的原型如下:
#include <unistd.h>
int pipe(int pipefd[2]); // 创建管道
其中pipefd
为管道写端和读端的文件描述符,其中pipefd[0]
为管道读端的文件描述符,pipefd[1]
为管道写端的文件描述符。当函数调用成功创建了管道返回0,失败则返回-1并设置errno。
在使用匿名管道进行通信的时候,一般是先用pipe
函数创建管道,再使用fork
函数创建子进程。这样父子进程就具有了相同的文件描述符,就会指向同一个管道。
在管道的通信中,读写数据也是使用read
和write
。管道的示例代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
int main()
{
// 创建管道
//int pipe(int pipefd[2]);
int fd[2];
int ret = pipe(fd);
if(ret < 0)
{
perror("pipe error");
return -1;
}
// 创建子进程
pid_t pid = fork();
if(pid < 0)
{
perror("fork error");
return -1;
}
else if(pid == 0)
{
// 子进程关闭写端
close(fd[1]);
char buf[1024];
memset(buf, 0x00, sizeof(buf));
sleep(5);
read(fd[0], buf, sizeof buf);
printf("child: read over, pid = [%d], fpid = [%d], buf = [%s]\n", getpid(), getppid(), buf);
}
else
{
// 父进程关闭读端
close(fd[0]);
write(fd[1], "helloworld", strlen("helloworld"));
printf("father: write over, pid = [%d], fpid = [%d]\n", getpid(), getppid());
pid_t wpid = wait(NULL);
printf("child [%d] is dead!\n", wpid);
}
return 0;
}
在shell中我们查询某一个进程的时候我们会使用ps -ef | grep --color=auto bash
,这里的|
就是管道。我们使用父进程进程去执行ps -ef
命令,由于我们需要交给grep
去作为输入,所以在父进程中需要将输出重定向到管道的写端。子进程执行grep
的时候会从输入进行读取内容,因此我们需要将输入重定向到管道读端。重定向的时候需要使用dup2
函数,需要执行命令则需要使用execl
和execlp
函数,示例程序如下:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
int main()
{
// 创建管道
int pipefd[2];
int ret = pipe(pipefd);
if(ret < 0)
{
// 创建失败
perror("pipe error");
return -1;
}
// 创建子进程
pid_t pid = fork();
if(pid < 0)
{
// 创建失败
perror("fork error");
return -1;
}
else if(pid == 0)
{
// 子进程关闭写端
close(pipefd[1]);
// 将标准输入重定向到管道读端
dup2(pipefd[0], STDIN_FILENO);
execlp("grep", "grep", "--color=auto", "bash", NULL);
perror("execlp error");
}
else
{
// 父进程关闭读端
close(pipefd[0]);
// 将标准输出重定向到管道写端
dup2(pipefd[1], STDOUT_FILENO);
execlp("ps", "ps", "-ef", NULL);
perror("execlp error");
}
return 0;
}
当管道有数据的时候,read
可以正常读,并返回读出来的字节数;当管道没有数据的时候,若写端全部关闭,则read
函数解出阻塞状态,返回0,相当于读文件读到了尾部。若写端没有关闭,则read
阻塞。
若读端全部关闭,进行写操作的时候则管道会破裂,进程终止,内核会给当前进程发送SIGPIPE
信号。若读端没有完全关闭,缓冲区写满了则write
会阻塞,缓冲区没有满则可以继续write
。
管道默认两端都是阻塞的,若要设置为非阻塞,则可以使用前面提过的fcntl
函数。首先使用F_GETFL
获取flags,然后在添加O_NONBLOCK
使用F_SETFL
设置即可。当读端设置为非阻塞状态的时候,会有以下四种情况:
- 写端没有关闭,管道中没有数据可读,则
read
返回-1。 - 写端没有关闭,管道中有数据可读,则
read
返回实际读到的字节数。 - 写端已经关闭,管道中有数据可读,则
read
返回实际读到的字节数。 - 写端已经关闭,管道中没有数据可读,则
read
返回0。
设置的流程为:
int flags = fcntl(fd[0], F_GETFL, 0);
flags = flags | O_NONBLOCK;
fcntl(fd[0], F_SETFL, flags);
除了前面使用的ulimit -a
可以查看到管道缓冲区的大小之外,也可以使用fpathconf
函数,该函数的原型为:
#include <unistd.h>
long fpathconf(int fd, int name);
这个函数会根据name
的参数设置返回文件描述符fd
对应文件的配置内容。比如获取管道缓冲区则需要把name
设置为_PC_PIPE_BUF
。如下面查看管道的缓冲区大小。
printf("pipe size = [%ld]\n", fpathconf(fd[0], _PC_PIPE_BUF));
printf("pipe size = [%ld]\n", fpathconf(fd[1], _PC_PIPE_BUF));
更多的name
参数设置可以使用man fpathconf
查询帮助文档。
3. 命名管道FIFO
使用pipe
管道只能实现有血缘关系的两个进程间的通信,那么对于两个没有血缘关系的进程又应该怎么实现呢?则需要这一节的命名管道FIFO
。
FIFO
是Linux上基于文件类型的一种通信方式,文件类型为p
,但是FIFO
在磁盘上并没有数据块,文件大小为0,仅仅用于标识内核中的一条通道。进程可以通过read
或者write
函数去对这一条通道进行操作,也就是内核缓冲区,这样就实现了进程间的通信。
要使用命名管道,就得先创建命名管道文件,创建管道文件使用mkfifo
函数,该函数的原型为:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
其中参数第一个pathname
表示管道的路径名,mode
表示权限,使用一个三位八进制数表示。创建成功函数返回0
,创建失败则函数返回-1
并设置errno
。
FIFO
严格遵守先进先出的规则,也就是对于管道的读总是从管道的开始处返回数据,而对于管道的写则是添加到末尾。因此命名管道不支持使用lseek
等对文件定位的操作。
使用FIFO
完成进程间通信的示意图如下:
既然是两个进程,也就是说两个进程中的程序都需要打开管道,也就是需要找到文件。若写进程没有打开就去使用读进程,则可能因为管道的文件不存在而报错。因此我们在使用管道或者创建管道之前要先判断文件是否存在。这个可以使用access
函数实现,该函数的原型为:
#include <unistd.h>
int access(const char *pathname, int mode);
该函数的第一个参数pathname
是文件的路径,第二个参数mode
表示要测试的模式,有四个参数可以传,分别为:
F_OK
: 文件存在R_OK
:有读权限W_OK
:有写权限X_OK
:有执行权限
当有对应的权限或者文件存在的时候,该函数的返回值为0
,若没有对应权限或者文件不存在则该函数返回-1
。
接下来我们实现两个无血缘关系之间的进程间通信。代码如下:
fifo_write.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
// FIFO 写进程
int main()
{
// 创建FIFO文件
// int access(const char *pathname, int mode);
int ret = access("./myfifo", F_OK);
if(ret < 0)
{
// int mkfifo(const char *pathname, mode_t mode);
ret = mkfifo("./myfifo", 0777);
if(ret < 0)
{
perror("mkfifo error");
return -1;
}
}
// 打开FIFO
int fd = open("./myfifo", O_RDWR);
if(fd < 0)
{
perror("open error");
return -1;
}
// 传输数据
char buf[1024] = "hello world";
write(fd, buf, strlen(buf));
sleep(1);
// 关闭FIFO
close(fd);
return 0;
}
fifo_read.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
// FIFO 读进程端
int main()
{
// 判断创建FIFO
int ret = access("./myfifo", F_OK);
if(ret < 0)
{
int ret = mkfifo("./myfifo", 0777);
if(ret < 0)
{
perror("mkfifo error");
return -1;
}
}
// 打开FIFO
int fd = open("./myfifo", O_RDWR);
if(fd < 0)
{
perror("open error");
return -1;
}
// 接收数据
char buf[1024];
memset(buf, 0x00, sizeof buf);
read(fd, buf, sizeof buf);
printf("%s\n", buf);
// 关闭FIFO
close(fd);
return 0;
}
在上述代码中,只要运行,不管是读进程还是写进程先运行,只要写进程没有往管道中写数据,读进程都会阻塞在read
处。直到写进程调用write
函数往管道中写数据之后读进程才会读出来管道中的数据输出。
4. 内存映射区
内存映射区是将一个磁盘文件与内存空间的一个缓冲区向映射。当我们在缓冲区中读取数据的时候,就相当于在文件中读取相应的字节。若我们向缓冲区中写数据,则会把数据写入对应的文件之中。这样就可以在不使用read
函数和write
函数啊的情况下使用指针完成IO操作。
映射这个过程可以使用mmap
函数实现。解除映射可以使用munmap
实现。函数的原型以及参数如下:
#include <sys/mman.h>
// 函数作用: 建立存储映射区
// 返回值: 成功:返回创建的映射区首地址
// 失败: 返回MAP_FAILED宏, 实际上就是(void *)-1
// 参数: addr: 指定映射的起始地址,通常设为NULL,由系统指定
// length: 映射到内存的文件长度
// prot: 映射区的保护方式,最常用的有
// PROT_READ 读
// PROT_WRITE 写
// PROT_READ | PORT_WRITE 读写
// flags: 映射区的特性,可以设置以下
// MAP_SHARED 写入映射区的数据会写回文件,且允许
// 其它映射改文件的进程共享。
// MAP_PRIVATE 对映射区的写入操作会产生一个映射区的复
// 制,对此区域所做的修改不会写回原文件。
// MAP_ANONYMOUS 匿名映射区,需要结合MAP_SHARED使用。
// fd: 代表要映射的文件,由open函数返回的文件描述符
// offset: 以文件开始出的偏移量,必须是4K的整数倍,通常为0,表示
// 从文件头开始映射
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
// 函数作用: 释放由mmap函数建立的存储映射区
// 返回值: 成功返回0,失败返回-1并设置errno
// 参数: addr: 调用mmap函数成功返回的映射区首地址
// length: 映射区的大小
int munmap(void *addr, size_t length);
需要注意到的是,mmap
函数不能开辟长度为0的存储映射区。在使用mmap
函数创建映射区的过程中,隐含着一次对映射文件的读操作,将文件读取到映射区。当我们将flags
设置为MAP_SHARED
的时候,要求映射区的权限应该小于或者等于文件打开的权限,这是出于对映射区的保护。而对于MAP_PRIVATE
,则是没有这个必要,因为mmap
中的权限是对内存的限制。
在映射区建立之后,映射区的释放是与文件的关闭无关的,所以在映射区建立之后就可以关闭文件。在创建映射区的时候,使用mmap
函数常常会出现总线错误,通常是因为共享文件存储空间大小引起的。所以创建映射区的时候出错的概率比较高,因此一定要检查函数的返回值,确保映射区建立成功了再进行后续的操作。使用munmap
函数的时候,传入的地址一定要是mmap
函数的返回值,一定不要改变指针的指向。其中函数的参数中offset
也是需要注意的,不能随便指定,必须要是4K的整数倍才行。
下面给出一个关于关于有血缘关系的进程间的通信示例。
//使用mmap完成有血缘关系进程间的通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
//使用mmap函数建立共享映射区
//void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int fd = open("test.log", O_RDWR);
if(fd < 0)
{
perror("open error");
return -1;
}
int len = lseek(fd, 0, SEEK_END);
void *addr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if(addr == MAP_FAILED)
{
perror("mmap error");
return -1;
}
close(fd);
//创建子进程
pid_t pid = fork();
if(pid < 0)
{
perror("fork error");
return -1;
}
else if(pid == 0)
{
//子进程
sleep(2);
char *p = (char *)addr;
printf("[%s]\n", p);
}
else
{
//父进程
memcpy(addr, "hello world", strlen("hello world"));
wait(NULL);
}
return 0;
}
共享存储映射区也可以用于没有血缘关系的两个进程。
写进程
// 使用mmap完成没有血缘关系的进程间的通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/mman.h>
// 写进程
int main()
{
// 使用mmap函数建立共享映射区
int fd = open("test.log", O_RDWR);
if(fd < 0)
{
perror("open error");
return -1;
}
int len = lseek(fd, 0, SEEK_END);
// 建立共享映射区
void *addr = mmap(NULL, len, PROT_WRITE, MAP_SHARED, fd, 0);
if(addr == MAP_FAILED)
{
perror("mmap error");
return -1;
}
close(fd);
memcpy(addr, "Good morning", strlen("Good morning"));
return 0;
}
读进程
// 使用mmap完成没有血缘关系的两个进程间的通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>
// 读进程端
int main()
{
// 使用mmap函数建立共享映射区
int fd = open("test.log", O_RDONLY);
if(fd < 0)
{
perror("open error");
return -1;
}
int len = lseek(fd, 0, SEEK_END);
// 建立共享映射区
void *addr = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0);
if(addr == MAP_FAILED)
{
perror("mmap error");
return -1;
}
char *p = (char *)addr;
printf("[%s]\n", p);
return 0;
}
上面的共享存储映射区都是有名字的,我们也可以创建匿名的共享存储映射区。匿名映射区是不需要使用文件去创建的,因此没有血缘关系的两个进程不能使用匿名映射区通信。在使用匿名共享映射区的时候,文件的描述符一般传为-1
。关于匿名共享映射区的示例代码如下:
// 建立匿名共享映射区
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <fcntl.h>
int main()
{
void *addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if(addr == MAP_FAILED)
{
perror("mmap error");
return -1;
}
pid_t pid = fork();
if(pid < 0)
{
perror("fork error");
return -1;
}
else if(pid == 0)
{
sleep(2);
char *p = (char *)addr;
printf("[%s]\n", p);
}
else
{
memset(addr, 0x00, 4096);
memcpy(addr, "hello world", strlen("hello world"));
wait(NULL);
}
return 0;
}
后续博客关于函数原型以及作用均以注释的形式写在代码中以便直观