进程间通信的目的
- 数据传输:一个进程许需要将它的数据发送给另外一个进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它们发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的分类
- 管道
- 匿名管道pipe
- 命名管道FIFO
- Sytem V IPC
- System V消息队列
- System V共享内存
- System V信号量
- POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
管道
我们把一个进程连接到另一个进程的一个数据流,称为管道。
进程是具有独立性的!要让进程间进行通信,“成本”一定不低。要让不同进程通信,首先要先让它们看到同一份资源。其次是通信。
这个公共的资源是谁提供的呢?其中一个进程?直接在进程内部创建资源,其他进程看不到。
所以,我们该如何理解进程间通信的本质问题呢?
- OS需要给直接或间接给通信双方进程提供“内存空间”
- 要通信的进程,必须看到同一份资源!
所谓不同的通信种类,本质就是:上面所说的资源,是OS中的哪一个模块提供的!
未来学习的进程间通信的接口,与其说是通信的接口,不如说它是让不同的进程看到同一份资源的接口。
匿名管道
如果是一个普通文件,需要将内核缓冲区里的数据刷新到磁盘中。但是进程间通信,是一个进程的数据给另外一个进程,是内存到内存之间的。不需要将内核缓冲区里的数据刷新到磁盘,另一个进程再从磁盘中读取,因为会大大降低通信的效率。
既然不需要刷新缓冲区,那么OS就不需要在磁盘中创建打开文件,然后在内存中创建struct file对象。OS不需要访问磁盘,直接就可以在内存中创建struct file对象,创建对应的缓冲区,然后将对象的地址填入到文件描述符中,那么再fork创建子进程时,子进程会拷贝父进程的文件描述符表,通过文件描述符,进而父子进程就能看到同一个文件。父子进程双方就能基于这个内存级文件来进行通信了。
一般在文件里面标定一个文件使用的是文件名,但是这个管道文件,是一个内存级文件,没有名字所以叫匿名管道。
从文件描述符角度理解管道
- 为什么让父进程分别以读和写的方式打开同一文件呢?
如果以只读或只写方式打开文件,那么子进程也会继承父进程的只读或只写方式,父子进程双方打开文件的方式是一样的,就完不成单向通信了。只有分别以读和写的方式打开,读和写的文件描述符才会被子进程继承,然后再选择对应的通信方向,关闭特定的文件描述符即可。
以读和写方式打开文件的本质:就是让子进程也能看到读写段端,让后续能自由的选择通信方向。
- 必须要关闭父子进程特定的文件描述符吗?例如父进程写,关闭读端,子进程读,关闭写端。
也可以不关特定的文件描述符。但是一般都建议关掉,因为这个不用的文件描述符有可能被别人用到,进而就有可能修改管道数据,引起程序运行出问题。
再来理解管道:父进程通过调用管道特定的系统调用,以读和写的方式打开一个内存级文件,再通过fork创建子进程的方式,被子进程继承下去,再关闭对应的读写端,进而形成的一条通信信道,这一套通信信道是基于文件的,所以叫管道。
用fork来共享管道原理
从内核角度理解管道本质
看待管道,就如同看待文件一样,管道的使用和文件一致,迎合了“Linux下一切皆文件”的思想。
创建匿名管道
参数fd :文件描述符数组, 其中 fd[0] 表示读端, fd[1] 表示写端。返回值: 成功返回 0 ,失败返回-1。文件描述符fd[0]、fd[1]默认从3开始,因为fd0、1、2默认被三个标准输入输出占用。
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <unistd.h>
#include <cassert>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;int main()
{// 第一步:创建管道文件,打开读写端int fds[2];int n = pipe(fds); // 成功返回0,失败返回-1assert(n == 0);// 第二步:创建子进程pid_t id = fork();assert(id >= 0);if (id == 0){// 子进程进行写入close(fds[0]);// 子进程的通信代码int cnt = 0;const char *s = "我是子进程,我正在给你发消息";while (true){cnt++;char buffer[1024]; // 只有子进程能看到!snprintf(buffer, sizeof buffer, "child->parent say: %s[%d][%d]", s, cnt, getpid());write(fds[1], buffer, strlen(buffer));sleep(1); // 细节:子进程每隔一秒写一次}// 子进程close(fds[1]);exit(0);}// 父进程进行读取close(fds[1]);// 父进程的通信代码while (true){char buffer[1024];ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1); // 多留一个位置给\0if (s > 0){buffer[s] = 0;cout << "Get Message# " << buffer << " | my pid: " << getpid() << endl;// 细节:父进程没有进行sleep}}n = waitpid(id, nullptr, 0);assert(n == id);close(fds[0]);
}
运行结果,父进程每隔一秒输出一次 。
- 如果将子进程休眠时间改为5秒,会有什么现象呢?
//子进程
sleep(5);
运行结果,父进程每隔5秒输出一次。
- 一开始子进程写入,父进程读取,输出。之后在子进程休眠的5秒内,父进程在干什么呢?
我们将代码改造一下:
// 父进程的通信代码while (true){char buffer[1024];cout<<"AAAAAAAAAAAAAA"<<endl;ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1); cout<<"BBBBBBBBBBBBBB"<<endl;if (s > 0){buffer[s] = 0;cout << "Get Message# " << buffer << " | my pid: " << getpid() << endl;}}
可以看到父进程在read()这里阻塞了。
read这里就涉及了两个功能:
- 等缓冲区里有数据。
- 将数据从内核拷贝到用户层。
如果此时缓冲区里没有数据,父进程就会一直阻塞等待。OS将父进程从运行状态R改为阻塞状态S,放在等待队列中。等待的不就是文件吗(等管道文件里有数据)?所以文件里也一定存在类似等待队列这样的结构,将进程的PCB放入这个文件对应的等待队列中。当写了之后,缓冲区有数据了,OS识别到,再将进程的PCB从等待队列拿到运行队列,将进程状态由S改为R,就可以继续被调度了。
总结:如果管道中没有了数据,读端再读,默认会直接阻塞当前正在读取的进程!
- 管道是一个固定大小的缓冲区。如果反过来,缓冲区写满了之后,写端继续写呢?
将代码改造一下:
//子进程不休眠//... while (true){cnt++;char buffer[1024]; snprintf(buffer, sizeof buffer, "child->parent say: %s[%d][%d]", s, cnt, getpid());write(fds[1], buffer, strlen(buffer));cout << "count: " << cnt << endl;}//...//父进程休眠1000秒//...while (true){sleep(1000);char buffer[1024];ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1); if (s > 0){buffer[s] = 0;cout << "Get Message# " << buffer << " | my pid: " << getpid() << endl;}}//...
一瞬间就写满了,不再继续写了。
可以看到,子进程一瞬间就将缓冲区写满了,不再继续写了。
总结:如果管道满了之后,写端再写,会发生阻塞等待,等待读端读取。
再将代码改造一下:
//子进程不休眠//... while (true){cnt++;char buffer[1024]; snprintf(buffer, sizeof buffer, "child->parent say: %s[%d][%d]", s, cnt, getpid());write(fds[1], buffer, strlen(buffer));cout << "count: " << cnt << endl;}//...//父进程休眠2秒//...while (true){sleep(2);char buffer[1024];ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1); if (s > 0){buffer[s] = 0;cout << "Get Message# " << buffer << " | my pid: " << getpid() << endl;}}//...
总结:父进程读取并不是一行一行读取的,而是按照指定大小读取的,也就是说缓冲区里有指定字节大小的数据,一次就会全部读完。
ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1);
- 如果子进程写了一次之后,就将对应的写端描述符关闭呢?
// ...// 子进程// ...while (true){cnt++;char buffer[1024]; snprintf(buffer, sizeof buffer, "child->parent say: %s[%d][%d]", s, cnt, getpid());cout << "count: " << cnt << endl;write(fds[1], buffer, strlen(buffer));break;}close(fds[1]);cout << "子进程关闭写端" << endl;exit(0);}// ...// 父进程// ...while (true){sleep(2);char buffer[1024];ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1); if (s > 0){buffer[s] = 0;cout << "Get Message# " << buffer << " | my pid: " << getpid() << endl;}else if (s == 0){cout << "read: " << s << endl;break;}}n = waitpid(id, nullptr, 0);assert(n == id);close(fds[0]);
父进程将管道数据读完之后,写端文件描述符也关闭了,那么就意味着已经完成了管道的读写,读端read()读到文件末尾,返回0。
- 如果关闭读端,写端继续写呢?
// ...// 子进程// ...while (true){cnt++;char buffer[1024]; snprintf(buffer, sizeof buffer, "child->parent say: %s[%d][%d]", s, cnt, getpid());cout << "count: " << cnt << endl;write(fds[1], buffer, strlen(buffer));}close(fds[1]);cout << "子进程关闭写端" << endl;exit(0);}// ...// 父进程// ...while (true){sleep(2);char buffer[1024];ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1); if (s > 0){buffer[s] = 0;cout << "Get Message# " << buffer << " | my pid: " << getpid() << endl;}else if (s == 0){cout << "read: " << s << endl;break;}break;}close(fds[0]);cout << "父进程关闭读端" << endl;int status = 0;n = waitpid(id, &status, 0);cout << "pid->" << n << " : " << (status & 0x7F) << endl;assert(n == id);
如果读端被关闭,写就没有意义了没有意义操作系统会杀掉写的子进程,是通过发送信号的方式被杀掉,也就相当于子进程异常退出了。一旦父进程关闭读端,子进程会立马退出,父进程waipid()就能获取到子进程的退出码。
OS会给子进程直接发送13号信号,来终止写进程。
读写特征
- 读快,写慢:读阻塞,等待写入。
- 读慢,写快:写阻塞,等待读取。
- 写关闭:读到0。
- 读关闭,终止写。
这四种读写特征分别对应了上述各种现象。
管道的特征
- 管道的生命周期随进程,进程退出,管道释放。
- 只能用于具有共同祖先(具有血缘关系的进程)之间进行通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后,父子进程之间就可以应用管道。常用于父子通信。
- 管道是面向字节流的。
- 内核会对管道操作进行同步与互斥,对共享资源进行保护的方案。
- 管道是半双工的,数据只能向一个方向流动,需要双方进行通信时,需要建立两个管道。
命名管道
匿名管道应用的一个限制就是只能在具有血缘关系的进程之间进行通信,如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来完成,被称为命名管道,命名管道是一种特殊类型的文件。
命名管道可以从命令行上创建:
$mkfifo filename
- 命名管道是如何让不同的进程看到同一份资源的呢?
命名管道也可以通过函数创建:
- 创建
成功返回0,失败返回-1。
- 删除
成功返回0,失败返回-1。
命名管道与匿名管道的区别
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfififo函数创建,打开用open
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完 成之后,它们具有相同的语义。
匿名管道是通过文件描述符来让具有血缘关系的进程进行通信的。
命名管道是通过文件名来让不同的进程使用同一个管道通信的。
client&server通信
示例代码:
Makefile:
.PHONY:all
all:server clientserver:server.ccg++ -o $@ $^ -std=c++11 -gclient:client.ccg++ -o $@ $^ -std=c++11 -g.PHONY:clean
clean:rm -f server client
comm.hpp
#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>#define NAMED_PIPE "/tmp/mypipe"bool createFifo(const std::string &path)
{umask(0);int n = mkfifo(path.c_str(), 0600);if (n == 0)return true;else{std::cout << "errno: " << errno << " err string: " << strerror(errno) << std::endl;return false;}
}void removeFifo(const std::string &path)
{int n = unlink(path.c_str());assert(n == 0);//assert只在Debug下有效,在release下就没有了。(void)n;
}
server.cc
#include "comm.hpp"int main()
{bool r = createFifo(NAMED_PIPE);assert(r);(void)r;//removeFifo(NAMED_PIPE);return 0;
}
此时就在tmp路径下创建了名为“mypipe”的管道。
下面来进行server和client的通信。通信的过程就是对文件的读写读写操作。
client.cc
int main()
{int wfd = open(NAMED_PIPE,O_WRONLY);if(wfd<0) exit(1);char buffer[1024];while(true){std::cout<<"Please say# ";fgets(buffer,sizeof buffer,stdin);ssize_t s = write(wfd,buffer,strlen(buffer));assert(s==strlen(buffer));(void)s;}close(wfd);return 0;
}
server.cc
int main()
{bool r = createFifo(NAMED_PIPE);assert(r);(void)r;std::cout<<"server begin"<<std::endl;int rfd = open(NAMED_PIPE, O_RDONLY);std::cout<<"server end"<<std::endl;if(rfd < 0) exit(1);//readchar buffer[1024];while(true){ssize_t s = read(rfd, buffer, sizeof(buffer)-1);if(s > 0){buffer[s] = 0;std::cout << "client->server# " << buffer << std::endl;}//如果读端关闭,写端读到0else if(s == 0){std::cout << "client quit, me too!" << std::endl;break;}//读取错误else{std::cout << "err string: " << strerror(errno) << std::endl;break;}}close(rfd);// sleep(10);removeFifo(NAMED_PIPE);return 0;
}
这样就完成了客户端client和服务端server两个进程间的通信。
- 为什么服务端读取的时候会多一行空行呢?
原因是我们从键盘输入的时候会摁回车键,例如:输入“hello world”。实际fgets读取到的为:“hello world \n”。“\n”也会被读取到,所以会被多打印一行空行。
再对代码做一下优化。
client.cc:
int main()
{std::cout<<"client begin"<<std::endl;int wfd = open(NAMED_PIPE,O_WRONLY);std::cout<<"client end"<<std::endl;if(wfd<0) exit(1);char buffer[1024];while(true){std::cout<<"Please say# ";fgets(buffer,sizeof buffer,stdin);if(strlen(buffer)>0) buffer[strlen(buffer)-1] = 0;ssize_t s = write(wfd,buffer,strlen(buffer));assert(s==strlen(buffer));(void)s;}close(wfd);return 0;
}
server.cc:
int main()
{bool r = createFifo(NAMED_PIPE);assert(r);(void)r;std::cout<<"server begin"<<std::endl;int rfd = open(NAMED_PIPE, O_RDONLY);std::cout<<"server end"<<std::endl;if(rfd < 0) exit(1);//readchar buffer[1024];while(true){ssize_t s = read(rfd, buffer, sizeof(buffer)-1);if(s > 0){buffer[s] = 0;std::cout << "client->server# " << buffer << std::endl;}//如果读端关闭,写端读到0else if(s == 0){std::cout << "client quit, me too!" << std::endl;break;}//读取错误else{std::cout << "err string: " << strerror(errno) << std::endl;break;}}close(rfd);// sleep(10);removeFifo(NAMED_PIPE);return 0;
}
当读端先执行时,会在open()阻塞。
当写端再执行时,读端才继续调度执行。
总结:只有当两个进程同时打开文件程序才能继续向后运行。