要量化 objc_msgSend 与直接函数调用的性能差异,我们需要从时钟周期、指令流水线以及编译器优化这三个维度进行拆解。
在现代 Apple 芯片(如 M3/M4)上,这种差异通常被压缩在**纳秒(ns)**级别,但在高频循环下,其累积效应会非常显著。
1. 绝对数值的量化对比
以下是在受控环境(缓存命中、无锁竞争)下的典型耗时估算:
| 调用类型 | 耗时 (ns) | CPU 周期 | 底层指令行为 |
|---|---|---|---|
| 直接函数调用 (C/C++) | ~0.1 - 0.5 | 1 - 2 | bl <address> (编译器可内联) |
| Swift 虚函数 (V-Table) | ~1.0 - 1.5 | 3 - 5 | ldr, ldr, blr (两次内存寻址) |
| objc_msgSend (Cache Hit) | ~2.5 - 4.0 | 8 - 12 | 哈希计算 + 缓存行读取 + 间接跳转 |
| objc_msgSend (Cache Miss) | ~15 - 100+ | 40 - 300+ | 线性搜索方法列表 + 父类递归查找 |
2. 指令层面的开销差 (Instruction Overhead)
直接函数调用:
编译器在编译期就知道了函数地址。如果函数足够小,它会被内联 (Inline) ,此时调用成本为 0。即便不内联,也只是一条 bl (Branch with Link) 指令,CPU 的分支预测器(Branch Predictor)可以极佳地预处理这种跳转。
objc_msgSend 调用:
即使是性能最强的“快速路径”,也必须执行以下步骤:
- 寄存器保护:为了保证通用性,必须保存部分状态。
- 获取 Class 指针:通过对象的
isa指针找到类对象。 - 哈希计算:将 Selector 的地址与掩码进行
AND运算,确定在cache_t中的槽位。 - 缓存读取:从内存中加载
bucket_t。如果发生缓存行失效(Cache Line Miss),成本瞬间翻倍。
3. 编译器优化的“隐形屏障”
性能差异不仅仅在于那几纳秒的跳转,更在于 编译器优化(Inlining & Interprocedural Analysis) 的丧失:
- 直接调用:编译器知道被调用函数的副作用(Side Effects),它可以跨函数进行常量折叠(Constant Folding)或死代码删除。
- objc_msgSend:对编译器来说,这本质上是一个黑盒。编译器无法确定运行时这个 Selector 会指向哪段代码(因为可能被 Swizzling),因此必须关闭几乎所有的跨函数优化。这种“优化抑制”带来的间接成本往往远超消息发送本身的开销。
4. 如何在你的项目中量化?
如果你想获取针对具体业务代码的量化数据,推荐以下两种方案:
方案 A:微基准测试 (Microbenchmarking)
使用 Google Benchmark 或简单的 mach_absolute_time 对以下两种代码块运行 1 亿次:
Objective-C
// 1. 消息发送
[object simpleMethod];
// 2. 预存 IMP 调用
IMP imp = [object methodForSelector:@selector(simpleMethod)];
void (*func)(id, SEL) = (void *)imp;
func(object, @selector(simpleMethod));
通常你会发现,预存 IMP 的方式比 objc_msgSend 快 2~3 倍,因为你绕过了哈希查找逻辑。
方案 B:Instruments 采样分析
使用 Instruments - Time Profiler,观察 objc_msgSend 在整个 CPU 周期中的占比。在一些重绘频率极高的 UI 组件(如 drawRect: 中大量调用属性)里,消息发送的占比有时能达到 5% - 10% 。
5. 总结:什么时候该在意这个差异?
-
不需在意:普通的按钮点击、网络请求回调、视图加载。这些场景下 3ns 和 0.5ns 的区别人类完全感知不到。
-
必须在意:
- 音视频原始数据处理(每秒处理数百万个样本)。
- 复杂的物理引擎或数学计算。
- 在
for循环中访问数十万次属性。