MMKV 是基于 mmap 内存映射的移动端通用 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。从 2015 年中至今,在 iOS 微信上使用已有近 3 年,其性能和稳定性经过了时间的验证。近期已移植到 Android 平台。在腾讯内部开源半年之后,得到公司内部团队的广泛应用和一致好评。现在一并对外开源:
https://github.com/tencent/mmkv
欢迎 Star、提 Issue 和 PR。
MMKV 的源起、设计原理与具体实现参考前文。将 MMKV 迁移到 Android 平台过程中,很多同事反馈需要支持多进程访问——这在之前是没有考虑过的(因为 iOS 不支持多进程),需要进行全盘的设计和仔细的实现。
说到 IPC,首要的问题就是架构选型,不同的架构效果大相径庭。
Android 平台第一个想到的就是 ContentProvider:一个单独进程管理数据,数据同步不易出错,简单好用易上手。然而它的问题也很明显,就是一个字慢:启动慢,访问也慢。这个可以说是 Android 下基于 Binder 的 CS 架构组件的通用痛点。至于其他的 CS 架构,例如经典的 socket、PIPE、message queue,因为要至少 2 次的内存拷贝,就更加慢了。
MMKV 追求的是极致的访问速度,我们要尽可能地避免进程间通信,CS 架构是不可取的。再考虑到 MMKV 底层使用 mmap 实现,采用去中心化的架构是很自然的选择。我们只需要将文件 mmap 到每个访问进程的内存空间,加上合适的进程锁,再处理好数据的同步,就能够实现多进程并发访问。
然而去中心化的架构实现起来并不简单,Android 是个阉割版的 Linux,IPC 组件的支持比较残缺。例如,说到进程锁第一个想到的就是 pthread 库的 pthread_mutex,创建于共享内存的 pthread_mutex 是可以用作进程锁的,然而 Android 版的 pthread_mutex 并不保证robust,亦即对 pthread_mutex 加了锁的进程被 kill,系统不会进行清理工作,这个锁会一直存在下去,那么其他等锁的进程就会永远饿死。其他的 IPC 组件,例如信号量、条件变量,也有同样问题,Android 为了能够尽快关闭进程,真是无所不用其极。
找了一圈,能够保证 robust 的,只有已打开的文件描述符,以及基于文件描述符的文件锁和 Binder 组件的死亡通知(是的,Binder 也是依赖这个清理机制运作,打开的文件是 /dev/binder)。
我们有两个选择:
关于 mutex 清理,有个可能的方案是基于 Binder 死亡通知进行清理:A、B进程相互注册对方的死亡通知,在对方死亡的时候进行清理。但有个比较棘手的场景:只有 A 进程存在,那么他的死亡通知就没人处理,留下一个永远加锁的 mutex。Binder 规定死亡通知不能本进程自行处理,必须由其他进程处理,所以这个问题不好解决。
综合各种考虑,我们先将文件锁作为一个简单的互斥锁,进行 MMKV 的多进程开发,稍后再回头解决递归锁和读写锁升级/降级的问题。
首先我们简单回顾一下 MMKV 原来的逻辑。MMKV 本质上是将文件 mmap 到内存块中,将新增的 key-value 统统 append 到内存中;到达边界后,进行重整回写以腾出空间,空间还是不够的话,就 double 内存空间;对于内存文件中可能存在的重复键值,MMKV 只选用最后写入的作为有效键值。那么其他进程为了保持数据一致,就需要处理这三种情况:写指针增长、内存重整、内存增长。但首先还得解决一个问题:怎么让其他进程感知这三种情况?
状态同步逻辑用伪码表达大概是这个样子:
当一个进程发现 mmap 写指针增长,就意味着其他进程写入了新键值。这些新的键值都 append 在原有写指针后面,可能跟前面的 key 重复,也可能是全新的 key,而原写指针前面的键值都是有效的。那么我们就要把这些新键值都读出来,插入或替换原有键值,并将写指针同步到最新位置。
当一个进程发现内存被重整了,就意味着原写指针前面的键值全部失效,那么最简单的做法是全部抛弃掉,从头开始重新加载一遍。
正如前文所述,发生内存增长的时候,必然已经先发生了内存重整,那么原写指针前面的键值也是统统失效,处理逻辑跟内存重整一样。
到这里我们已经完成了数据的多进程同步工作,是时候回头处理锁事了,亦即前面提到的递归锁和锁升级/降级。
为了解决这两个难题,需要对文件锁进行封装,增加读锁、写锁计数器。处理逻辑如下表:
需要注意的地方有两点:
写了个简单的测试,创建两个 Service,测试 MMKV、MultiProcessSharedPreferences、SQLite 多进程读写的性能,具体代码见 GitHub。
(测试环境:Pixel 2 XL 64G, Android 8.1.0,单位:ms。每组测试分别循环 1000 次;MultiProcessSharedPreferences 使用 apply() 同步数据;SQLite 打开 WAL 选项。)