Linux进程间通信
- 1 进程间通信的介绍
- 1.1为什么要有进程间通信
- 1.2 为什么能进程间通信
- 2 进程间通信的框架
- 2.1 进程间通信机制的结构
- 2.2 进程间通信机制的类型
- 2.2.1 共享内存式
- 2.2.2 消息传递式
- 2.3 进程间通信的接口设计
- 3 进程间通信机制简介
- 4 详细讲解进程间通信部分机制,介绍部分机制
- 4.1管道
- 4.1.1匿名管道
- 4.1.1.1匿名管道的原理
- 4.1.1.2创建进程间的通信方案的代码样例以及分析:
- 4.1.1.3 场景需求:让子进程给父进程发消息
- 4.1.1.4 通过上一个具体的场景看一下匿名管道的特点
- 4.1.1.5 管道特点总结
- 4.1.2 命名管道
- 4.1.2.1 利用系统调用创建命名管道
- 4.1.2.2 利用命名管道给server和client通信
- 4.2 SysV共享内存
- 4.3 POSIX共享内存
- 4.4 共享内存映射
- 4.5 Android ION
- 4.6 dma-buf heaps
- 4.7 SysV消息队列
- 4.8 POSIX消息队列
- 4.9 套接字
- 4.10 Android Binder
- 4.11 信号机制
- 4.12 伪终端
- 5 总结
1 进程间通信的介绍
进程间通信(IPC,Interprocess communication)是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。这使得一个程序能够在同一时间里处理许多用户的要求。因为即使只有一个用户发出要求,也可能导致一个操作系统中多个进程的运行,进程之间必须互相通话。
1.1为什么要有进程间通信
在软件体系中,有些任务需要多个进程一起协同完成,所以多个进程之间必须要进行数据的通信,资源共享,通知事件,进程控制等操作。
但是,因为进程具有独立性,进程之间不能正常的相互访问,不能正常通信,所以我们需要一个“桥梁”来为两个或者多个线程之间建立通信关系。
1.2 为什么能进程间通信
为进程之间搭立起通信“桥梁”的是内核。
首先,OS(操作系统)会将内存空间划分为内核空间和用户空间。
虽然我们有多个进程,但是我们的内核只有一个。
虽然用户空间是完全隔离的,但是用户空间和内核空间并不是完全隔离的,OS会暴露一些接口来给用户使用,也就是系统接口。
所以我们可以通过系统调用和内核沟通来给进程之间搭立起通信的桥梁。
2 进程间通信的框架
2.1 进程间通信机制的结构
进程间通信主要有两部分构成,一种是内核空间的通信中枢,一种是存在于用户空间的通信接口。
如上面所说:内核空间为通信建立了基础,我们可以使用操作系统的一些接口来为进程之间建立联系的通道。
2.2 进程间通信机制的类型
进程间通信机制的类型有两种:
1.共享内存式
2.消息传递式
2.2.1 共享内存式
共享内存式进程间通信,通信中枢建立好通信信道之后,就不再管了,通信双方之后的通信不需要通信中枢的协助。由于通信信息的传递不需要通信中枢的协助,所以通信双方还需要进程间同步,来保证数据读写的一致性,以避免踩踏数据或者读到垃圾数据。
2.2.2 消息传递式
消息传递式进程间通信,通信中枢建立好通信信道之后,每次通信还都需要通信中枢的协助。由于通信信息是通过通信中枢传递的,所以不需要进程间同步。消息传递式进程间通信又可以分为两类,有边界消息和无边界消息。无边界消息就是字节流,发过来是一个一个的字节,要靠进程自己设计如何区分消息的边界。有边界消息的进程间通信的发送和接收都是以消息为基本单位的。
2.3 进程间通信的接口设计
按照通信双方的关系,可以把通信类型分为对称型通信和非对称型通信。对称型通信的双方关系是对等的,非对称型通信的双方关系是不对等的,可能是命令执行关系、客户服务关系、生产消费关系等。这种关系是通信双方逻辑上的关系,并不是进程间通信机制本身的特征。消息传递式进程间通信一般用于非对称型通信,共享内存式进程间通信一般用于对称型通信,也可以用于非对称型通信。
进程间通信机制一般要实现下面三类接口,但是有些机制不一定要这三类接口都实现。
1.如何建立通信信道,谁去建立通信信道。
2.后者如何找到并加入这个通信信道。
3.如何使用通信信道。
对于对称型通信来说,谁去建立通信信道无所谓,有一个人去建立就可以了,后者直接加入通信信道。对于非对称型通信,一般是由服务端、消费者建立通信信道,客户端、生产者则加入这个通信信道。如何建立信道呢,不同的进程间通信机制,有不同的接口来创建信道,这个在下一章讲。后者如何找到并加入前者建立的通信信道呢?一般情况是,双方通过提前约定好的信道名称找到信道句柄,通过信道句柄加入通信信道。但是有的是通过继承把信道句柄传递给对方,有的是通过其它进程间通信机制传递信道句柄,有的则是通过信道名称直接找到信道,不需要信道句柄。如何使用信道呢?对于消息传递式进程间通信来说,一般都要提供特殊的接口。对于共享内存式进程间通信来说,则不需要提供这个接口,因为就和访问普通内存一样访问共享内存就行。
3 进程间通信机制简介
前面所说的是进程间通信的介绍和大体的设计,接下来我们看看Linux中进程间通信的机制。所有的总结如下图:
如上文所说,进程间通信的机制类型有两类,一类是消息传递式,一类是内存共享类,该图中的第三类“进程间同步”是为了同步进程间对共享内存的读写,进程间同步也算是在进程间传递了信息,所以把“进程间同步”也放进了进程间通信中。
4 详细讲解进程间通信部分机制,介绍部分机制
本篇文章会详细讲解消息传递式中的 “匿名管道” 和 “命名管道”,其他的只给出大致的介绍,读者可自行学习。
4.1管道
管道是Unix中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
比如以下命令:
$ who | wc -l //获取云服务器上的登录用户的个数
who命令和wc命令都是两个程序,当它们运行的时候就会成为进程,who进程通过标准输出将信息输出到管道中,然后wc进程通过标准输入从管道中获取刚才的信息。
[ffg@iZ2vc8w7mi80oztydn6tgiZ ~]$ who | wc -l
1
4.1.1匿名管道
匿名管道是进程间通信的一种方式,仅用于具有血缘关系进程(如父子进程)之间的通信。
4.1.1.1匿名管道的原理
首先要知道,进程间通信的本质是让要产生通信的进程看到同一份被打开的文件资源,然后要通信的进程就可以对该被打开的文件进行写入或者读写操作,以此达到通信的目的。
Linux下一切皆文件
那么,我们这里讲的管道,匿名管道其实就是一份文件,父子进程通过改变这份文件的读写方式来达到通信。
匿名管道具体通信原理图:
注意:
管道是单向通信的,当父进程想要“写”时,就必须把读端关闭,写端打开子进程也同理。
在父进程刚开始创建管道时,一定要把读端和写端都打开,方便子进程继承下去。
4.1.1.2创建进程间的通信方案的代码样例以及分析:
在开始进程间通信之前,需要为通信做好以下准备:
1.创建管道
2.创建子进程
3.关闭不需要的fd
1.创建管道
如上面所说,我们要开始利用系统调用来和内核沟通,建立起通信的桥梁了。
pipe函数
man手册查询
函数原型
int pipe(int pipefd[2]);
pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:
一般是3和4
数组元素 | 含义 |
---|---|
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
pipe函数调用成功返回0,失败返回-1。
创建匿名管道代码演示:
#include <iostream>
#include <cerrno>
#include <string.h>
#include <unistd.h>
int main()
{
int pipefd[2] = {0};
//1.创建管道
int n = pipe(pipefd);
if(n < 0)//判断是否创建成功
{
std::cout << "pipe error," << errno << ":" << strerror(errno) << std::endl;
return 1;
}
std::cout << "pipefd[0]=" << pipefd[0] << std::endl;//这里我们查看读端和写端的文件描述符fd
std::cout << "pipefd[1]=" << pipefd[1] << std::endl;
return 0;
}
运行这段代码结果如下:
pipefd[0]=3
pipefd[1]=4
2.创建子进程
#include <iostream>
#include <cerrno>
#include <string.h>
#include <unistd.h>
#include <cassert>
int main()
{
int pipefd[2] = {0};
//1.创建管道
int n = pipe(pipefd);
if(n < 0)//判断是否创建成功
{
std::cout << "pipe error," << errno << ":" << strerror(errno) << std::endl;
return 1;
}
//std::cout << "pipefd[0]=" << pipefd[0] << std::endl;//这里我们查看读端和写端的文件描述符fd
//std::cout << "pipefd[1]=" << pipefd[1] << std::endl;
//2.创建子进程
pid_t id = fork();
assert(id != -1);
if(id == 0)//子进程创建成功
{
exit(0);//子进程完成相应的操作后就可以退出了,下面是父进程
}
//父进程
return 0;
}
3.关闭不需要的fd
这里我们的需求让父进程读取数据,子进程写入数据
#include <iostream>
#include <cerrno>
#include <string.h>
#include <unistd.h>
#include <cassert>
int main()
{
int pipefd[2] = {0};
//1.创建管道
int n = pipe(pipefd);
if(n < 0)//判断是否创建成功
{
std::cout << "pipe error," << errno << ":" << strerror(errno) << std::endl;
return 1;
}
//std::cout << "pipefd[0]=" << pipefd[0] << std::endl;//这里我们查看读端和写端的文件描述符fd
//std::cout << "pipefd[1]=" << pipefd[1] << std::endl;
//2.创建子进程
pid_t id = fork();
assert(id != -1);
if(id == 0)//子进程创建成功
{
//3.关闭不需要的fd,我们这里要执行的操作是让父进程读取,子进程写入
//所以子进程要关闭pipefd[0],也就是关闭读端
close(pipefd[0]);
exit(0);//子进程完成相应的操作后就可以退出了,下面是父进程
}
//父进程
//3.关闭不需要的fd,父进程要读,所以我们要关闭父进程的写端,即pipefd[1]
close(pipefd[1]);
return 0;
}
到这里为止,我们已经为进程间通信搭起了桥梁,我们完成了构建进程间通信的一种方案,再往后的话就是要结合某种场景,来完成我们的需求了。
4.1.1.3 场景需求:让子进程给父进程发消息
先让子进程给管道里面写数据
#include <iostream>
#include <cerrno>
#include <string.h>
#include <unistd.h>
#include <cassert>
#include <sys/types.h>
int main()
{
int pipefd[2] = {0};
//1.创建管道
int n = pipe(pipefd);
if(n < 0)//判断是否创建成功
{
std::cout << "pipe error," << errno << ":" << strerror(errno) << std::endl;
return 1;
}
//std::cout << "pipefd[0]=" << pipefd[0] << std::endl;//这里我们查看读端和写端的文件描述符fd
//std::cout << "pipefd[1]=" << pipefd[1] << std::endl;
//2.创建子进程
pid_t id = fork();
assert(id != -1);
if(id == 0)//子进程创建成功
{
//3.关闭不需要的fd,我们这里要执行的操作是让父进程读取,子进程写入
//所以子进程要关闭pipefd[0],也就是关闭读端
close(pipefd[0]);
//4.开始通信 -- 结合相应的需求写代码,这里我们要让子进程给父进程发消息
const std::string namestr = "hello,我是子进程";
int cnt = 1;//给一个会变化的计数器,方便观察
char buffer[1024];
while(true)
{
snprintf(buffer,sizeof buffer,"%s, 计数器:%d,我的PID: %d",namestr.c_str(),cnt++,getpid());//往buffer里面写数据
//开始写数据
write(pipefd[1],buffer,strlen(buffer));
sleep(1);
}
exit(0);//子进程完成相应的操作后就可以退出了,下面是父进程
}
//父进程
//3.关闭不需要的fd,父进程要读,所以我们要关闭父进程的写端,即pipefd[1]
close(pipefd[1]);
return 0;
}
然后是父进程读取数据
#include <iostream>
#include <cerrno>
#include <string.h>
#include <unistd.h>
#include <cassert>
#include <sys/types.h>
int main()
{
int pipefd[2] = {0};
//1.创建管道
int n = pipe(pipefd);
if(n < 0)//判断是否创建成功
{
std::cout << "pipe error," << errno << ":" << strerror(errno) << std::endl;
return 1;
}
//std::cout << "pipefd[0]=" << pipefd[0] << std::endl;//这里我们查看读端和写端的文件描述符fd
//std::cout << "pipefd[1]=" << pipefd[1] << std::endl;
//2.创建子进程
pid_t id = fork();
assert(id != -1);
if(id == 0)//子进程创建成功
{
//3.关闭不需要的fd,我们这里要执行的操作是让父进程读取,子进程写入
//所以子进程要关闭pipefd[0],也就是关闭读端
close(pipefd[0]);
//4.开始通信 -- 结合相应的需求写代码,这里我们要让子进程给父进程发消息
const std::string namestr = "hello,我是子进程";
int cnt = 1;//给一个会变化的计数器,方便观察
char buffer[1024];
while(true)
{
snprintf(buffer,sizeof buffer,"%s, 计数器:%d,我的PID: %d",namestr.c_str(),cnt++,getpid());//往buffer里面写数据
//开始写数据
write(pipefd[1],buffer,strlen(buffer));
sleep(1);//这里每次写入的时候休眠一秒
}
exit(0);//子进程完成相应的操作后就可以退出了,下面是父进程
}
//父进程
//3.关闭不需要的fd,父进程要读,所以我们要关闭父进程的写端,即pipefd[1]
close(pipefd[1]);
char buffer[1024];
while(true)
{
int n = read(pipefd[0],buffer,sizeof(buffer) - 1);//读的时候末尾留下一个位置,我们添加'\0'
if(n > 0)
{
buffer[n] = '\0';
std::cout <<"我是父进程,子进程给我的消息是:" << buffer << std::endl;
}
}
return 0;
}
运行程序,子进程会一直循环给父进程发消息。
4.1.1.4 通过上一个具体的场景看一下匿名管道的特点
特点1: 字节流
我们刚才让子进程给管道写入的数据的时候,用了sleep(1),而父进程没有用。本意让子进程写得慢一点,父进程读得快一点。
那么我们现在让子进程写得慢一点,父进程读得快一点,观察一下程序的输出情况。
运行程序
我们可以看到,并不像上次那样一行一行的输出了。
这说明,在管道通信中,写入的次数和读取的次数并不是严格匹配的(比如写入了10次的时候,才读取了1次),读写次数的多少并没有强相关。这就是上面说所的"字节流"的一种表现。
特点2: 自带同步机制
管道具有一定的协同能力,让读端和写段能够按照一定步骤进行通信
我们来看一下以下四种场景:
1.如果读端读取完了管道的所有数据,但是对方不发,读端只能进行等待。
看看结果:
笔者这边是过了10秒后第二条消息才出来,这说明读端确实进行等待了。
场景2:管道是会写满的,如果写满了就不能再写入了。
执行结果:
当cnt = 65535的时候,cnt的打印停止,不再增加,时间达到10秒后,父进程输出数据。
这说明,管道大小是有限的,为65535个字节。
场景3:如果关闭了写端,管道数据读取完毕后,再读,read就会返回0,表示读到了文件的结尾
场景4:写端一直写,读端关闭,操作系统会直接杀死一直在写入的进程。
OS是不允许这种无意义,低效率,或者浪费资源的事情发生的,OS会通过13号信号(SIGPIPE)终止进程
4.1.1.5 管道特点总结
1.单向通信
2.管道的本质是文件,因为fd的生命周期随进程,管道的生命周期是随进程的
3.管道通信,通常用来进行具有”血缘“关系的进程,进行进程间通信。常用与父子通信。pipe打开管道,并不清楚管道的名字,所以叫匿名管道。
4.在管道通信中,写入的次数和读取的次数不是严格匹配的,读写次数的多少没有强相关,这时字节流的一种表现
5.管道具有一定的协同能力,让读端和写端能够按照一定的步骤进行通信,这叫自带同步机制
4.1.2 命名管道
命名管道的原理
匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
注意:
普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题。
命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。
创建命名管道
$ mkfifo fifo//使用该命令来创建一个命名管道
有了这个命名管道就可以进行两个进程之间的通信了。
我们在一个进程A中,使用shell脚本每秒向命名管道写入一个字符串,在另一个进程B中用cat命令从命名管道中读取。
现象就是当进程A启动后,进程B会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信
前面在匿名管道我们说过,在写端一直写入,读端进程退出时,写端会直接被OS杀死。我们这里终止进程B(读端),看看进程A(写端)。
当我们ctrl + c终止进程B后,进程A直接退出了,bash进程被操作系统杀掉了,云服务也就退出了。
4.1.2.1 利用系统调用创建命名管道
mkfifo函数
查询man手册
函数原型
int mkfifo(const char *pathname, mode_t mode);
mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。
若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。
mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。
mkfifo函数创建成功返回0,创建失败返回-1
命名管道创建代码:
我们这里建立了两个C++文件,一个是服务端,一个是客户端,我们在服务端进行命名管道的创建。
运行程序,可以看到,命名管道文件成功创建了。
4.1.2.2 利用命名管道给server和client通信
本质上和匿名管道没有多大区别,这里不再详细分析。
服务端代码:
#include <iostream>
#include <sys/stat.h>
#include <sys/types.h>
#include <cerrno>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
//1.创建管道文件
umask(0);//将权限掩码设为0,防止mkfifo函数设置出来mode参数不是我们想要的
int n = mkfifo("my_fifo",0666);
if(n != 0)
{
std::cout << errno << ":" << strerror(errno) << std::endl;
return 1;
}
//2.让服务端直接开启管道文件
int rfd = open("my_fifo",O_RDONLY);
if(rfd < 0)
{
std::cout << errno << ":" << strerror(errno) << std::endl;
return 2;
}
//3.正常通信
char buffer[1024];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd,buffer,sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "client#" << buffer << std::endl;
}
else if(n == 0)
{
std::cout << "client exit,me too" << std::endl;
break;
}
else
{
std::cout << errno << ":" << strerror(errno) << std::endl;
break;
}
}
close(rfd);
return 0;
}
客户端代码:
#include <iostream>
#include <sys/stat.h>
#include <sys/types.h>
#include <cerrno>
#include <string>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <cassert>
int main()
{
//1.客户端不需要创建管道文件,只需要打开对应的文件即可
int wfd = open("my_fifo",O_WRONLY);
if(wfd < 0)
{
std::cerr << errno << ":" << strerror(errno) << std::endl;
return 1;
}
//可以进行常规通信了
std::string buffer;
while(true)
{
std::cout << "请输入你的消息# ";
std::getline(std::cin,buffer);
ssize_t n = write(wfd,buffer.c_str(),buffer.size());
assert(n > 0);
(void)n;
}
close(wfd);
return 0;
}
于是我们就可以进行通信了
在我们使用mkfifo函数创建命名管道的时候,我们可能会遇到一个问题:如果该命名管道已经存在了那就不能在创建了。我们每次都要删除它,才能重新编译文件。
我们可以使用下面的函数,程序执行完成的时候会删除掉我们的命名管道文件
int unlink(const char *pathname);
4.2 SysV共享内存
SysV共享内存是一种非常古老的共享内存方法,是在UNIX诞生早期就有的方法。SysV共享内存创建共享内存的方法是使用接口shmget,它有三个参数,分别是key、size、flag。其中key是一个整数,是表示通信信道的名称,两个进程要提前约定好key。Size代表共享内存的大小。Flag用来表示创建的行为,flag IPC_CREAT 表示如果通信信道存在就直接获取它,如果还不存在就创建它,没有IPC_CREAT的话表示只获取不创建。如果再加上IPC_EXCL的话,表示只创建,如果已经被别人创建了则返回失败。shmget返回的是共享内存的id,代表通信信道的句柄。然后拿着通信信道的句柄通过shmat接口就可以把底层的物理内存映射到本进程空间了。函数返回值就是映射到本进程虚拟内存空间的一个指针,然后就可以像访问普通内存一样读写这段内存了。任务完成之后就可以通过shmdt接口释放信道。注意这只是释放了本进程的通信信道,没有释放底层的物理内存,要释放底层物理内存的话,需要使用接口shmctl()并选择IPC_RMID操作。
4.3 POSIX共享内存
相信大家对前面的叙述都有个疑惑,用一个整数当做通信信道名称,那岂不是很容易就选重了,那不就错乱了嘛!而且如果有人恶意猜测使用你的key,你也没有办法。针对这个问题,POSIX设计出了一个新的共享内存方案,叫做POSIX共享内存,很好地解决了这个问题。POSIX共享内存使用接口shm_open来创建共享内存通信信道句柄,它的参数和open是一样的,但是它不创建磁盘文件。这样以来,我们使用的是一个路径名作为通信信道的名称,这就比一个整数key好多了,容易起名字还不容易重复。并且它的参数是和open一样的,所以它的第三个参数mode可以指定权限,这样就更安全了。shm_open的第二个参数和open的第二个参数是一样的,可以指定flag O_CREAT O_EXCL,这两个flag和前面的shmget可以达到相同的效果,你可以选择是仅加入已经信道,还是非要自己亲自创建信道,或者已有就加入没有就创建。shm_open返回的是一个fd,这个fd就是通信信道的句柄。有了这个fd,我们可以通过接口ftruncate来设置共享内存的大小。得到了信道句柄之后,我们加入信道的方式不是用的专用的方法,而是使用系统已有的接口,用的是shared mmap,这点和SysV共享内存有很大的不同。mmap之后我们就加入了信道,其返回值是本进程虚拟内存空间的指针,我们就可以像操作普通内存一样操作它了。
4.4 共享内存映射
系统调用mmap并不是专门用来做进程间通信的,它是用来做内存映射的。它的映射来源可以用文件也可以是匿名(也就是没有来源,直接分配内存并初始化为0)。它的映射方式可以是私有的,也可以是共享的。映射来源和映射方式两者一组合是四种方式。当我们使用共享映射方式的时候,正好可以用来做进程间通信。对于共享文件映射,两个进程映射相同的文件就可以达到共享内存的目的,文件名就是通信信道的名称,由名称直接加入信道,没有信道句柄。对于共享匿名映射,是通过fork之后在父子进程之间共享内存的。fork之后父子进程之间的内存本来是COW(写时复制)的,也就是说父子进程之间不会共享内存,但是被共享匿名映射的部分不会COW,而是在父子进程之间共享物理内存,这就达到了共享内存的效果。这种方法既没有信道名称也没有信道句柄,是通过继承方式直接就获得了信道。这两种共享内存的解除方法都是使用munmap函数。
4.5 Android ION
很多博客上都会介绍说ION是一个内存分配管理器,这么说既对也不对,单看ION它确实是内存分配管理器,但是我们不能单看ION,我们要把和dma-buf一起看。Dma-buf既不是dma也不是buffer,它是一个buffer sharing框架,重点是sharing。Dma-buf框架实现了进程与进程之间、进程与内核之间的内存共享方案。但是它仅仅是一个框架,本身并没有分配内存的能力。ION则在dma-buf框架的基础之上实现了内存分配管理功能,所以应该把ION与dma-buf当做是一个整体,看成是共享内存机制。ION与普通共享内存机制不同的是,它不仅仅可以在进程间共享内存,还能在进程与内核之间共享内存。ION在进程之间共享内存时,是一方通过/dev/ion的ioctl ALLOC命令创建一个fd,这个fd就是信道句柄,通过对这个fd进行mmap就可以通信了。这和POSIX共享内存的模式有点像,不同的是对方是如何得到这个fd的。POSIX共享内存是通过大家都shm_open打开相同的文件名得到了同一个信道的句柄(句柄值不一定相同,但是底层对应的信道是相同的)。ION是通过Binder向另一个进程传递fd的,Binder对fd做了特殊处理,对方收到的fd和自己的fd,数值不一定相同,但是底层对应的东西是相同的。如果直接给一个进程传递fd的值,那是没有意义的。ION和内核驱动之间共享内存有两种情况,一种是内核驱动创建了底层的物理内存然后把它包装成一个fd,通过一些系统调用传递给进程,进程对这个fd进行mmap就可以进行进程间通信了。另一种情况是进程创建了通信信道的fd,然后通过一些系统调用传递给内核驱动,内核驱动就根据这个fd找到其对应的物理内存。
ION里面有许多不同的堆,每个堆分配的物理内存区域和方式并不相同,可以在使用ION接口的时候通过指定flag来选择不同的堆。
4.6 dma-buf heaps
dma-buf heaps是ION的替代品。因为ION里面所有的堆都对应同一个设备文件/dev/ion,不同的堆是通过在接口中指定flag来选择的。那么这就存在一个问题,就是ION所有的堆对所有进程都是开放的,没法或者不太容易对不同的进程做权限限制。dma-buf heaps正好解决了这个问题,它把不同的堆分拆成了不同的设备,都在目录 /dev/dma_heap/ 下,比如 /dev/dma_heap/system 是默认的堆。这样不同的堆就可以设置不同的文件权限,还可以通过selinux进行限制,这样就大大提高了安全性。它的用法和底层逻辑与ION是一样了,这里就不再过多介绍了。值得一提的是dma-buf heaps已经合入了标准内核,而且Android也正在逐步替换ION。
4.7 SysV消息队列
SysV消息队列是一个有边界的消息传递式进程间通信。它的信道创建逻辑和SysV共享内存差不多。创建接口是msgget,有两个参数key和flag。Key是一个整数,是信道名称。Flag有两个,flag IPC_CREAT 表示如果通信信道存在就直接获取它,如果还不存在就创建它,没有IPC_CREAT的话表示只获取不创建。如果再加上flag IPC_EXCL的话,表示只创建,如果已经被别人创建了则返回失败。msgget返回的是消息队列的id,也就是信道的句柄。然后可以通过接口msgsnd和msgrcv来发送和接收消息,一个只能发送或者接收一个消息。当通信完成之后,可以通过接口msgctl的IPC_RMID操作来销毁消息队列。
4.8 POSIX消息队列
SysV消息队列和SysV共享内存存在的问题是一样的,于是又设计了POSIX消息队列。POSIX消息队列的创建接口是mq_open,它的参数和open是类似的。用一个字符串类型的name作为信道名称。还有一个flag参数和前面讲的flag参数是一样的,可以指定是创建信道还是加入已经的信道。返回值叫做消息队列描述符,是信道句柄。然后可以通过接口mq_send、 mq_receive来发送接收消息。当通信完成后可以通过接口mq_close来关闭信道。如果所有的进程都关闭信道了,底层信道才会被删除。
4.9 套接字
套接字是分为网络套接字和UNIX local套接字。网络套接字不仅可以在本机进行进程间通信,还能在不同的机器间进行通信。UNIX local套接字只能在本机的进程间进行通信。两者都分为流式套接字和数据报套接字,前者是无边界消息传递式进程间通信,后者是有边界消息传递式进程间通信。套接字是区分服务端和客户端的,服务端创建通信信道,客户端加入通信信道。套接字的接口这里就不介绍了,大家可以找一些网络编程相关的书籍或者博客来学习。
4.10 Android Binder
Android Binder是谷歌为Android开发的RPC,RPC是远程过程调用的意思。RPC也是一种进程间通信,但又不仅仅是进程间通信,它的使用接口表现为可以透明调用其它进程的函数。Binder的通信中枢是内核里的Binder驱动,它的用户空间接口是对虚拟设备/dev/binder的一系列ioctl命令。但是进程并不是直接使用这些ioctl命令的,而是使用谷歌封装好的libbinder库。Binder的具体细节这里就不讲了,给大家推荐两个学习博客
4.11 信号机制
信号机制是在UNIX里面很早就存在的机制,它是内核用来处理程序运行时发生错误的一种方法,也是给进程发送一些简单特定的消息的方法,所以也可以看做是一种进程间通信机制。但是它又比较特殊,它和一般的进程间通信机制的结构都不太相同。它是不需要建立通信信道的,因为它不是典型的进程间通信,或者说它的通信信道是天然建立好的,因为它用的是pid来指定消息传递给谁。它的发送是内核发送或者进程通过kill等接口发送,指定pid就能发送给对方。对方可以设置信号处理函数来接收处理信号,也可以不设置,内核会进行默认处理。
4.12 伪终端
大家可能听说过终端、虚拟终端、控制台、终端模拟器、伪终端等这些词。估计大家和我一样也是对这些词一头雾水,理不清它们到底是什么意思,相互之间是什么关系。其实我对虚拟终端和控制台也不太理解,但是对终端、终端模拟器、伪终端还是比较了解的,在这里给大家讲解一下。最早的时候,一台电脑还是一台几间房子那么大的大型机,普通人根本买不起,有些大学或者科研单位或者政府机关也只能买得起一台。然后是大家每人买一个终端连接到这台电脑就可以使用了。终端就是一台显示器加一个键盘,只不过这个显示器并不是像素显示器,而是字符显示器,一屏只能显示80x25的字符。当时的程序也都是命令行程序,从终端接收输入,再把结果输出到终端。具体到程序内部来说,fd 0 对应的就是终端输入,fd 1就是终端输出。终端并不是说键盘输入的是什么它就原封不动地传给程序,而是会做一定的预处理。
后来随着技术的不断发展,计算机就变成了我们今天使用的计算机。每个人都可以买一台独立的电脑了,而且显示器也变成了像素显示器了,可以显示丰富的画面。而且很多程序的模式也从命令行模式转变成了GUI模式。但是仍然有很多程序比较适合在命令行执行,仍然保留了命令行模式。为此系统开发了一个GUI程序,叫做终端模拟器,也是我们平常说的命令行界面或者终端程序。它利用图形界面模拟了之前的终端界面,让我们看起来像是在使用终端,但是它本身是一个GUI程序。终端模拟器是怎么运行命令行程序的呢?它会使用系统的接口创建一个伪终端,伪终端分为主端和从端两部分,模拟器自己拿主端,命令行程序拿从端,这样命令行程序仿佛就像运行在终端环境里一样。我们从键盘输入的字符其实是先按照GUI程序的逻辑传递给了终端模拟器,终端模拟器再把输入传递给伪终端的主端,然后伪终端在内核里按照终端本身的逻辑进行处理,再发给伪终端从端,这样我们的命令行程序才会收到输入。命令行程序的输出先发给伪终端从端,然后再进入内核里的伪终端,然后再发给伪终端主端,然后终端模拟器才收到我们的输出,然后它再按照GUI程序的方法把输出绘制到它的窗口上,我们就看到了程序的输出。所以说伪终端可以看做是终端模拟器和命令行程序之间的进程间通信机制。
5 总结
本文中我们先分析了进程间通信的本质,然后讲解了进程间通信的基本框架,最后详细分析了古老的通信机制”管道“以及简单介绍了Linux系统中存在的各种进程间通信机制。大家在实际的工作过程中可以根据自己的需求来选择使用哪种进程间通信机制。