Android IPC | 内存映射详解

1,715 阅读13分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第3天,点击查看活动详情

前言

在上一篇文章中说Parcelable原理时,我们说其C++层会在共享内存中开辟一块内存,然后是往这块内存写入数据,至于这块内存指的是什么,本章内容将仔细介绍,以及涉及一个非常重要的内存映射函数的原理简析。

正文

因为Android系统是Linux平台上的系统,所以本篇文章会先介绍一些关于Linux的知识,包括虚拟内存、进程空间等,只有了解了这些知识,才能更好地理解Android的跨进程通信。

Linux相关知识

先从最基础的虚拟内存相关知识说起。

物理内存

我们使用的Android手机都有内存和存储参数,比如8G内存加256G存储,这时你或许有个疑问,这俩者有什么区别,为什么要设计出俩种。

最大的区别就是速度差异,CPU作为核心硬件,其速度很快但是容量很小,而存储磁盘的速度非常慢但是容量大,这俩者的速度差异可以用亿级倍来衡量,所以这时就需要物理内存来缓解俩者速度差异,内存的访问速度在硬件磁盘和CPU之间。

比如我手机安装了一个100M的App,该App是存放在存储磁盘中的,只有我点开该App,才会把相关代码加载到内存中,这时内存和CPU进行交互数据通信,在有必要时才和存储磁盘通信,这样就可以提高运行效率和速度。

虚拟内存

在前面我们说过,在Android系统中,一个应用就是一个进程,而一般给一个进程的内存是4G,但是这里实际上使用内存的空间一般是500M左右,超过某个阈值会发生OOM。这里又有了疑问,一个应用还可以开启多个进程,系统服务也有很多进程,管理这些进程的内存使用就是一件麻烦的事情,所以就有了虚拟内存的概念。

image.png

比如上图中,中间是物理内存,也就是我们手机的内存条;这时有2个进程(实际会有很多)P1和P2,这时操作系统偷偷告诉P1和P2,我这个整个内存条都是你的,你随便用。可是实际上,操作系统只是画了个大饼,这些内存说是给P1和P2,实际上只是给了一个序号而已,当P1和P2真正使用这些内存时,系统才开始对物理内存操作,拼凑出各个块给进程中。P2以为在使用A内存,其实操作系统悄悄定位到了B内存,而且还可以多个进程共同使用一块物理内存。

操作系统这种欺骗进程的手段,就是虚拟内存。对于P1和P2等进程来说,虽然给它们分配了好几个G的内存,其实这好几个G的内存是虚拟内存,而真正使用的物理内存是那一块,它们并不知道也无需关心。

分页和页表

虚拟内存是操作系统里的概念,对于操作系统来说,虚拟内存就是一张张的对照表,P1获取A内存里的数据就应该去物理内存的A地址去找,而P2获取A内存里的数据就应该去物理内存的B地址去找。

我们知道系统里基本单位是Byte字节,而给一个进程分配了4G虚拟内存,如果讲每个虚拟内存的Byte都对应到物理内存地址,这个表就要几个G来存储,这明显不可能,于是操作系统引入了的概念。

在系统启动时,操作系统将物理内存以4K为单位,划分为各个页。之后进行内存分配时,都是以页为单位,那么虚拟内存对应物理内存页的映射表就大大减小了,8G内存一般只需要几M的映射表,而且Linux还为大内存设计了多级页表,可以进一步减小内存消耗。操作系统虚拟内存到物理内存的映射表,就被称为页表

虚拟内存好处

使用虚拟内存管理,大概有俩个好处:

  1. 内存完整性,由于虚拟内存对进程的"欺骗",每个进程都以为自己获取的内存是一块连续的地址,其实在物理内存上是不一定的。
  2. 安全性,由于各个进程都是通过页表来访问物理内存的,这样就可以设置权限,让多个进程之间相互隔离,不会导致P1和P2随意访问同一块物理内存。

进程空间划分

在了解完Linux其实给每个进程分配的内存都是虚拟内存后,我们来看一个新概念,即进程空间划分

Linux对每个进程都分了4G的内存空间(后面说内存,一般指的是虚拟内存),而这4G内存空间又分为了3G的用户空间和1G的内核空间,即把进程内的用户空间和内核空间隔离开来

image.png

如图所示,每个进程的用户空间之间数据是不能共享的,而划分用户空间和内核空间的原因就是为了安全,其中用户空间运行着应用的代码,而内核空间则是调用系统的代码,而这俩者通信需要使用系统调用,如果可以随意调用系统的代码,则很容易把系统整挂掉。

这里可以发现用户空间和内核空间的交互是通过系统调用,系统调用也是只能由系统程序运行,主要通过2个函数:

  1. copy_from_user():将用户空间的数据拷贝到内核空间
  2. copy_to_user():将内核空间数据拷贝到用户空间

所以当进程P1和进程P2要通信时,进程就要把数据拷贝给内核空间,再由内核空间拷贝到另一个进程的用户空间,而当代码运行在什么空间,我们就说该应用程序在什么状态:

  1. 用户态,当前代码在用户空间运行;
  2. 内核态,当前代码在内核空间运行;

这也就是我们平时听到的说状态切换会耗费系统资源的原因,因为要通过系统调用不断的拷贝数据。

进程隔离和IPC

从内存划分我们就可以看出,因为每个进程分配的空间都是虚拟内存,所以每个进程的用户空间之间是无法直接通信的,从Android系统来看就是Android的进程是互相独立、隔离的。

那如果想进行IPC通信该如何做呢,比如进程P1和进程P2通信,流程如下:

  1. 进程P1通过系统调用,将需要发送的数据通过copy_from_user()函数拷贝到Linux进程的内核空间中的缓冲区。
  2. 内核服务程序,唤醒接收进程P2,然后通过系统调用函数copy_to_user()把这部分数据拷贝到P2的用户空间中。

这样就完成了一次IPC,可以发现一共进行了2次拷贝。大致流程如下图:

image.png

可以发现这种IPC的效率比较低,原因是一次发送、接收数据就需要拷贝2次数据,但是Android中的Binder可以做到只拷贝一次,是利用mmap()系统调用来实现的,所以为了给下一篇文章铺垫,现在需要说一下这个mmap()即内存映射相关知识。

内存映射

mmap(Memory Mapping)即内存映射,定义上来说就是关联进程中的一个虚拟内存区域和一个磁盘上的对象,使其二者产生映射关系。

内存映射使用非常广泛,不仅Android中Binder使用较多,还包括以效率闻名的MMKV库其原理也是内存映射。

内存映射作用

如果进程1和进程2中的和同一个共享对象建立映射关系时,当进程1对虚拟内存区域进行写操作时,由于进程2和共享对象存在映射关系,进程2也是对这部分数据是可见的。

image.png

理解这里的关键就是前面说的Linux给每个进程所分配的内存是虚拟内存,只是一个记录物理地址的映射表而已。在这个前提下,就可以让多个进程的内存指向同一块物理内存地址或者物理磁盘地址,这样就实现了共享。

从这里我们就可以发现使用内存映射的好处:

  1. 提高数据读、写的性能,因为减少了数据拷贝次数
  2. 用户空间和内核空间可以通过映射区域直接交互,而不用频繁状态切换
  3. 可以用内存读写,代替I/O读写
  4. 提高内存利用率,因为可以多个进程共享一个对象

mmap()函数

那么这么好用的功能是如何实现的呢? 其实通过调用系统函数:mmap()即可,该函数在C++层,如果我们想自己实现类似功能,必须要了解JNI,在C/C++代码中调用该函数,具体实现就不说了,看一下该函数的原型:

void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

该函数的作用如下:

  1. 创建进程用户空间中的一块虚拟内存区域T;
  2. 实现映射关系,把该内存区域T和某一个共享对象进行映射;(这个共享对象可以是内存的一块物理地址,也可以是磁盘上的物理地址)

这时仅仅建立了映射关系,当一个进程往该虚拟内存区域T写数据时,其他和改共享对象建立映射关系的进程就能看到数据。

应用场景

应用场景主要就是文件读写和IPC通信,而mmap的高效率本质就是进程的用户空间可以直接访问进程的系统空间,从而减少一次拷贝过程,我们就来场景的引用场景。

文件读写

一般来说,修改一个文件的内容需要下面3个步骤:

  1. 把文件内容读到内存条中;
  2. 修改内存中的内容;
  3. 把内存的数据写入到文件中。

整个流程如下图所示:

image.png

这里的页缓存(page cache)是读写文件时的中间层,内核使用页缓存和文件的数据库关联起来,所以应用程序读写文件时,实际操作的是页缓存。

从传统读写文件的过程中,我们可以发现有个地方可以优化:如果可以直接在用户空间读写页缓存,那么将免去将页缓存的数据复制到用户空间缓冲区的过程,即可以减少read和write的过程。

这里就可以使用mmap来实现,使用mmap系统调用可以将用户空间的虚拟内存地址与文件进行映射(绑定),对映射后的虚拟内存地址进行读写操作就如图对文件进行读写操作一样。如下图:

image.png

前面说过,读写文件必须要经过页缓存,所以mmap映射的正是文件的页缓存,而非磁盘中的文件本身。由于mmap映射的是文件的页缓存,所以就涉及到同步问题,即页缓存会什么时候把数据同步到磁盘。

Linux内核并不会主动把mmap映射的页缓存同步到磁盘,而是需要用户主动触发,同步mmap映射的内存到磁盘有4个时机:

  • 调用msync函数主动进行数据同步。
  • 调用munmap函数对文件进行解除映射关系时。
  • 进程退出时。
  • 系统关机时。

从这里我们发现进程退出时会同步,这个特性有个非常重要的作用,就是可以利用mmap来做一个日志上报系统,当系统出现异常时,可以及时把异常信息保存下来。

IPC通信

从文件读写我们就可以看出mmap的作用,它可以把进程用户空间中某块虚拟地址设置为内核空间某块虚拟地址一样,这样就都是指向同一块地方,如果了解过C/C++的指针,就非常容易理解这个。

而把俩块地址指向同一块区域的好处就是可以减少一次用户空间和内核空间之间的拷贝,所以我们看一下前面所说的进程IPC流程:

image.png

这里我们可以看见进程P1向进程P2发送一个数据,需要进行俩次拷贝,这时我们就可以利用mmap原理,来减少一次数据拷贝。

比如这里可以让进程P1和某个对象进行mmap映射,这时会在进程P1的用户空间和内核空间都会创建出一块虚拟内存,而这俩快内存都指向同一个缓冲区,这个缓冲区就是用来数据传输的中间,如下图:

image.png

在这种情况下进程P1中某一块内存地址就直接指向了内核空间中指向的某块物理内存,这样进程P1对这块空间写数据,就不需要再进行一次拷贝了,这样和前面方式就少了一次拷贝,效率大大提升。

总结

本篇文章涉及的知识点挺多的,现在做个小总结:

  1. 首先需要对Linux系统的内存管理有个简单了解,即进程所操作的内存都是虚拟内存
  2. 其次要知道为了安全性考虑,Linux对每个进程的内存占用都划分了用户空间和内核空间,其中用户空间数据不共享,想共享的话必须通过内核空间。
  3. 默认的IPC方式需要用户态和内核态不断切换和拷贝数据,使用系统调用
  4. 内存映射mmap就是可以把用户空间的某段虚拟地址直接和文件或者某个对象产生映射关系,即该进程的用户态中某段内存和内核态某段内存指向同一块区域,从而达到减少一次数据拷贝的目的。
  5. 常用的使用场景比如文件读写和IPC通信,而在Android中用到的技术有Binder,MMKV,一些高性能的日志库等。

下篇文章开始,就切换到Android,先从最常见的通信方式AIDL说起,然后透过AIDL来分析Binder的运行原理。

笔者水平有限,如有问题,欢迎大家评论指正。