【Android 15】内存分配器Scudo在这些年的优化

三年半前,我写过一篇介绍Scudo的文章,那时候它刚刚被引入Android 11。如今几年过去,很多厂家却依然不愿意使用它。

作为应用而言,一款优秀的内存分配器应该是无感的。它意味着两个方面:

  1. 迅速地申请内存。

  2. 迅速地释放内存。

这个"迅速"的时间越短越好,但它同时受到内存的掣肘,毕竟我们不能无节制地使用它。因此内存分配器有了第三个目标:

  1. 高效地使用内存。

但Scudo还为自己设置了第四个目标:

  1. 安全地访问内存。

这里的"安全"指的是加强对内存非法行为的检测,譬如有意的踩踏行为或是double-free。另外也指增强内存行为的随机性,譬如让攻击者无法猜测下一次分配的地址。

正是第四个目标的设定,使得Scudo背负了包袱,拖累了它在性能和内存方面的表现。举个例子,Scudo在内存申请时需要进行洗牌以增加安全性,但它肯定没有顺序分配性能好。

"戴着镣铐跳舞",这六个字便是Scudo面临的困境。系统厂商看重安全性,而应用方可能更看重性能和内存,这才是Scudo在国内被冷落的主要原因。当然,Scudo这几年并没有躺平,它也在不断地进化迭代。而本文就是尝试从宏观角度,分析Scudo这些年在性能和内存上所做的优化。

1. 迅速地申请内存

申请内存时有两件事情会影响它的速度,分别是:

  1. 如何找到可用的空闲内存?
  2. 如何应对多线程同时申请时的竞争关系?

解决好这两个问题,就可以保证申请时的速度。Scudo分别采用了Cache和TSD来应对这两个问题。

Cache利用了内存的分级思想,批量地取出一些内存作为"第一级缓存"。当申请到来时,优先从第一级缓存中分配。第一级缓存设计成数组结构,每次存取操作的都是数组的最后一个元素,这样既方便,也提高了局部性。当第一级缓存无可用内存时,可以从第二级缓存中补充弹药。第二级内存设计成链表结构,每个链表节点都指向一个数组,该数组可以批量地补充给第一级缓存。当第二级缓存无可用内存时,可以从主存中补充弹药,这通常需要mmap一块新的区域,开辟出更多的空闲内存。这种缓存的设计可以缩短可用内存寻找的时间,对性能颇有好处。

TSD(Thread Specific Data)让不同的线程在分配时使用不同的内存,从而避免了竞争。回到上面Cache的描述,第一级缓存一定是使用频次最高、对性能影响最大的缓存。因此TSD的作用范围主要是第一级缓存。创建多个第一级缓存分给不同的线程使用,这样可以减少申请时的竞争等待。

2. 迅速地释放内存

释放是申请的逆向过程。按照Cache的思想,首先会将内存归还给第一级缓存。当第一级缓存装不下后,便会尝试归还给第二级缓存。以此类推。但归还需要耗时,从极致性能的角度出发,不归还反倒是最快的策略。但这会带来内存的浪费。举个例子,你喝不完的水可以倒回杯子,杯子满了以后可以倒回水壶,但你也可以直接倒在地上,这样最快,但会背负"浪费"的骂名。

归还之后的内存从物理内存的视角依然属于当前进程,这就会导致尽管用户释放了大量内存,但进程的RSS并没有下降,这会挤压系统中其他进程的可用内存。因此,释放的最后一级一定需要将空闲页归回给系统,让RSS降下来。而它也是对性能影响最大的环节。

归还空闲页之所以产生性能损耗,主要有两个原因:

  1. 内存的使用是碎片化的,因此哪些页可以回收是需要遍历统计的。
  2. 空闲页的回收需要经过madvise系统调用,这是一个有点耗时的操作。

由于归还空闲页是个相对耗时的操作,所以在做这件事之前需要考虑"性价比",即能不能在一次过程中归还尽可能多的空闲页?

Scudo采用了三个措施来缓解这个问题。

第一个措施是尽量减少小内存Region的归还动作。因为小内存的碎片化更加严重,一页上的chunk数量越多,整页都空闲的概率就越低。这里Google自己做了个实验,发现只有当空闲内存超过90%时,针对小内存的页归还才有意义。比如下面的数据,对于32bytes的region,当空闲内存达到97%时,只有7%的页完全空闲。

rust 复制代码
Size: 32
92% freed -> 0% released
93% freed -> 0% released
94% freed -> 0% released
95% freed -> 1% released
96% freed -> 3% released
97% freed -> 7% released
98% freed -> 17% released
99% freed -> 41% released
Size: 48
92% freed -> 0% released
93% freed -> 0% released
94% freed -> 1% released
95% freed -> 3% released
96% freed -> 7% released
97% freed -> 13% released
98% freed -> 27% released
99% freed -> 52% released

所以针对256bytes以下的小内存,Scudo设定了如下的阈值,当整个region中的空闲内存不超过阈值时,便不会进行空闲页归还的动作,因为它既耗时也没什么效果。

threshold = (100- 1 - BlockSize/16) / 100

可是当空闲内存超过97%时,会不会频繁地发生页归还?答案是会的,实际场景也确实出现了因为这种情况导致的掉帧,因此有了下面两个措施。

第二个措施是增加时间限制,譬如一秒内只允许发生一次页归还的动作,这样可以避免短时大量释放导致的卡顿,但代价就是RSS降的没那么及时。

第三个措施是增加delta free bytes的限制,也即两次页归还之间必须新增一定数量的空闲内存,这样即可以降低归还的频次,也可以让下一次归还更加有效。

上面讨论的是如何控制页归还的频率,那么页归还这个动作本身是否还有优化的空间?

在研究源码的过程中,我感觉这里还可以做些工作,因此跟Google提了两个建议。一个是引入bitmap加速空闲页的遍历统计过程,另一个是将页归还的动作放到单独的线程去处理。这二者试图解决的都是单次页归还对performance-sensitive线程(譬如UI线程)的影响。Google表示第一个他们曾经考虑过,未来可能会做,第二个他们正在做。

3. 高效地使用内存

高效地使用内存,意味着用尽可能少的内存满足尽可能多的需求,这里面核心的点在于控制内存的碎片化。由于Scudo没有compact和copy机制,因此无法对既有的内存进行整理,但它可以在申请时做些工作,譬如尽量让申请的内存集中在一块区域。不过这又和Scudo强调的安全性相冲突,因为从安全的角度来说,每次申请到的内存地址越随机、越离散越好。

最终Google做了个折中方案。它将每个256M的Region切割成许多小的Group(e.g. 256K),分而治之。每次申请时,都优先使用FreeList最头部的Group,这样可以一定程度地减少碎片化。但Group内部的分配依然随机,因此保证了安全性。

这样做还有一个好处,就是页归还的时候可以针对每个Group单独处理,选择回收效率较高的Group,而不必针对整个Region大费周章。

小结

跳出技术的视角,我们可以发现上述的优化思路其实都来源于生活:

  1. 抓住主要矛盾,通过资源的分配来解决问题。譬如Cache机制牺牲了一些内存,但换来了更好的性能。
  2. 做一件事要考虑"值不值得",尽量在产出最大的时候做。譬如页归还的时机选择。
  3. 精细化管理,分而治之。譬如Group的划分。

好的设计并不会凭空产生,它的思想内核通常来自于社会实践。多从这个视角观察源码,你会发现代码可爱了许多。

相关推荐
拭心12 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王14 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡14 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道15 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库15 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道16 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe16 小时前
Android Hook - 动态加载so库
android
居居飒17 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He20 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗20 小时前
Android笔试面试题AI答之Android基础(1)
android