三年半前,我写过一篇介绍Scudo的文章,那时候它刚刚被引入Android 11。如今几年过去,很多厂家却依然不愿意使用它。
作为应用而言,一款优秀的内存分配器应该是无感的。它意味着两个方面:
-
迅速地申请内存。
-
迅速地释放内存。
这个"迅速"的时间越短越好,但它同时受到内存的掣肘,毕竟我们不能无节制地使用它。因此内存分配器有了第三个目标:
- 高效地使用内存。
但Scudo还为自己设置了第四个目标:
- 安全地访问内存。
这里的"安全"指的是加强对内存非法行为的检测,譬如有意的踩踏行为或是double-free。另外也指增强内存行为的随机性,譬如让攻击者无法猜测下一次分配的地址。
正是第四个目标的设定,使得Scudo背负了包袱,拖累了它在性能和内存方面的表现。举个例子,Scudo在内存申请时需要进行洗牌以增加安全性,但它肯定没有顺序分配性能好。
"戴着镣铐跳舞",这六个字便是Scudo面临的困境。系统厂商看重安全性,而应用方可能更看重性能和内存,这才是Scudo在国内被冷落的主要原因。当然,Scudo这几年并没有躺平,它也在不断地进化迭代。而本文就是尝试从宏观角度,分析Scudo这些年在性能和内存上所做的优化。
1. 迅速地申请内存
申请内存时有两件事情会影响它的速度,分别是:
- 如何找到可用的空闲内存?
- 如何应对多线程同时申请时的竞争关系?
解决好这两个问题,就可以保证申请时的速度。Scudo分别采用了Cache和TSD来应对这两个问题。
Cache利用了内存的分级思想,批量地取出一些内存作为"第一级缓存"。当申请到来时,优先从第一级缓存中分配。第一级缓存设计成数组结构,每次存取操作的都是数组的最后一个元素,这样既方便,也提高了局部性。当第一级缓存无可用内存时,可以从第二级缓存中补充弹药。第二级内存设计成链表结构,每个链表节点都指向一个数组,该数组可以批量地补充给第一级缓存。当第二级缓存无可用内存时,可以从主存中补充弹药,这通常需要mmap一块新的区域,开辟出更多的空闲内存。这种缓存的设计可以缩短可用内存寻找的时间,对性能颇有好处。
TSD(Thread Specific Data)让不同的线程在分配时使用不同的内存,从而避免了竞争。回到上面Cache的描述,第一级缓存一定是使用频次最高、对性能影响最大的缓存。因此TSD的作用范围主要是第一级缓存。创建多个第一级缓存分给不同的线程使用,这样可以减少申请时的竞争等待。
2. 迅速地释放内存
释放是申请的逆向过程。按照Cache的思想,首先会将内存归还给第一级缓存。当第一级缓存装不下后,便会尝试归还给第二级缓存。以此类推。但归还需要耗时,从极致性能的角度出发,不归还反倒是最快的策略。但这会带来内存的浪费。举个例子,你喝不完的水可以倒回杯子,杯子满了以后可以倒回水壶,但你也可以直接倒在地上,这样最快,但会背负"浪费"的骂名。
归还之后的内存从物理内存的视角依然属于当前进程,这就会导致尽管用户释放了大量内存,但进程的RSS并没有下降,这会挤压系统中其他进程的可用内存。因此,释放的最后一级一定需要将空闲页归回给系统,让RSS降下来。而它也是对性能影响最大的环节。
归还空闲页之所以产生性能损耗,主要有两个原因:
- 内存的使用是碎片化的,因此哪些页可以回收是需要遍历统计的。
- 空闲页的回收需要经过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大费周章。
小结
跳出技术的视角,我们可以发现上述的优化思路其实都来源于生活:
- 抓住主要矛盾,通过资源的分配来解决问题。譬如Cache机制牺牲了一些内存,但换来了更好的性能。
- 做一件事要考虑"值不值得",尽量在产出最大的时候做。譬如页归还的时机选择。
- 精细化管理,分而治之。譬如Group的划分。
好的设计并不会凭空产生,它的思想内核通常来自于社会实践。多从这个视角观察源码,你会发现代码可爱了许多。