作者:于聪,抖音研发-客户端基础技术团队 Android 技术专家
文章中所述技术实现已申请专利
导读:
随着业务发展,国内各类 App 均朝向“超级App”的方向发展:单个 App 不再围绕单个功能开发,而是以 App 为载体建设为综合性的平台。这对存量旧手机的体验与稳定性带来了极大的挑战: Android art 虚拟机的heap内存十分有限,部分老机型即使在app标注largeHeap后还是仅有256MB; Android 9 以下版本,单个进程的fd上限为1024;部分厂商在 Android 8 以下系统版本,更是限制一个app的进程+线程数不能超过500。
本文将围绕上述的三个方向:内存、FD、线程,分别深度揭秘抖音如何挽救存量旧手机的用户体验。
1. ART 虚拟机 malloc space扩容
在本优化落地之前,抖音系 app 的 java oom 问题中,Android 6-7 系统占比超过 50%(以 pv 计 )。以机型维度简单归因不难发现,出现问题的这类机型的 art 虚拟机 heap 大小仅为 256MB。
这些 256MB 的机型并不是抖音 app 没有声明 largeHeap 或者 largeHeap 未生效的问题,而是厂商在 build.prop 中声明的最大 heap 就是 256MB。对于这部分抖音用户,不仅 crash 率非常高,不 crash 的情况下因为要频繁 gc,使用体验也非常差。
正对这种情况,笔者开始着手解决这个问题:探索 Android 6-7 系统下 art 虚拟机 heap 扩容。
1.1 基础知识
art 虚拟机的 heap 由多个类型的 space 组成,其中 app 运行中,大部分普通内存申请都发生在主 space 上,大对象分配在 largeObjectSpace ,一些特殊使用(例如方便 native 快速共享数据的 buffer )分配在 non-moving space 上。不同 art 版本因为需要配合 gc 算法不同,其主要 space 实现也不同:
-
android 5-7 使用 cms + copy gc,对应 malloc space
-
Android 8-14 使用 cc 下,对应 region space
-
Android 15+ 使用 cmc 下(很多国内厂商还在使用 cc ),对应 bump pointer space
篇幅限制,这里只展开一下 app 中最常用的主 space—— Android 5-7 上也就是 malloc space 的扩容方案。
art 虚拟机内存布局示意图
android 5-7 系统上的 art ,默认首先使用 cms (并发标记-清除算法 ),在 app 退到后台或者 art heap 严重不足触发 full gc 时,会使用 copy gc (标记-复制算法,art 内部称为 semi space collector )进行更加深度的垃圾清理并且实现内存碎片整理。
1.2 技术方案
android 5-7 系统的 art,其主 space 为了实现对 copy gc 的支持,需要两块同样大小的 malloc space 互相作为对方的 backup :
-
app 在运行时,其 heap 上的对象只会存在于两个 malloc space 其中的一个
-
cms 工作时不会切换当前使用 malloc space (因为知识标记-清除,不会移动对象 )
-
当 copy gc 工作时,需要 stop the world 后进行标记-复制,将存活对象从 原工作 malloc space 移动到 backup malloc space,之后切换两个 malloc space 的身份: backup 变为 main,main 变为 backup
扩容流程内存布局变化示意图
这里的扩容方案,利用了上述特性:
-
我们可以限制 copy gc 的发生,从而限制 art 虚拟机暂时只能工作在一个 space 上
-
释放掉此时没有在使用中的 backup space,并创建更大的一块 backup space
-
接触 copy gc 限制并触发 copy gc,使 art 的 main space 切换到扩容后的 backup space 上
-
重复上述1-3,再扩容另一块 space
-
修改 art heap 的容量限制
至此,我们就实现了整个 art heap 的主要 space 的扩容,下面来详细展开一下一些关键实现细节:
1.2.1 锁定一个 space
由于扩容过程中需要释放原来的 backup space ,所以必须先让 art 虚拟机的 heap 工作在一个固定的 space 上。其实这种“限制”场景,在 art 虚拟机标准的工作状态下就存在:当我们通过 jni 方法在 native 上持有一个 java heap 上的对象时,由于 native 层拿到的是 java heap 上对象的内存地址,此时如果进行任何的 moving gc(会移动对象地址的 gc 统称, copy gc 也是一种 moving gc )会导致 native 持有的内存地址失效,从而导致不可预期的结果。所以 art 虚拟机会在 native 持有其 heap 对象地址时,临时禁用 moving gc。
JNIEnv 中提供的GetPrimitiveArrayCritical/ReleasePrimitiveArrayCritical就是一个 art 外部 native 代码持有了 java 对象的内存指针的“特殊场景”。
当然我们还有另一种方案:简单整理 art 源代码中触发 gc 的入口可得:
art::Heap::PerformHomogeneousSpaceCompact
art::Heap::CollectGarbageInternal
由于 android 5-7 时代的系统对于 libart.so 的符号还是非常完整的,简单检查5-7系统版本下多个厂商的 libart.so 符号表可以确认上述方法符号均存在
通过 inline hook 的方式,在 heap 扩容期间的,对这些 gc 入口方法通过 pthread_mutex_lock 阻塞住调用(并非直接 skip 调用,因为调用可能来自 alloc,直接 skip 可能会导致 alloc 失败 ),在 heap 扩容结束后再唤醒原 gc 调用
1.2.2 搜索扩容内存空间
乍一看“寻找扩容空间”读者可能会存在疑惑:直接在通过 proc/self/maps 寻找空闲空间进行 mmap ,甚至说直接 malloc 一大段内存作为扩容空间不行吗?答案是不行
简单来说, art 虚拟机为了最大限度节省设备内存,使用了“指针压缩”技术:在 64 位机型上,正常表示一个地址为 64 位,但 art 中对所有 heap 上的对象地址均使用 32 位存储(实际由于 LockWord+ 对象地址对齐的设计,用于存储地址的只有 28 位[0-27] ),这就导致 art 虚拟机最大理论寻址空间为 4GB。
art 虚拟机为了最简化32位地址->64位地址的映射,选择将 art 设计为工作在进程地址空间的最低4GB区域——这样压缩后的指针,不要加一个 bias 就实现了地址的直接映射(当然由于 LockWord 的存在,还是需要执行一步位移操作 )。
art 虚拟机内存布局示意图
在低4GB区域上,由于 card table 映射管理区域限制、 heap 分 space 设计、 image space 固定占用等原因,实际 art 虚拟机能用于扩容的空间需要同时满足下面的条件:
-
地址范围必须大于 linux 用户空间最小地址,小于 4GB
-
只能在 card table 映射范围内
-
一个 malloc space 的内存地址必须是一段连续的内存
-
两个 malloc space 的内存大小必须一致
-
内存的其实地址必须为 pagesize 对齐(从 maps 中获得的地址本身就是对齐的 )
1.2.3 创建space
找到合适的扩容内存地址后,就可以开始着手在该地址上创建新的 malloc space 了。原理也比较明确:阅读 art heap 的创建流程,我们直接寻找其中创建 space 流程需要的 symbol 来触发创建即可:
总结下来大致流程为:
-
通过 MapAnonymous 方法,在指定内存地址上创建 MemMap
-
通过 CreateMallocSpaceFromMemMap 方法,使用指定 MemMap 创建 malloc space
这两个方法应的 symbol 也都在 libart.so 中存在,通过老生常谈的 android 特色 dlsym 找到对应符号并触发调用即可
1.2.4 替换 heap 中 space 引用
获取 heap 指针以及 heap 上各类字段,才能进行实际的修改。很多现有的代码实践中都是通过内存搜索来实现的。由于 heap 对象实例被 runtime 实例持有,通过双重循环内存 搜索来匹配 heap 指针:
-
通过符号表获取到 runtime 指针
-
从 runtime 起始地址开始循环,假定每处位置都是 heap
-
以当前假定的 heap 起始地址开始,判断当前位置的指针指向的对象是否是目标对象(例如 RegionSpace,通过对比对象头部的 vtable 指针实现 ),如果判断成立,则当前的假定 heap 就是 heap
双重内存搜索 逻辑不仅不够健壮,兼容性差,而且性能也不够好。
其实利用一下c++的调用约定:对象方法的入参中,第一个位置(x0寄存器 )为当前对象自身。所以我们可以通过 inline hook 我们需要关心的对象(例如 art::Heap )的对象方法,通过其他方法触发该方法的调用,从而在 x0 寄存器中拿到 this 的地址。
再次来简单阅读下 heap 代码,注意到:
// Removes the growth limit on the alloc space so it may grow to its maximum capacity. Used to
// implement dalvik.system.VMRuntime.clearGrowthLimit.
voidClearGrowthLimit();
// Make the current growth limit the new maximum capacity, unmaps pages at the end of spaces
// which will never be used. Used to implement dalvik.system.VMRuntime.clampGrowthLimit.
voidClampGrowthLimit()LOCKS_EXCLUDED(Locks::heap_bitmap_lock_);
这两个方法为 heap 的方法的同时,拥有最简单直接的触发逻辑:
其原本触发逻辑为:在 art 虚拟机加载一个 app 时,会判断当前 app manifest 中是否声明了 largeHeap:
-
是 largeHeap:通过 dalvik.system.VMRuntime 中的 jni 方法,调用VMRuntime_clearGrowthLimit
-
不是则调用VMRuntime_clampGrowthLimit
所以我们只要 hook 住VMRuntime_clearGrowthLimit/VMRuntime_clampGrowthLimit,在 java 或者 native 上随意触发一下这个方法,就可以在art::Heap::ClearGrowthLimit中第一个参数拿到 heap 指针!
避免调用影响也很简单:
- 首先hookart::Heap::ClearGrowthLimit
- 记下当前 tid ,再触发VMRuntime_clearGrowthLimit
- 在art::Heap::ClearGrowthLimit的 proxy 中:
a.判断 tid 为前面记下的 tid 时,保存 x0 为 heap 指针,并跳过原方法执行
b.非记录 tid 则直接调用原方法
这样只有我们触发的调用被 skip 掉,对其他线程触发无影响。其他字段较多,这里不在一一列举获取过程,原理相同。
1.2.5 触发 space 切换
接下来再看下如何切换 space: main space/backup space 自身就是设计用来 copy gc 的,所以从直觉上也很容易想到通过触发 copy gc 来切换 space。简单阅读 heap 代码可以发现:
// Compact source space to target space. Returns the collector used.
collector::GarbageCollector* Compact(space::ContinuousMemMapAllocSpace* target_space,
space::ContinuousMemMapAllocSpace* source_space,
GcCause gc_cause)
art 虚拟机通过 Heap::Compact 方法来实现 source_space 压缩到 target_space ,但观察 Heap::Compact 的实现可以发现,其并没有 STW 操作,也没有修改 target_space 权限的操作(参考 1.0 中的 maps ,作为 backup 的 space 的 maps 权限是---p,没有 rw ),所以我们再来看一下所有调用 Compact 的位置:
voidHeap::PreZygoteFork()
HomogeneousSpaceCompactResult Heap::PerformHomogeneousSpaceCompact()
voidHeap::TransitionCollector(CollectorType collector_type)
排除PreZygoteFork()和TransitionCollector()两处无关调用,实际触发 copy gc 的位置为PerformHomogeneousSpaceCompact(),并且实际 STW/RTW , maps 内存的权限修改, mian/backup 分区身份的替换,调整 FootprintLimit ,更新 cleared java ref 等操作都在这里。
所以,这里可以把PerformHomogeneousSpaceCompact()作为一个触发 copy gc 的原子操作。同理其符号也在 libart.so 中存在。
1.3 收益
最终笔者实现了在 Android 5/6 设备上扩容至[740MB, 750MB], Android7 设备上扩容至[960MB,980MB]。抖音在 Android 6-7 设备上,整体 OOM 率**-60.77%**
2.ART 虚拟机 region space 扩容
在完成 android 5-7的 heap 扩容后,发现对于性能、稳定性、业务指标均存在大幅收益,笔者遂开始着手探索 android 8-9 的 region space 扩容。
因为跟高版本的art虚拟机中 cc 实现了 generation ,本着从简单开始入手,先尝试扩容8-9的 region space 。
2.1 基础知识
在 android 8.0 之后, art 引入了 concurrent copying (以下都简称 cc ),并配套使用了 region space ,解决了 cms 算法无法彻底处理内存碎片的问题,同时保持了极短的 stw 时间
region space的内存结构示意图
在 cc 算法中,堆空间不再是连续的一大块,而是被切分成大量大小相等(通常为256KB )的单元,称为 Region(对应 art 虚拟机中一个 Region 对象 ),可以把单个 Region 看作是一个微型 bump pointer space ,每次申请一块内存,其指针向后移动对应内存大小。每个 Region 的状态为以下状态之一:
-
free:空闲,可以分配。
-
allocated(to-space ):正在被使用的空间,新对象在此分配。
-
from-space:在 GC 开始时,包含存活对象、准备被回收的空间。
-
large object:专门存放大对象的连续多个 Region 。
cc 算法就是将存活对象从 from-space 拷贝到 to-space,拷贝完成后,直接释放整个 from-space。
2.2 技术方案
扩容流程示意图
整体扩容方案为:
- 在 low4gb 内存上,原 region space 的结束位置后,寻找空闲连续空间,创建 region space(expand )
- 阻塞 gc/heap trim 调用(注意这里不是扔掉这次调用! )等待扩容完成
- 通过 inline hook 寄生扩容操作到 Heap::StartGC( ) 的中间过程中(为扩容创造一个安全执行环境 )
- 执行扩容:
a. stop the world
b. 扩容 regions 数组相关内存
c. 扩容 live-bitmap (也就是 mark-bitmap , region space 只有一个 )
d. 修改 memmap 管理地址
e. 向 heap 中重添加当前 region space
f. resume the world 5. 触发 Heap::FinishGC( )
-
修改 heap 容量限制到扩容后的容量
-
解除 gc/trim 阻塞
2.2.1 搜索扩容内存空间
RegionSpace 的创建需要一块大小为2* capacity 的连续内存空间,所以相比于 MallocSpace 可以把 main 与 backup 在低4G空间上分开从而充分利用 maps 的空隙, RegionSpace 对于寻找扩容内存空间更加困难。
扩容空间:
一块在 low4g 头部:对于 linux 内核而言,一个进程的[0x0-0x00010000]地址空间是作为 null 检查的保留地址,所以从0x00010000到原 region space 的下一个 maps 项中间,都是我们可以扩容的空间:
-
299MB:从0x00010000到0x12c00000之间的 gap ,位于原 region space 的低地址方向,以下简称为 backward
-
1024MB:原 region space (512MB*2 ),以下简称 origin
-
476MB:从0x52c00000到0x7088d000之间的 gap ,位于原 region space 的高地址方向,以下简称为 forward
共计约1800MB的空间,我们用这块内存来作为 region space 扩容的区域,理论上扩容后的 heap 空间可以达到约900MB。实际上,在扩容实验中发现,如果要支持 backward 的这部分内存,是需要修改 region space 的起始地址的,但依赖于 region space 起始地址来计算 offset 的逻辑可能会导致潜在的稳定性问题,综合考虑 ROI 后,决定暂只支持 forward 扩容:扩容后 heap 大小可以达到740MB(对比手机出厂设置的512MB,+45% )
region space 扩容 low 4g 内存 maps 示意图
2.2.2 扩容 regions_数组
region space 是将一整段内存划分为一个个 region 进行管理的,每个 region 大小256KB,一次内存申请,必须在一个 region 中(例如前一个 region 剩余8byte,当前要申请32byte,只能在一个 region 中申请完整的 32byte )
region space 通过一个名为 regions_的 Region 类型数组来管理这些 Region ,其大小为 num_regions_ ,所以扩容需要修改这两处。
Region 内部除了可能会因为被设置为 thread-local 而持有 Thread 指针,没有其他引用类型。(RegionState/RegionType 是 uint8 枚举 ),所以 Region 可以简单的当作一整段内存来进行复制 。
Android 8-9上的 Region 都是0x50大小(高版本 Android 为0x40,因为对这些变量执行了重排序,减少了碎片内存 ),每个 Region 对应256KB的实际内存,所以扩容 Region 的内存大小为:
expand\_regions\_size = (region\_space\_size / 256KB) * 0x50
例如1GB的 region space,其 regions 数量为(1024* 1024*1024 )/(25 1024 )=4096个,其 regions 内存大小为409680=320KB。创建扩容的 regions 就直接申请这么多内存就可以了。
当然我们也可以直接拷贝一份 Region 的实现,直接创建更大的数组。我这里采用的就是直接创建数组,对所有扩容后的 Region 进行初始化后, memcpy 原 Region 到扩容后到 Region 中。
初始化 Region 与 copy 原 Region
2.2.3 扩容live-bitmap(mark-bitmap )
region space 的 live-bitmap ,是一个8字节单位的 SpaceBitmap。在 region space 创建时,根据映射的大小创建。现在我们扩容后 region space 管理的内存更大, live-bitmap 也需要扩容到更大。从 maps 入手可以发现 live-bitmap 附近的内存都是紧密相连,并没有任何空间可以实现原地址扩容:
所以我们只能创建新的 8byte bitmap 来扩容。
创建一个新的 bitmap 也比较简单, SpaceBitmap<8byte> 有相关的 Create 静态方法符号导出,只要传入开始/结束地址即可创建。
创建完成后,还需要将存活数据放入 bitmap。这里有两个思路:触发一次 concurrent-copying 来重建 bitmap 数据( concurrent-copying 不依赖任何前置的 bitmap 数据,开始 gc 前会清空原 bitmap )或者 将原 bitmap 数据放入新 bitmap 中。
bitmap 的工作方式是以 bit 为单位的: 8byte bitmap 是指 1bit 映射 8byte 内存, 1GB 的 region space,其 bitmap 管理位数量为 1GB / 8byte = 134217728 个,也就是 16777216byte(整 16MB )。这里对于我们复制原 bitmap 数据,就会存在一个问题:管理的 heap 必须是以 512byte (即64*8 )对齐的。好在 mmap 是以 pagesize 为单位管理内存,我们申请的内存一定是 512byte 对齐的。所以只需要执行 memcpy 将原 bitmap 复制到扩容 bitmap 即可。
2.2.4 修改 memmap 的地址/space 地址
RegionSpace 作为 Space 子类,中间 MemMapSpace 中会持有其管理的 memmap , ContinuousSpace 会持有内存开始地址和内存大小。其中 memmap 的大小会影响 offset 计算, ContinuousSpace 的内存地址会影响 Contains( )和 HasAddress ( )的判断, memmap 还会影响 dump 的逻辑,所以这里必须要修改地址信息。
修改方法也比较简单:在对比众多品牌机型的 Android 8-9 的 libart.so 后,确认 offset 只和 Android 版本有关,并不存在定制 libart 导致 offset 不同的情况。综合 ROI 考虑,采用运行时 disassemble 计算 offset 验证最后一位的 offset,其他位置的 offset 使用验证后的 hardcode 的 offset。
这里修改地址时,是以 RegionSize(256KB ) 对齐计算后的地址。对于对齐后剩余的内存,不需要做额外操作。
2.2.5 重添加 region space 到 heap
heap 拥有其独立的 bitmap ,是根据其管理的各个 space 的区域综合来判断创建的
为了保障逻辑正确性,我们在扩容后,需要 RemoveSpace + AddSpace 来使扩容后的 SpaceBitmap 被正确管理。同样这两个方法在 libart.so 中有 symbol ,拿到地址调用之即可。
2.2.6 扩容后状态恢复
刚刚进行扩容前,我们阻塞了所有 gc/trim ,挂起了整个 art 虚拟机,并通过 Heap::StartGC( ) 使 art 进入 gc 状态。现在扩容结束,需要恢复上面的状态,按照如下顺序来恢复虚拟机状态:
-
resume 虚拟机,使 java 代码恢复执行状态
-
触发 FinishGC 结束当前的 gc 状态
-
修改 heap 的 capacity/growth_limit 容量限制到扩容后的容量大小
-
解除对 gc 和 trim 调用的阻塞
这里的顺序考虑了各种边界 case ,例如当前的 gc 触发来自于 Alloc,此时如何先解除 gc 阻塞,会导致 gc 时撞到 heap capacity 限制,无法申请到更大内存,进而导致 Alloc 失败:实际这时已经完成扩容, Alloc 可以获取到额外的内存。
2.3 关键 offset 锚点
对于寻找上述的扩容过程中需要的数据 offset ,这里笔者提供几个锚点方法可以供参考:
- RegionSpace::FromSpaceSize()
单个 region 大小、 regions 数组 offset、regions 数组大小 offset 可以从这个方法反编译获取
region_size_byte = 0x50;
num_regions_offset = 0xb0;
regions_offset = 0xc0;
- Heap 构造函数
RegionSpace 中 MemMap 的 offset 可以从这里获取
- DlMallocSpace::Clear
MemMap中的begin/size/base_begin/base_size 的 offset(只要知道其中一个即可,这4个字段是排列在一起的 )
2.4 收益
crash 率**-8.8%** 卡死率**-4.8%** OOM 率 -6.93% gc 后内存水位超90%渗透率**-73.34%**
android 8-9 的设备上并不存在像 android 6-7 部分设备上 largeHeap 下仅 256MB heap 的情况,所以整体收益并没有上面的方案那样夸张。但整体收益依然很大,且 android 8-9 设备用户数量远远大于 android 5-7 用户数量。从 gc 后内存水位超 90%渗透率这项指标也可以发现:扩容后设备的 java 内存压力大幅下降。
3.FD/FD_SET 扩容
在 android9 及以下版本中,Android 在 linux 内核上限制一个进程中的 fd 最大数量为1024。随着业务发展,抖音在运行时所需的 fd 数量也随之增大,造成在 android9 以下版本的用户,因为 fd 超限导致的稳定性问题越来越严重。
典型堆栈:
Signal 6(SIGABRT), Code -6(SI_TKILL)
#00 pc 0000000000069014 /system/lib64/libc.so (pthread_kill+64)
#01 pc 00000000000241c0 /system/lib64/libc.so (raise+24)
#02 pc 000000000001cc2c /system/lib64/libc.so (abort+52)
#03 pc 00000000000211cc /system/lib64/libc.so (__libc_fatal+104)
#04 pc 0000000000021160 /system/lib64/libc.so (__fortify_chk_fail+52)
#05 pc 0000000000074284 /system/lib64/libc.so (__FD_SET_chk+100)
...
3.1 技术方案
对于单个进程中 fd 的限制,主要包括两方面:内核态中 linux 内核的 rlimit 限制,以及用户态 libc 中 FD_SET 的限制。下面分别来展开
3.1.1 扩展 linux 内核 fd 限制
就 fd 本身而言, fd 最大上限为1024为 linux 内核限制。详细的原理可以前往 linux 内核源代码中 fs/file.c 中查看
这里的扩容也非常简单: linux 给用户态提供了 syscall 用于修改部分 rlimit ,且 android 上对于修改 fd 的 limit 是允许的。
intgetrlimit(int __resource, struct rlimit* __limit);
intsetrlimit(int __resource, const struct rlimit* __limit);
3.1.2 扩展 libc 中 FD_SET 上限
验证 syscall 对于更大 FD_SET 的支持
libc 中 fd_set 的容量上限为 select.h 中写死的大小,并不是读取内核的大小限制。要实现 fd_set 的容量拓展,先来查看下 fd_set 的使用方,是否支持更大的容量:
所有 fd_set 的操作,最终都会收敛于 select.h 头文件中的两个方法select和pselect,这两个方法最终收敛于 linux 的 syscall:__pselect6中
在内核中,__pselect6最终逻辑执行在 core_sys_select 中,这里设计考虑了超过栈上内存开辟的情况,实测也是符合预期,支持超过1024的 fd_set。
既然内核对于 syscall 是支持超过1024的,那么只需要关系如何修改用户态 libc 中的限制即可
扩容栈内存上的 FD_SET
FD_SET 的大部分使用场景都是直接在栈上开辟内存,这对于我们这种动态 inline hook 替换就非常不友好了,因为我们没办法给栈内存上的 FD_SET 进行扩容。虽然我们无法为栈上的内存在运行时进行拓展,但所有对 fd_set 的操作:
#if __ANDROID_API__ >= __ANDROID_API_L__
/** Removes `fd` from the given set. Use <poll.h> instead. */
#define FD_CLR(fd, set) __FD_CLR_chk(fd, set, __bos(set))
/** Adds `fd` to the given set. Use <poll.h> instead. */
#define FD_SET(fd, set) __FD_SET_chk(fd, set, __bos(set))
/** Tests whether `fd` is in the given set. Use <poll.h> instead. */
#define FD_ISSET(fd, set) __FD_ISSET_chk(fd, set, __bos(set))
#else
以及 fd_set 最终消费处__pselect6,均为 libc 中的方法,可以通过 inline hook 收敛所有 fd_set 的调用。所以我们可以通过对所有的 fd_set 在堆内存上开辟 peer 对象,来实现一一映射。
fd_set *get_expanded_fd_set(fd_set *origin_fd_set)
fd_set *add_expanded_fd_set(fd_set *origin_fd_set)
voidrelease_expanded_fd_set(int fd)
因为实际使用中可能存在直接用一个 fd_set 来赋值另一个 fd_set 的 case :
fd_set a_set;
//xxx
fd_set b_set = a_set;
所以映射关系直接通过原fd_set 来存储:
原 fd_set 为 unsigned long 数组,unsigned long 在32 位 arm 上是32位,64位 arm 上是64位,刚好用来存放映射的堆上扩展的 fd_set 的地址。
这里做了简单的校验逻辑:第0,2位全为 F ,第1,3位为实际扩展后的地址。
扩容后堆内存的peer释放
原 fd_set 为栈上对象,没有统一收敛的回收方法供我们 hook 来释放扩展后的 fd_set 。这里我们通过白名单机制,将系统中所有使用 fd_set 的方法 hook 上,作为进入和退出 fd_set 扩容场景的触发点:
-
进入方法时触发 fd_set 扩容,期间所有 fd_set 都将进行堆上创建扩容 fd_set 映射
-
退出方法时停止 fd_set 扩容,释放当前扩容场景创建的 fd_set
例如 ssl_do_handshake 中,使用了 FD_SET ,则我们通过 inline hook 该方法,在进入时,为其在堆内存上创建扩容 FD_SET 映射,在方法执行完毕后释放扩容 FD_SET
3.2 收益
上线本优化后,线上 android 9 以下版本 FD/FD_SET 超限问题几乎全部解决, android 9 以下系统整体 crash -7.23%
4.M:N透明用户态线程
在部分厂商的 android 8 及以下版本系统上,限制单个 app 最大线程+进程(其实对 linux 内核来说,并不存在线程的概念,线程只是一个“共享内存和其他资源”的轻量级线程 )不能超过500。随着抖音业务发展,500线程限制对于这些设备的用户使用体验造成了很大影响,笔者遂开始着手探索线程复用的可能性。
4.1 基础知识
pthread 在 linux 下的定义
无论是 java 中的线程,还是 native 中的线程,亦或者任何其他形式的线程封装,在 Android 的用户态上最终都会以 pthread 作为最底层的承载。但 pthread 是 posix 定义的一个对线程的抽象, linux 因为遵守 posix 标准,所以其用户态呈现的也是 pthread 。
但内核态与用户态的边界是 syscall ,从 linux syscall 来看,并不存在一个叫 NR_PTHREAD_XXX 的 syscall number ,其并不感知 pthread 。正如上文所说, linux 下并没有线程这个概念,所以 linux 的 pthread 仅仅是其用户态 libc 的封装, pthread 对应的底层 syscall 是 clone ,其通过各种 flag 来让内核创建出一个共享内存空间、共享系统资源等符合线程的轻量级进程(以下简称 LWP , light weight process )。
内核视角下线程的 context
一个线程的 context 本质上是能够完整恢复CPU 执行状态 所需的所有信息。
- 通用寄存器
x0-x30:共31个64位通用寄存器。
sp:堆栈指针,指向当前线程栈顶。
pc:程序计数器,指向下一条执行指令。
- 系统与状态寄存器
pstate:条件标志位(负数、零、进位、溢出 )。这决定了后续 b.eq, b.ne 等跳转指令的行为。
tpidr_el0:"thread local"寄存器。在 Android/Linux 中,这个寄存器存储着 TLS (Thread Local Storage ) 的基地址。如果不切换它,所有线程将共享同一个 errno 或 pthread_self( )数据,在 java 、 c 、c++ 中的 thread local 数据也会被“共享”
- 浮点与向量寄存器
v0-v31:浮点/向量寄存器
FPSR/FPCR:浮点状态寄存器和控制寄存器
4.2 技术方案
我们只要能实现周期性将 pthread 的 context 进行 swap,就可以实现一个 LWP 承载多个 pthread ,进而突破系统内核对500线程(进程 )的限制。
4.2.1 透明代理 pthread
pthread 中包含了 TCB 的详细实现,例如 tpidr_el0 寄存器指向的 tls 存储的 slot 中,每个 slot 的作用随着 libc 版本的不同而不同,显然我们不会也不可能为每个 android 的 libc 适配不同的 pthread TCB 封装。
所以这里我们选择了进行透明代理:将所有 pthread 和其相关的内存的创建和管理都 委托给 libc 来执行,原汤化原食:我们在 clone syscall 处做拦截,这样 tls 的具体结构、栈内存的申请释放等,对 syscall 来说都是透明指针,我们在这里 hook 对我们也是透明的。
在 pthread 释放时,会触发 pthread_exit 进行相关的释放操作,并最终通过 syscall 来结束 LWP 的生命周期,这里还需要对 pthread_exit 相关的 syscall 进行 hook,防止我们的 vcpu 真的被退出。
4.2.2 抢占线程执行权
linux 中,用户态如果想要强制中断一个正在运行的线程,有且只有一个方法:让这个线程接收一个 signal ,打断其执行。我们要实现抢占式并且还要实现时间片轮转,可以通过定义一个 timer ,周期性触发 real time signal 来打断当前正在执行的线程,跳转到我们设计的 signal handler 中执行。
/**
* [timer_create(2)](http://man7.org/linux/man-pages/man2/timer_create.2.html)
* creates a POSIX timer.
* Returns 0 on success, and returns -1 and sets `errno` on failure.
*/
inttimer_create(clockid_t __clock, struct sigevent* _Nullable __event, timer_t _Nonnull * _Nonnull __timer_ptr);
这里简单展开一下 timer 的 signal 选择:在 linux 中,signal 被分为两大类:前 32 位标准信号和后 32 位实时信号
我们常见的 sigill/sigsevg/sigbus 等都是标准信号,如果进程已经有一个 Pending 的标准信号,在它被处理之前,同类型的信号再次到达会被内核直接丢弃;实时信号并没有为每个信号起一个名字,而是直接从 SIGRTMIN 到 SIGRTMAX 之间的任意数字。如果多个相同的实时信号发送给进程,内核会将其放入队列中,每一个信号都会被交付,不会丢失。
对我们的需求来说,我们选择使用实时信号,不仅避免了与标准信号潜在的大量 signal handler 撞车,而且通过在 timer 触发 signal 时,携带一个 si_value ,我们可以进一步标记这次 realtime signal 是否是由我们 timer 发起的。
4.2.3 切换上下文
首先是保存上下文:在 signal handler 中,通过 ucontext_t 我们已经可以拿到当前线程的绝大部分上下文,通用寄存器、线程状态、pc 等,但浮点寄存器/向量寄存器并不是直接存在这里,而是通过其中的__reserved 字段进行了二次引用。由于 __reserved 中只有引用,所以这里需要手动访问 __reserved 中引用的 extra_context 并根据其 magic 来保存一下每个的 context。
但除了这部分之外,我们还需要保存一下 tpidr_el0 寄存器,实现 thread local 存储的隔离。
在了解并实现了上述保存上下文的逻辑之后,恢复另一个任务的上下文就是水到渠成的逆操作:将另一个任务的上述数据赋值回去即可。
4.2.4 处理 sycall interrupt
完成上述1-3后,我们已经实现了一个在简单执行环境下可以运行的 m:n 抢占式用户态线程了。但最大的麻烦却是本章节的 syscall 处理。
在 linux 中,即便你在sigaction中设置了SA_RESTART标志,依然有一部分系统调用不会自动重启。一旦在虚拟线程中这些调用被我们的 timer 发送的 realtime signal 信号中断,可能会影响业务代码运行的逻辑正确性。所以这里还需要对这些“非信号安全”的 syscall 做一下处理:
-
select/pselect ,poll/ppoll ,epoll_wait/ epoll_pwait ,这类调用通常涉及“阻塞等待多个事件”,内核认为一旦信号发生,程序可能需要重新评估等待的集合,因此不自动重启。这里的方法是通过独立的 vcpu 作为 daemon 线程,代替 pthread 去等待:当业务代码调用到上述 syscall 时,我们将这些具体需要执行的操作和对应的 fd,添加到 daemon 线程的任务中,当前的 pthread 线程挂起到任务队列中,直到 daemon 线程检测到其对应的事件已经到达,再将其添加到就绪队列中。
-
nanosleep,clock_nanosleep 休眠类 syscall 自身设计上,就是为了能在休眠过程中通过 signal 来唤醒,所以其会通过参数返回剩余的时间,用户态程序需要自行决定是否进行继续休眠。这里的处理方法是通过在调度器中,直接实现不对 sleep 中的函数进行调度从而实现休眠。
-
sigsuspend,sigtimedwait/sigwaitinfo 等信号处理与同步类 syscall 本身就是为了等待信号或同步状态,重启它们会违背其逻辑。这里通过判断打断的 signal 是否为 realtime signal 来重启对应的操作。
-
如果在一个非阻塞或具有超时属性的 Socket 上调用 connect 被中断,它是不可重启的。即便你再次调用 connect ,它也会返回 EALREADY 或 EISCONN ,此时必须通过 select/poll 检查其连接是否完成。
以此类推,完成所有潜在 syscall 的处理即可。
4.3 效果
为了方便在各个位置进行方便的识别 tid 是否为虚拟线程(例如在 syscall 的 proxy 中,只对虚拟线程发起的 syscall 做补偿操作 ),所有虚拟线程的 tid 号段采用系统中不会使用的号段。可以通过/proc/sys/kernel/pid_max 读取默认 tid/pid 的最大值,通常为 32768。
下图为1个 LWP 上运行了15个 java 线程(1x HandlerThread, 4x Thread, 10x ThreadPool ),3 个 native pthread。
当然这个方案中还是有些遗憾在的,例如由于通过周期性 signal 来实现抢占,性能没有原生的 LWP 好; syscall 的 interrupt 处理麻烦等。但对于本次的目的:拯救大量 Android 存量旧手机体验来说,这个方案保障了遇到500线程限制的用户的体验下限,解决了能不能用的问题,还是有非常大的价值的。
