【Linux】进程间通信
进程通信介绍
进程通信的目的
进程之间为什么需要通信?——进程之间虽然具有独立性,但还是要进行“交流”的,进程之间的通信将类似于我们打电话,是要进行数据的交流的,进程通信有如下目的:
- 数据传输:一个进程需要将它的数据传输给另一进程
- 资源共享:多个进程共享同一资源
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止
时要通知父进程)
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
如何进行进程通信
进程之间具有独立性,彼此看不到对方的代码和数据,连最基本的数据传输都做不到,如何进行通信呢?这就需要第三方介入了,这个第三方就是操作系统。由操作系统开辟一块空间,让两个进程可以在这个空间中看到同一个资源
进程通信的发展
进程通信发展出了很多不同的方案:
- 管道
- System V 进程间通信
- POSIX 进程间通信
进程通信的分类
进程通信需要遵守一定的标准,只有大家共用一套标准,才可以在不同平台、不同系统、不同软件、不同语言下都能实现互通的进程通信
进程通信的实现方式有很多
管道
匿名管道pipe
命名管道
System V IPC
System V 消息队列
System V 共享内存
System V 信号量
POSIX IPC
消息队列
共享内存
信号量
互斥量
条件变量
读写锁
匿名管道
最初设计进程通信的人不太想重新写一个通信模块,这样还需要修改操作系统的内核代码,比较麻烦。于是为了省时省力,进程通信的实现可以复用操作系统已经实现的功能。例如通过文件操作来实现管道
当一个进程分别以读和写的方式打开同一文件时,因为文件的打开方式不同,一定会产生两个不同的 struct file。此时进程没有打开其他额外文件,也就是说进程的文件描述符表中 3 号 fd 指向读文件的 file,4 号 fd 指向写文件的 file。
而结构体 file 也会包含三个关键内容:inode(文件属性)、内核级缓冲区(文件内容)、操作方法集。虽然文件打开方式不同,但是文件的属性内容相同,没必要加载两份,所以两个 struct file 指向的内容是同一份
如果此进程创建一个子进程,那么子进程会继承父进程的代码和数据,对父进程的内核数据结构稍加修改继承。问题来了,子进程会不会像父进程那样,把文件再加载一遍呢?
父子进程为了保持进程独立性,所以会使用不同的内核数据结构,暂时共享数据。但是文件是文件系统的东西啊,进程的独立性关我什么事?所以子进程会拷贝父进程的文件描述符表,父子进程打开的文件是相同的,就像浅拷贝一样
此时父子进程都可以看到缓冲区的内容,而缓冲区又是由OS提供的,这不就是我们一开始提过的进程通信的方式吗?父子进程可以向缓冲区写入数据,也可以读取数据,这样不就可以通信了吗?
这个 struct file(read) - 缓冲区 - struct file(write) 的结构就可以被称为管道,但是一般情况下管道都是单向的。所以我们需要关闭父进程的写文件,关闭子进程的读文件,这样管道就是单向的了。反过来关闭父进程的读,关闭子进程的写也是可以的,看具体需求
所以说需要进程通信时,只需要创建上图的结构即可,缓冲区的数据来源是进程,最终也是刷新到进程而不是硬盘。这个打开的文件不需要名字,只是充当管道的作用而已,所以这种管道叫做匿名管道
匿名管道创建过程总结:
疑问
- 既然最终只需要父子进程各打开一个文件,那么父进程一开始只以一种方式打开文件可以吗?
不可以,因为最终父子进程打开文件的方式是不同的,所以父进程必须用两种方式打开文件让子进程继承文件描述符表,最后根据需求关闭相应的文件
- 为什么管道一定是要单向的?可不可以父子进程都用两种方式打开文件,这不就是双向的了?
单向的简单,所以使用单向,毕竟管道一开始设计的时候就是为了简单才复用已经实现操作系统功能的。如果进程都可以向缓冲区写入数据,那么还需要区分缓冲区内的数据是谁写,管道就变得复杂了。
而且单向管道更加符合文件的操作,文件就是从进程获得数据到缓冲区,然后刷新到硬盘;或者从硬盘获得数据到缓冲区,然后将数据输送到进程。文件操作也是单向的
代码demo
说了这么一大堆,我们可以写一个代码来见识一下匿名管道的使用,使用如下接口创建匿名管道
int pipe(int pipefd[2]);
创建成功则返回0;失败返回-1,并设置错误码
参数则是一个输出型参数,数组有两个数,第一个数是读端的fd,第二数则是写端的fd
我们可以先把打开的管道的fd打印出来看看
#include <iostream>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>
int main()
{
// 1.创建匿名管道
int pipefd[2];
int n = pipe(pipefd);
if (n != 0)
{
// 创建管道失败
std::cerr << "errno: " << errno << ",errstring: " << strerror(errno) << std::endl;
return 1;
}
// 打印fd
std::cout << "读端:" << pipefd[0] << "写端:" << pipefd[1] << std::endl;
sleep(1);
// 2.创建子进程
pid_t pid = fork();
if (pid == 0)
{
// 3.子进程
}
// 3.父进程
return 0;
}
然后就是父子进程关闭不需要的文件,这里我们让父进程关闭写文件,充当读端;子进程关闭读文件,充当写端
父进程调用 FatherProcessRead()
进行读取,子进程调用 SubProcessWrite
进行写入
// 2.创建子进程
pid_t pid = fork();
if (pid == 0)
{
std::cout << "子进程关闭不需要的文件,准备开始写入了" << std::endl;
sleep(1);
// 3.子进程关闭读文件
close(pipefd[0]);
SubProcessWrite(pipefd[1]);
// 结束
close(pipefd[1]);
exit(0);
}
std::cout << "父进程关闭不需要的文件,准备开始读取了" << std::endl;
sleep(1);
// 3.父进程关闭写文件
close(pipefd[1]);
FatherProcessRead(pipefd[0]);
// 结束
close(pipefd[0]);
接下来编写 SubProcessWrite
和 FatherProcessRead
,为了方便我们查看,将两个函数都写成死循环,一个不停地写,一个不停地读。
至于写什么内容,写什么都可以,这里我们就让子进程向管道内写入一句“I am child process”。还可以写入其他一些信息,例如:第几次写入,写入进程的pid,获得其他信息就封装为另一个函数 getOtherMsg()
std::string getOtherMsg()
{
static int cnt = 0; // 用于计数,是第几次写入
std::string msgid = std::to_string(cnt++); // 第几次写入,转换为字符串
std::string self_id = std::to_string(getpid()); // 进程id,转换为字符串
// 将各个信息组合
std::string msg = "messageid is ";
msg += msgid;
msg += ", my pid is ";
msg += self_id;
return msg;
}
void SubProcessWrite(int wfd)
{
std::string msg = "I am child process!";
// 一直写入
while(1)
{
std::string info = msg + getOtherMsg();
// 将数据写入管道
write(wfd, info.c_str(), info.size()); // 向文件中写入时,不必写入\0
sleep(1);
}
}
父进程读入数据,先来一个字符数组 inbuffer 存放读入的数据,数组的大小设置为 1024
每次读入数组大小 n-1 个字符,为什么呢?因为要留一个位置放 ‘\0’,之前子进程写入时并没有写 ‘\0’
使用 n 来接收 read 的返回值,表示读取了多少字符。如果 n > 0,表示此次读取有效,就输出读到的数据。其他情况暂时不考虑
void FatherProcessRead(int rfd)
{
char inbuffer[1024];
// 一直读取
while(1)
{
ssize_t n = read(rfd, inbuffer, sizeof(inbuffer)-1);
if (n > 0)
{
// 读取成功,读入 n 个字符
inbuffer[n] = 0; // 设置 \0
std::cout << "father get msg: " << inbuffer << std::endl;
}
// 其他情况
}
}
现在就可以先把代码跑起来看看效果了,另开一个窗口追踪进程信息,使用如下命令追踪
while :;do ps -axj | head -1 && ps -axj | grep testpipe | grep -v grep; echo "-----------"; sleep 1; done
运行结果如下,父进程果然收到了子进程向管道中写入的信息并打印了出来
管道的情况和特点
四种情况
使用管道时根据写端和读端的情况,例如一直在读/写,或者是某一端关闭等,可以自由组合出以下四种情况:
- 管道内容为空 && 写端没有关闭,那么读端读不到数据只能等待,进入阻塞状态,等待唤醒。唤醒条件:写端向管道中写入数据
- 管道内容写满了 && 读端没有关闭,那么写端就不可以再写了,进入阻塞状态,等待唤醒。唤醒条件:读端读取数据
- 读端一直在读 && 写端关闭,那么读端就会返回0,表示读到了文件末尾
- 写端一直在写 && 读端关闭,也就是说管道的内容没有进程读取了,但是负责写的进程还在不停地向管道写入数据。这种情况在操作系统看来是不合法的,既浪费时间也浪费空间,所以会使用信号13将写进程杀掉,相当于进程出现了异常
我们还是使用上面的代码,对这四种情况进行演示
- 管道内容为空 && 写端没有关闭,读进程进入阻塞状态
将子进程(写进程)的休眠时间设置很长时间,这样读进程就会进入阻塞态
void SubProcessWrite(int wfd)
{
std::string msg = "I am child process!";
// 一直写入
while(1)
{
std::string info = msg + getOtherMsg();
// 将数据写入管道
write(wfd, info.c_str(), info.size()); // 向文件中写入时,不必写入\0
sleep(100); // 休眠时间
}
}
虽然图片看不出来,但是这时程序已经卡住不动了,就是因为父进程(读进程)进入阻塞态,等待管道写入数据
- 管道内容写满了 && 读端没有关闭,管道满了,不可以再写入数据,所以写进程只能进入阻塞态,等待管道数据被读取
可以一直让子进程写入数据,每次写入一字节的数据,看看管道究竟有多大。而父进程则是一开始就休眠很长时间,这样就可以写满管道,写进程进入阻塞
void SubProcessWrite(int wfd)
{
std::string msg = "I am child process!";
// 一直写入
int pipesize = 0;
char ch = 'A';
while(1)
{
std::cout << "pipesize: " << ++pipesize << ", write char: " << ch << std::endl;
}
}
void FatherProcessRead(int rfd)
{
sleep(100); // 读进程休眠
}
此时写进程已经进入阻塞态,程序卡住了。而 65536 字节就是 64 KB,说明在 ubuntu 20.04 环境下,管道的大小就是 64 KB
- 写进程关闭,读进程一直在读,读进程就会返回0,表示读到文件末尾
写进程,写完ABCDEF这几个字符后就退出,父进程则对 read 的返回值进行检查,返回 0 时,表示读到文件末尾,写端关闭,那么读端也没有读的必要了,关闭读端
void SubProcessWrite(int wfd)
{
std::string msg = "I am child process!";
// 一直写入
int pipesize = 0;
char ch = 'A';
while(1)
{
write(wfd, &ch, 1);
std::cout << "pipesize: " << ++pipesize << ", write char: " << ch++ << std::endl;
if (ch == 'G') break;
sleep(1);
}
}
void FatherProcessRead(int rfd)
{
char inbuffer[1024];
// 一直读取
while(1)
{
ssize_t n = read(rfd, inbuffer, sizeof(inbuffer)-1);
if (n > 0)
{
// 读取成功,读入 n 个字符
inbuffer[n] = 0; // 设置 \0
std::cout << "father get msg: " << inbuffer << std::endl;
}
else if (n == 0)
{
// 写端关闭,读到文件末尾
std::cout << "client quit, father get return val: " << n << ", father quit, too!" << std::endl;
break;
}
}
}
也可以在 main 函数中加一些提示
// 3.父进程关闭写文件
close(pipefd[1]);
FatherProcessRead(pipefd[0]);
std::cout << "father will quit in 5s!" << std::endl;
sleep(5);
测试:
- 读端关闭,写端却一直在写,操作系统会使用信号13把写进程杀死。写进程(子进程)被杀死,进入进入僵尸状态,需要父进程进行等待
写进程一直写,读一开始就关闭
void SubProcessWrite(int wfd)
{
std::string msg = "I am child process!";
// 一直写入
int pipesize = 0;
char ch = 'A';
while(1)
{
std::string info = msg + getOtherMsg();
// 将数据写入管道
write(wfd, info.c_str(), info.size()); // 向文件中写入时,不必写入\0
sleep(1);
}
}
void FatherProcessRead(int rfd)
{
return;
}
父进程等待子进程,顺便获取退出信息,看看退出信号
// 3.父进程关闭写文件
close(pipefd[1]);
FatherProcessRead(pipefd[0]);
std::cout << "father will quit in 5s!" << std::endl;
sleep(5);
// 结束
close(pipefd[0]);
// 等待子进程
int status = 0;
pid_t ret = waitpid(pid, &status, 0);
if (ret > 0)
{
std::cout << "wait child sucess, exit_sig: " << (status & 0x7f) << std::endl;
std::cout << "wait child sucess, exit_code: " << ((status >> 8)&0xff) << std::endl;
}
五种特点
- 匿名管道只