文章目录
- 进程间通信
- 进程间通信概述
- 进程间通信的方式
- 管道通信
- 示例--基于管道的父子进程通信
- 示例--使用管道进程兄弟进程通信
- 管道的读写特性
- 示例--不完整管道(读一个写端关闭的管道)
- 示例--不完整管道(写一个读端关闭的管道)
- 标准库中的管道操作
- 示例--使用popen函数进行管道的读写
- 命名管道(FIFO)的创建
- 示例--使用FIFO进行进程间通信
- 匿名管道和命名管道的异同
进程间通信
进程间通信概述
- 数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几兆字节之间(例如一个进程要将某一个计算结果发送给另外一个进程使用,这时候就会用到进程间通信)。
- 共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立即看到(例如就像线程中的全局变量一样,一个线程修改别的线程立马就能看到)。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它们发生了某种事件(例如子进程在结束时会产生一个
SIGCHILD
信号通知父进程去回收子进程的资源)。 - 资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供锁和同步机制(例如多个进程在操作同一个文件的时候就会涉及到资源共享的问题,需要靠文件锁等机制来实现进程之间的同步和互斥).
- 进程控制:有些进程希望完全控制另一个进程的执行,此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变(例如常用的
gdb
调试)
进程间通信的方式
- 匿名管道(pipe)和命名管道(FIFO)
- 信号(signal)
- 消息队列
- 共享内存
- 信号量(进程的信号量和线程信号量不一样)
- 套接字(socket)
管道通信
- 管道是针对于本地计算机的两个进程之间的通信而设计的通信方法,管道建立后,实际获得两个文件描述符,一个用于读取一个用于写入。后续对管道的操作都可以像对普通文件一样使用
read
和write
函数对管道进行读取和写入。 - 最常见的IPC机制,通过
pipe
系统调用 - 管道是单工的,也就是说数据只能向一个方向流动,需要双工通信时,需要建立起两个管道。
- 数据的读出和写入:一个进程向管道中写的内容被另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据(管道实际上就是位于内核中的一个缓冲区,当数据被从缓冲区读走以后,数据会从缓冲区移除,即管道中的数据读走即无)。
管道分类
-
匿名管道
- 通过系统调用
pipe()
函数创建管道 - 在有血缘关系的进程中进行(例如父进程和子进程、兄弟进程之间)
- 管道位于内核空间,实质是一块缓存
- 通过系统调用
-
命名管道
-
两个没有任何关系的进程之间通信可通过命名管道进行数据传输,本质是内核中的一块缓存,另外文件系统中以一个特殊的设备文件(管道文件,前边将文件系统的时候有讲过)存在。只要对管道文件进行读写就能够同步到内核空间的那片缓存中去。
-
通过指令
mkfifo
或者系统调用mkfifo()
函数创建
匿名管道的创建
#include <unistd.h>int pipe(int fd[2]);/*功能:创建一个管道,用于进程间的通信参数:一个数组,用于存储读端文件描述符和写端文件描述符返回值:成功执行返回0,失败返回-1fd[0]用于从管道中读取数据,fd[1]用于向管道中写入数据 */
管道的读写
匿名管道主要用于有血缘关系的进程,尤其是父子进程之间的关系。父进程调用
pipe()
函数创建一个管道,然后调用fork()
函数创建一个子进程。这里有两点需要注意:
- 通过之前的章节可知当通过
fork()
函数创建子进程的时候,子进程会继承父进程的代码段、数据段、堆、栈等内容,所以当在父进程中定义了一个数组用于存储读端和写端的文件描述符的时候,由于定义的数组位于栈区,所以这个数组也会被子进程继承。而父子进程中的数组是相同的内容,它们指向了管道的读端和写端,所以父子进程可以通过管道进行通信。 - 根据管道的特性可知,管道是单工的。所以即使这里父子进程都有读端和写端,数据也只能从一个方向进行传输。也就是说同一时间只能有父进程向管道中写入子进程从管道中读取或者子进程向管道中写入父进程从管道中读取,所以通过
fork()
函数创建子进程后父子进程必须关闭一端然后参能进行通信。如果想要同一时间进行双方通信,必须要建立两个管道。
-
示例–基于管道的父子进程通信
#include "header.h"int main(void)
{int pipe_fd[2];pid_t pid;//父进程通过pipe函数创建管道用于父子进程间通信if(pipe(pipe_fd) != 0){perror("pipe error");exit(EXIT_FAILURE);}if((pid = fork()) < 0){perror("fork error");exit(EXIT_FAILURE);}//父进程向管道中写入,关闭读端else if(pid > 0){int start = 1, end = 100;close(pipe_fd[0]); //父进程关闭读端if(write(pipe_fd[1], &start, sizeof(int)) != sizeof(int)){perror("write error");exit(EXIT_FAILURE);}if(write(pipe_fd[1], &end, sizeof(int)) != sizeof(int)){perror("write error");exit(EXIT_FAILURE);}close(pipe_fd[1]); //写入完成后关闭写端wait(NULL); //等待子进程退出并回收它的资源}//子进程从管道中读取,关闭写端//父进程从尾部写入,子进程从头部读取else{int start, end;close(pipe_fd[1]);if(read(pipe_fd[0], &start, sizeof(int)) < 0){perror("read error");exit(EXIT_FAILURE);}if(read(pipe_fd[0], &end, sizeof(int)) < 0){perror("read error");exit(EXIT_FAILURE);}close(pipe_fd[0]); //读取完成后关闭读端printf("child process read data start:%d end:%d\n",start,end);}return 0;
}
示例–使用管道进程兄弟进程通信
在Linux系统中经常会用到
grep
这个指令,grep
指令的作用是在文件中搜索文本或者字符串,并打印出匹配的行。它经常会配合别的指令一起使用,例如:cat /etc/passwd | grep root
,这个指令的作用是查看passwd
这个文件里边有关root
的文本。cat
和grep
是两个指令,前边有讲过在shell上执行的指令都属于这个shell的子进程,所以它其实相当于执行了两个进程,进程1使用cat
指令来将passwd
这个文件里边的内容全部输出出来,进程2使用grep
指令根据这些内容过滤出有关root
字样的文本。在中间有一个字符|
表示的就是一个管道,因为cat
指令默认是输出到标准输出(屏幕),而grep
指令默认是从标准输入(键盘)中获取,所以这里加一个管道|
,将cat
指令执行的结果写入到管道里,然后grep
指令从管道中读取并将与root
字样有关的文本打印出来
#include "header.h"int main(void)
{int pipe_fd[2];int i = 0;pid_t pid;char *cmd1[] = {"cat", "/etc/passwd", NULL};char *cmd2[] = {"grep", "root", NULL};//父进程创建管道用于在兄弟进程中通信if(pipe(pipe_fd) < 0){perror("pipe error"); exit(EXIT_FAILURE);} //父进程创建子进程for(; i < 2; i++){if((pid = fork()) < 0){perror("fork error");exit(EXIT_FAILURE);}else if(pid == 0){//子进程1用于获取cat指令执行的结果并写到管道中去if(i == 0){close(pipe_fd[0]); //子进程1用于向管道中写入,关闭读端//将标准输出重定向到管道的写入端,将cat的内容写入到管道中去//重定向后标准输出就指向了管道的写入端if(dup2(pipe_fd[1], STDOUT_FILENO) != STDOUT_FILENO){perror("dup2 error");exit(EXIT_FAILURE);}close(pipe_fd[1]); //此时标准输出的指向和之歌相同,所以关闭原来的文件描述符if(execvp(cmd1[0], cmd1) == -1){perror("execvp error");exit(EXIT_FAILURE);} }//子进程2用于从管道中获取并过滤相应的关键字 if(i == 1){close(pipe_fd[1]); //子进程2用于从管道中读取,关闭写端//grep指令默认会从标准输入中读取,所以要将标准输入重定向到管道的读端//然后利用grep过滤出来if(dup2(pipe_fd[0], STDIN_FILENO) != STDIN_FILENO){perror("dup2 error");exit(EXIT_FAILURE);}close(pipe_fd[0]); //经过重定向后标准输入和管道的读端文件描述符指向相同,所以关闭原来的文件描述符if(execvp(cmd2[0], cmd2) == -1){perror("execvp error");exit(EXIT_FAILURE);}}break;}else {if(i == 1){//父进程只做创建管道和创建子进程的事情,不对管道进行操作//所以这里关闭父进程中的文件描述符close(pipe_fd[0]);close(pipe_fd[1]);//等待两个子进程退出并回收它的资源wait(NULL);wait(NULL);}}} return 0;
}
cat /etc/passwd | grep root
通过编译执行可以看到两个执行结果是相同的,在shell终端执行的命令|
就等同于pipe()
系统调用,通过管道配合grep
指令可以过滤出用户所需要的文本。
代码中有几点需要注意:
cat
指令它默认是输出到标准输出里的,而grep
指令它默认是从标准输入中去获取的,所以这里要使用dup2
函数将标准输出重定向到管道的写端,将标准输入重定向到管道的读端。通过这个操作两个兄弟进程就能够通过管道进行通信。- 在这个代码中,父进程的作用是建立管道和创建子进程,所以它并没有对管道进行操作。在父进程中就要把管道的读端和写端关闭,但是一定要等待创建子进程全部创建完成后才能关闭文件描述符,若子进程还没有创建完成就关闭文件描述符,那么后来的子进程它的文件描述符就是无效的。
管道的读写特性
-
通过打开的两个管道来创建一个双向的管道(管道是单工通信的,若想要实现双方之间的通信,就要建立两个管道实现双方之间的收发)
-
管道是阻塞性的,当进程从管道中读取数据,若没有数据进程会阻塞(和之前的普通文件不一样,若普通文件中没有数据,读取的时候会直接返回。若是一个进程读取一个没有数据的管道时它会阻塞知道管道中有数据写入它才会退出阻塞状态)
-
当一个进程往管道中不断地写入数据但是没有进程去读取数据,此时只要管道没有满是可以的,但如果管道中放满数据则会报错。
-
不完整管道
- 完整性管道指的就是写端和读端都打开的管道(上边说的特性都是针对完整性管道的),而不完整管道就是读端或者写端中的任意一端被关闭的管道。
- 当读一个写端已经被关闭的管道时,在所有数据被读取后,
read
返回0,以表示到达了文件尾部。 - 如果写一个读端已经被关闭的管道,则产生信号
SIGPIPE
,如果忽略或者捕捉该信号并从处理程序中返回,则write
返回-1,同时errno
设置为EPIPE
。 - 不完整管道的应用场景:后边的网络编程它是两个进程(客户端和服务器端)基于网络的通信,通信的方式类似于管道。当其中的某一方出现问题导致网络通信出现异常的时候,就可以像判断不完整管道的方式来判断网络通信是否出现了问题,然后基于此问题做相应的操作。
示例–不完整管道(读一个写端关闭的管道)
#include "header.h"/**创建不完整管道:读一个写端关闭的管道*/int main(void)
{int pipe_fd[2];pid_t pid;if(pipe(pipe_fd) < 0){perror("pipe error");exit(EXIT_FAILURE);}if((pid = fork()) < 0){perror("fork error");exit(EXIT_FAILURE);}else if(pid > 0) //父进程等待子进程写入,然后从管道中读取{sleep(2); close(pipe_fd[1]); //关闭管道的写端char c;while(1){if(read(pipe_fd[0], &c, 1) != 0){printf("%c",c);}else{printf("\nread the end of pipe\n");break;}}wait(NULL);}else //子进程关闭读端,先向管道中写入数据后关闭写端 {close(pipe_fd[0]);char *s = "1234";if(write(pipe_fd[1], s, strlen(s)) != strlen(s)) //向管道中写入数据{perror("write error");exit(EXIT_FAILURE);}close(pipe_fd[1]); //关闭管道的写端}return 0;}
通过编译执行可以发现读一个写端已经被关闭的管道,当读到管道的末尾会返回0
示例–不完整管道(写一个读端关闭的管道)
#include "header.h"/** 创建不完整管道:写一个读端关闭的管道*/void sig_handler(int signum)
{if(signum == 13){printf("receive a signal is SIGPIPE\n");}
}int main(void)
{int pipe_fd[2];pid_t pid;//向内核注册信号和信号处理函数,如果发生信号就去执行相应的处理函数if(signal(SIGPIPE, sig_handler) == SIG_ERR){perror("signal error");}//父进程创建管道if(pipe(pipe_fd) < 0){perror("pipe error");exit(EXIT_FAILURE);}if((pid = fork()) < 0){perror("fork error");exit(EXIT_FAILURE);}else if(pid > 0) //父进程等待子进程关闭读端然后再向管道中写入{sleep(2); //睡眠2秒,保证子进程已经将读端关闭close(pipe_fd[0]); //父进程关闭读端char *s = "1234";if(write(pipe_fd[1], s, strlen(s)) != strlen(s)){fprintf(stderr,"%s:%s\n",strerror(errno),(errno == EPIPE)?"EPIPE":"unknown"); //当向已经关闭读端的管道中写入的时候会产生SIGPIPE信号,同时errno被设置为EPIPE}close(pipe_fd[1]); //写完后关闭写端wait(NULL); }else //子进程将读端和写端的管道都关闭{close(pipe_fd[0]);close(pipe_fd[1]);}return 0;
}
通过编译执行可以看到当写一个读端被关闭的管道时会产生一个SIGPIPE
信号,同时errno
会被置为EPIPE
。
标准库中的管道操作
通过上边的案例可以看出基于匿名管道的进程间通信较为复杂,在标准库中有一个将管道的操作封装成一个函数popen()
。
标准库中的管道操作
#include <stdio.h>FILE *popen(const char *command, const char *mode);
int pclose(FILE *stream);/*功能:popen函数创建一个管道以启动新进程并与其进行通信参数:command 要执行的命令字符串,该字符串包含一个shell命令, 以NULL结尾mode 指向一个以NULL结尾的字符串的指针,该字符串必须是r(只读)或w(只写)之一stream 文件流指针返回值:popen()如果执行成功,popen返回一个文件流指针,如果失败返回
NULLpclose 如果执行成功返回传递给popen()的参数cmd指定的子进程的终止状态,如果失败,则pclose返回-1,并设置errno来指示错误
*/
popen函数的内部实现流程
示例–使用popen函数进行管道的读写
#include "header.h"int main(void)
{FILE *fp; char buffer[128];//命令执行的结果放置在fp指向的结构体指针中//内部实现流程:子进程去执行popen传入的cmd,然后将执行的结果重定向到管道的写端//父进程然后使用标准库函数从管道中读取,然后将数据存放到fp所指向的缓存中去fp = popen("cat /etc/passwd | grep root", "r");if(fp == NULL){perror("fopen error");exit(EXIT_FAILURE);}memset(buffer, '\0', sizeof(buffer)); while(fgets(buffer, sizeof(buffer), fp)){printf("%s",buffer);}pclose(fp);printf("--------------------------------------\n");//父进程将内容写入到文件流指针所指向的缓存区域,然后popen函数会将内容写入到管道中,子进程会做一个将标准输入重定向到管道的读端的操作然后从管道中获取数据,最后根据cmd指令指向x应的fp = popen("wc -l", "w");fprintf(fp, "%s", "hello\nhaha\nlala\n");pclose(fp);return 0;
}
通过编译执行可以发现这个函数的执行类似于system
函数,但是system
函数它的运行是直接输出到标准输出去的,并没有输入到管道。但是如果需要将指令的执行结果存放到某个地方的话,使用popen
函数比较适合。
命名管道(FIFO)的创建
#include <sys/types.h>
#include <sys/stat.h>int mkfifo(const char *pathname, mode_t mode);/*功能:创建命名管道用于进程间的通信参数:pathname 要创建管道的路径mode 创建管道的权限返回值:若成功执行返回0,出错返回-1
*/
- 命令
mkfifo
创建命名管道(命令内部调用mkfifo
函数) - 命名管道和 匿名管道一样,实质上是内核中的一块缓存,但是命名管道会在文件系统中存在一种特殊的设备文件(管道文件)。用户进程对管道文件进行读写操作会直接同步到内核中的缓存。
- 只要对
FIFO
有适当的权限,FIFO
可用在任何两个没有血缘关系的进程之间通信,和匿名管道不一样,它通过系统调用pipe()
函数创建只能用于有血缘关系的进程(父子进程和兄弟进程)。 - 管道文件系统中只有一个索引块存放文件的路径,没有数据块,所有的数据存放在内核中。和之前的普通文件系统不一样,之前的文件有一个索引和数据块,数据就存放在数据块中。
- 命名管道必须读和写同时打开,否则单独读或者单独写会引发阻塞
- 对
FIFO
的操作与操作普通文件一样,使用mkfifo
创建了一个FIFO
,就可以用open
打开它,一般的文件I/O函数(read
、write
、close
…)等都可用于FIFO
FIFO
相关出错信息EACCESS
(无存取权限)EEXIST
(指定文件不存在)ENAMETOOLONG
(路径名太长)ENOENT
(包含的目录不存在)ENOSPC
(文件系统剩余空间不足)ENOTDIR
(文件路径无效)EROFS
(指定的文件只存在于只读文件系统中)
示例–使用FIFO进行进程间通信
//fifo_r.c#include "header.h"int main(int argc, char **argv)
{if(argc < 2){fprintf(stderr,"usage:%s pathname\n",argv[0]);exit(EXIT_FAILURE);}//创建管道,管道的权限为文件的拥有者和同组人有可读可写可执行的权限,其他人只有可读权限if(mkfifo(argv[1], S_IRWXU | S_IRWXG | S_IROTH) < 0){perror("mkfifo error");exit(EXIT_FAILURE);}int fd;char buffer[32];fd = open(argv[1], O_RDONLY); //以只读的权限打开管道if(fd < 0){perror("open file error");exit(EXIT_FAILURE);}printf("open pipe read....\n");memset(buffer, '\0', sizeof(buffer));if(read(fd, buffer, sizeof(buffer)) < 0){perror("read error");exit(EXIT_FAILURE);}printf("%s\n",buffer);close(fd); //操作完文件后将文件描述符关闭return 0;
}
//fifo_w.c#include "header.h"int main(int argc, char **argv)
{if(argc < 2){fprintf(stderr,"usage:%s pathname\n",argv[0]);exit(EXIT_FAILURE);}int fd = open(argv[1], O_WRONLY);char *str = "hello world";if(fd < 0){perror("open file error");exit(EXIT_FAILURE);}printf("open file write....\n");if(write(fd, str, strlen(str)) != strlen(str)){perror("write error");exit(EXIT_FAILURE);}close(fd);return 0;
}
通过编译执行可以发现对于命名管道的操作实际上和操作普通文件无异,对于管道的来说它是阻塞性的,所以当管道里没有数据的时候读端会阻塞。而对于命名管道来说,当只有读端或者写端执行的时候它会阻塞,必须读端和写端都运行才能够从管道里获取数据。
匿名管道和命名管道的异同
-
相同点
-
作用相同:都用于不同进程间的通信
-
数据流向:匿名管道创建两个管道后允许数据双向传输,命名管道也支持数据的双向传输
-
缓冲区:都可以使用内核缓冲区来存储传输的数据,允许发送和接收过程之间的解耦。
-
-
不同点
- 创建与管理:
- 命名管道:通过系统调用
mkfifo()
或者使用指令mkfifo
创建,创建后会生成一个管道文件,后续都可以基于这个文件进行操作(和操作普通文件一样,使用open
,read
,write
,close
等API进行操作),后续使用完管道以后还需要使用close
函数关闭文件描述符。 - 匿名管道:通过系统调用
pipe()
函数创建,创建后会返回两个文件描述符,其中fd[0]
表示管道的读端,fd[1]
表示管道的写端。通过这两个文件描述符来实现基于匿名管道的进程间通信。使用完后会自动将匿名管道销毁。
- 命名管道:通过系统调用
- 命名
- 命名管道:由于创建命名管道后会在文件系统中存在一个特殊的设备文件(管道文件),所以叫做命名管道,它们的生命周期可以独立于创建它们的进程直到用户显式操作将其删除。
- 匿名管道:没有名字
- 使用范围:
- 命名管道:可用于任何两个进程之间的通信,通常在不同的用户会话或系统的进程之间使用。
- 匿名管道:只能用于有血缘关系的进程,例如兄弟进程和父子进程。
- 创建与管理: