这篇文章我来介绍一种 iOS 系统的极端激进的空间换时间优化.
重新阅读 objc4-950 的源码,发现 objc_msgSend 是使用纯汇编实现函数,通过汇编代码我们可以看到以下定义:
MSG_ENTRY _objc_msgSend
这里的 MSG_ENTRY 是什么意思呢?在文件中继续搜索 MSG_ENTRY 我们找到了这么一个宏:
.macro MSG_ENTRY /*name*/
.text
.align 10
.globl $0
$0:
.endmacro
我们展开
.text
.align 10
.globl _objc_msgSend
_objc_msgSend:
在这里,系统将符号 _objc_msgSend 映射为 C 的全局方法符号。也就是说,这段汇编可以通过头文件声明,便已完成了 C 的函数定义。我们在后续处理的时候可以将其视为 C 方法。
MSG_ENTRY 的引入
如果看过老版本 objc_msgSend 源码的人,应该意识到,新版本用 MSG_ENTRY 在这里替换掉了 ENTRY,而且是仅限 objc_msgSend 方法,其他方法依然使用 ENTRY.
而在老版本中,objc_msgSend 方法使用的是 ENTRY 宏,我意识到这个可能是个专门针对 objc_msgSend 的一个优化.
我们都知道 objc_msgSend 这个函数对于 iOS 程序的重要性,极其高频.哪怕 1 个时钟周期的浪费,乘以每秒亿万次的调用,都会变成巨大的性能损耗。
所以系统在这里使用"空间换时间",进行了一次极端的优化.
CPU 的读取机制
对于 iPhone 使用的 ARM 芯片在执行代码时,并不是执行一条读一条,而是 按“块”读取 。这个“块”通常对应 Cache Line(缓存行) 的大小。Cache Line 通常是 64 字节; 而在 ARM64 下指令固定是 4 字节 。
也就是说, CPU 读取一次,会把 16 条指令 ( 64 ÷ 4 = 16 )搬进指令队列。
那么我们可以假设一种情况, objc_msgSend 的入口地址刚好在一个 Cache Line 的 末尾 (比如最后 4 个字节)。CPU 第一次读取只拿到了函数的 第 1 条指令 ; CPU 发现后面没了,必须发起 第二次读取 ,去拿下一个 Cache Line。结果就是流水线(Pipeline)前端出现气泡(Stall),CPU 等待指令数据,无法全速工作。这种情况一般被称之为缓存抖动(Cache Thrashing).
那么假如强行把函数入口安排在 Cache Line 的 起始位置 (Offset 0),一次性全部读取到了函数的前 16 条指令.结果就是 CPU 仅需一次读取就能把整个函数最核心的逻辑全部加载进来,完全消除了取指延迟。
1024 字节的对齐
MSG_ENTRY 的宏长度声明是 .align 10 ,实际上起始地址对齐约束 (地址按 1024-byte 边界对齐).而一般的指令(以及老版本的 objc_msgSend) 使用的是 ENTRY 宏,长度声明是 .align 5,实际上是 32 字节.
看到这里,可能会有一个疑问:Cache Line 只有 64 字节,那系统搞一个 1024 字节(1KB)对齐,这也读不起啊?
确实读不起,但是这里的优化是为了保证前 64 字节一定在一个 cache line 之内.
转回 CPU 视角, 1024 = 0x400 ,二进制是 100 0000 0000 。这意味着 objc_msgSend 的地址低 10 位全是 0。
CPU 是使用地址的某些位(Index Bits)来寻址的.而地址低 10 位全部为 0,那么就可以让 CPU寻址的时候,取某些位的时候全部取到 0,方便进行匹配.即使不同版本的 CPU 使用寻址的位可能发生变化,但是因为低 10 位全部为 0 ,降低入口地址低位随机性、提升落点可预测性.
Fast Path
我们应该知道 objc_msgSend 是整个 iOS 底层的最常用的函数:它是一个极度优化的函数,它的快速路径(Fast Path)——也就是 99% 的情况下执行的那段代码—— 非常非常短 !
我们看下 objc_msgSend 的核心代码
cmp p0, #0 // 1. 判空 (4字节)
b.le LNilOrTagged // 2. 跳转 (4字节)
ldr p13, [x0] // 3. 读 isa (4字节)
and p16, p13, #ISA_MASK // 4. 处理 isa (4字节)
LGetIsaDone:
CacheLookup NORMAL // 5. 查缓存 (宏展开后约 10-20 条指令)
我们可以发现,objc_msgSend 确实很长,但是最核心的快速路径存放在前 16 字节当中,算上 CacheLookup 加起来可能只有 64 字节(16 条指令)左右 。
这就很有意思了:
- Cache Line 大小 :64 字节。
- Fast Path 大小 :约 64 字节。
L1 Cache 是组相联(Set Associative)结构,通过地址的 Index Bits 决定数据存放的 Set 位置。我们可以把 Set 当做 Cache 里的“一个格子组”,每个 set 里有多个槽位(way),每个槽位能放 1 条 cache line .
打个比方,可以把 L1 Cache 想成:
- 一排很多“组”(set0, set1, set2...)
- 每组里有多层“停车位”(way0, way1, way2...)
- 每个停车位停一辆“车”(一条 cache line,常见 64B)
如果不强行对齐(随机地址):
- 假设它从 0x...1030 开始。
- Set 0 ( 0x...1000 - 0x...103F ):装了前 16 字节。
- Set 1 ( 0x...1040 - 0x...107F ):装了后 48 字节。
- 后果 :执行一次 Fast Path,需要用到 2 个 Set(Set 0 和 Set 1) 。
- 风险 :只要 Set 0 或者 Set 1 被别的代码挤掉了, objc_msgSend 就会变慢。
如果强行对齐(起始地址低位全 0):
- 它强制从 0x...1400 开始(1024 的倍数,当然也是 64 的倍数)。
- Set 0 ( 0x...1400 - 0x...143F ): 完整装下了前 64 字节 。
- 后果 :执行一次 Fast Path,只需要用到 1 个 Set(Set 0) 。
- 收益 :只有 Set 0 被挤掉时才会变慢。Set 1 此时跟它无关。 被干扰的概率降低了一半!
objc_msgSend 的精髓在于 它最重要的那段最核心代码(Fast Path)非常短 。我们做 1024 对齐,就是为了保住这段最核心代码 ,让它只占 1 个坑位,而不是横跨两个坑位。
只要保住了这前 64 字节,99% 的消息发送都是满速的。后面慢路径(Slow Path)跨不跨 Set,已经不重要了(因为慢路径本来就慢,也不常走)。
快慢路径的代码
快速路径的代码如下:
CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached
这个 CacheLookup 宏展开后,就是一堆高度优化的汇编指令(计算哈希、查桶、对比 SEL、跳转 IMP)。如果命中了缓存(Cache Hit),它就直接 br x17 跳到目标函数去执行了。这就是最快的那条路。
而慢速路径发生的场景如下:如果 CacheLookup 发现缓存里没有(Cache Miss),它会跳到第三个参数指定的标签: __objc_msgSend_uncached
STATIC_ENTRY __objc_msgSend_uncached
UNWIND __objc_msgSend_uncached, FrameWithNoSaves
// THIS IS NOT A CALLABLE C FUNCTION
// Out-of-band p15 is the class to search
MethodTableLookup
TailCallFunctionPointer x17
END_ENTRY __objc_msgSend_uncached
这里会调用 C 函数 lookUpImpOrForward (在 MethodTableLookup 宏里),去遍历类的方法列表、父类、动态方法解析等等。这就非常慢了。
简单的说:
- Fast Path(前 ~64 字节):查找缓存 -> 命中 -> 跳转
IMP。这是99%的情况。 - Slow Path(后面的代码):查找缓存 -> 未命中 -> 跳转
__objc_msgSend_uncached。这是<1%的情况。
在 99% 的情况下,CPU 只读取并执行了第一个 Cache Line。后面的代码根本没被加载,所以它们跨不跨 Set、在不在 Cache 里,对当前调用毫无影响。
为什么说是极端的空间换时间优化
先总结这个优化的提升点:
从一个 App 启动开始(尤其是 pre-main 阶段和 main 之后的首屏渲染)会发生数以百万记的 objc_msgSend 调用;而 App 冷启动时多线程并发,指令缓存(i-Cache)的压力本来就大,如果不做对齐优化, Cache Thrashing(缓存抖动) 会导致所有线程的执行效率雪崩。
而跳出单个 App 的视角,objc_msgSend 是整个 Cocoa/Cocoa Touch 世界的基石。这个优化不仅让你的 App 启动变快,也让 SpringBoard(桌面)、SystemServer 等系统进程变快。系统负载低了,你的 App 获得的 CPU 时间片质量也更高。
对于单个 objc_msgSend 来说,这个优化可能只是纳秒级的提升。但对于 App 启动这种密集型场景,它是维持高性能的底线 。如果把这个对齐去掉,App 启动时间出现肉眼可见的劣化是完全可能的。它属于“最底层基础设施级”的优化。
那么代价呢
这种优化并非毫无代价.
为了让一个 objc_msgSend 对齐,系统可能要在它前面填 0~1023 字节(平均约 512 字节)的填充。这部分是空间成本。
对普通函数,32/64 字节对齐通常够了;这里给 objc_msgSend 这种极端热点函数使用 1024 字节,对齐粒度明显更大。代价是二进制体积增加,收益是入口落点更稳定、快路径更不容易被 i-cache 冲突干扰。
总结
系统使用 MSG_ENTRY 替代 ENTRY,是系统工程演进中的一个重要细节。它通过牺牲少量二进制空间,换取了 objc_msgSend 在 CPU 流水线的极致效率和缓存稳定性。对于 App 启动和高频消息发送场景,这是维持系统高性能运转的基石级优化。