13-7.【OC】【性能】objc_msgSend 与直接函数调用性能差异如何量化?

2 阅读3分钟

要量化 objc_msgSend 与直接函数调用的性能差异,我们需要从时钟周期指令流水线以及编译器优化这三个维度进行拆解。

在现代 Apple 芯片(如 M3/M4)上,这种差异通常被压缩在**纳秒(ns)**级别,但在高频循环下,其累积效应会非常显著。


1. 绝对数值的量化对比

以下是在受控环境(缓存命中、无锁竞争)下的典型耗时估算:

调用类型耗时 (ns)CPU 周期底层指令行为
直接函数调用 (C/C++)~0.1 - 0.51 - 2bl <address> (编译器可内联)
Swift 虚函数 (V-Table)~1.0 - 1.53 - 5ldr, ldr, blr (两次内存寻址)
objc_msgSend (Cache Hit)~2.5 - 4.08 - 12哈希计算 + 缓存行读取 + 间接跳转
objc_msgSend (Cache Miss)~15 - 100+40 - 300+线性搜索方法列表 + 父类递归查找

2. 指令层面的开销差 (Instruction Overhead)

直接函数调用:

编译器在编译期就知道了函数地址。如果函数足够小,它会被内联 (Inline) ,此时调用成本为 0。即便不内联,也只是一条 bl (Branch with Link) 指令,CPU 的分支预测器(Branch Predictor)可以极佳地预处理这种跳转。

objc_msgSend 调用:

即使是性能最强的“快速路径”,也必须执行以下步骤:

  1. 寄存器保护:为了保证通用性,必须保存部分状态。
  2. 获取 Class 指针:通过对象的 isa 指针找到类对象。
  3. 哈希计算:将 Selector 的地址与掩码进行 AND 运算,确定在 cache_t 中的槽位。
  4. 缓存读取:从内存中加载 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 循环中访问数十万次属性