[转载]【WWDC22 110363】App 包大小优化和 Runtime 上的性能提升

原文地址

摘要: 今年 AppleObjective-C 运行时和链接器底层做了重大优化,包括 Swift 协议检查、Objective-C 消息发送、RetainRelease 底层优化以及 Autorelease 自动省略优化。以往开发者往往需要使用各种奇淫技巧来优化包大小,而在 2022 年的当下,Apple 从汇编代码以及编译器、链接器层面做出的优化,就能自动让 App 的包体积得到减小。

本文基于 WWDC22 - Improve app size and runtime performance 进行创作

前言

作为 iOS 开发者,我们每天都会与 Swift 或 Objective-C 打交道。在编写完代码之后,我们需要通过 Xcode 进行编译,然后运行在真机或者模拟器上面。这一看似习以为常的操作依赖于编译器和 Swift 或 Objective-C 的运行时。

今年 Apple 在 Swift 和 Objective-C 的编译器和运行时上面做了许多优化和调整,使得基于 Xcode 14 开发或者以 iOS 16, tvOS 16, watchOS 9 为最低支持版本的 App 可以获得包大小的优化和 Runtime 性能的提升。值得一提的是,本文不会有新的 API,也不会涉及语法变动和新的 Xcode Build Setting 内容。

对于 Runtime 感兴趣的读者可以查阅下方的文档。

Objective-C Runtime 官方文档

Swift Runtime 官方文档

今年 Apple 在编译器和运行时带来的优化包括以下四个方面

  • Swift 协议检查
  • Objective-C 消息发送
  • Retain 和 Release 调用
  • Autorelease 自动省略

这些优化不需要修改你的代码,因为 Apple 做出的改动对于开发者来说是不可见的,你几乎不需要付出任何成本就可以获得这些优化。

Swift 协议检查

什么是协议检查

Protocol 概念不论是在 Objective-C 中还是 Swift 中都是十分基础却又不可忽视的一大特性。自 Swift 诞生以来,iOS 生态圈内对于面向协议编程(POP)的追捧和热度持续攀升。因为随着软件复杂度的提高,如何保持各个模块之间高内聚、低耦合就成为了每个软件工程师值得思考的问题。在 Swift 的世界里面,Protocol 可以说是无处不在,整个 Swfit 最核心的编程理念中就包括了面向协议编程。

Swift 中关于 Protocol 的语法想必读者应该都已经熟练掌握了,下面我们从实际的代码中来理解什么是「协议检查」。

1
2
3
protocol CustomLoggable {
var customLogString: String { get }
}

上面的代码定义了一个叫做 CustomLoggable 的协议,见名知意,这个协议的目的是实现自定义的输出,遵循该协议的类型具有 customLogString 这个只读计算属性。

1
2
3
4
5
6
7
func log(value: Any) {
if let value = value as? CustomLoggable {
...
} else {
...
}
}

我们定义了一个 log 方法,这个方法中针对遵循 CustomLoggable 协议的对象进行了经典的 if let 操作。

1
2
3
4
5
6
7
8
struct Event: CustomLoggable {
var name: String
var date: Date

var customLogString: String {
return "\(self.name), on \(self.date)"
}
}

接着我们定义了一个遵循 CustomLoggable 的 Event 结构体,这个结构体有 name 和 date 两个属性,同时为了遵循 CustomLoggable 协议,定义了 customLogString 属性的 getter 方法。

1
2
let event = ...
log(value: event)

然后我们将 Event 结构体的实例传给 log 方法,当我们执行这段代码的时候,log 方法通过使用 as 运算符来检查我们传入的 value 是否遵循了 CustomLoggable 方法。

关于 is、as 的区别,感兴趣的读者可以参考 Type casting in swift : difference between is, as, as?, as!

上面代码中对于 CustomLoggable 协议的检查,编译器会尽可能在编译时优化掉。但编译器并不总是有足够的上下文信息来完成这项优化。因此,借助于在编译时计算出的协议检查「元数据」,协议的遵循性检查常常发生在运行时。有了「元数据」之后,运行时就知道特定对象是否真正遵循了 CustomLoggable 协议。

MetaData in Runtime

协议检查的「元数据」一部分是在编译时产生的,但是相当大的一部分只能在 App 启动时得到,特别是使用泛型的时候。

Swift 协议检查存在的问题

由于需要在 App 启动时会计算协议检查所需的 「元数据」,当代码中大量使用了协议之后,对启动时间的影响将不再是微乎其微,而有可能达到客观的数百毫秒级别。在真实世界的 App 中,计算「元数据」的耗时甚至会占到启动时间的一半。

那么为什么 Swift 协议检查会有这样的性能问题呢?在 Swift 5.4 的更新文档中,Apple 就已经提到过针对 Swift 协议检查有过一次性能优化。

在 Swift 5.4 中,运行时的协议一致性检查明显更快,这要归功于更快的哈希表实现来缓存以前的查找结果。特别是,这加快了常见的运行时 as?as! 转换操作。

Swift 5.4 Release Notes

App launch time visualization

The Surprising Cost of Protocol Conformances in Swift 一文中的截图所示,在 App 的启动过程中,swift_conformsToProtocol 所花费的时间已经达到了 100+ ms 的级别。众所周知,Apple 官方给出的建议启动时长是控制在 400 ms 以内。

协议一致性检查底层解析

对于协议一致性检查的底层实现,我们可以在 Swift 源码中得到答案。

swift_conformsToProtocolImpl

我们来到 ProtocolConformance.cpp 源文件中,然后定位到 swift_conformsToProtocolImpl 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

static const WitnessTable *
swift_conformsToProtocolImpl(const Metadata *const type,
const ProtocolDescriptor *protocol) {
const WitnessTable *table;
bool hasUninstantiatedSuperclass;

// First, try without instantiating any new superclasses. This avoids
// an infinite loop for cases like `class Sub: Super<Sub>`. In cases like
// that, the conformance must exist on the subclass (or at least somewhere
// in the chain before we get to an uninstantiated superclass) so this search
// will succeed without trying to instantiate Super while it's already being
// instantiated.=
std::tie(table, hasUninstantiatedSuperclass) =
swift_conformsToProtocolMaybeInstantiateSuperclasses(
type, protocol, false /*instantiateSuperclassMetadata*/);

// If no conformance was found, and there is an uninstantiated superclass that
// was not searched, then try the search again and instantiate all
// superclasses.
if (!table &amp;&amp; hasUninstantiatedSuperclass)
std::tie(table, hasUninstantiatedSuperclass) =
swift_conformsToProtocolMaybeInstantiateSuperclasses(
type, protocol, true /*instantiateSuperclassMetadata*/);
return table;
}

swift_conformsToProtocolImpl 函数的实现很容易理解,它接收两个参数,分别是一个类型字段 type 和一个协议描述符 protocol,返回 WitnessTable 类型的实例对象。

  • 声明 WitnessTable 类型的实例 table
  • 声明是否有未初始化的父类布尔值 hasUninstantiatedSuperclass
  • 先调用一次 swift_conformsToProtocolMaybeInstantiateSuperclasses 方法,传入 type 和 protocol,并在第三个参数传入 false 表示不去处初始化父类。根据代码中的注视我们不难看出,对于 class Sub: Super<Sub> 这种场景这里的 false 可以避免出现无限循环的情况。这里 swift_conformsToProtocolMaybeInstantiateSuperclasses 方法的调用会更新前面声明的两个变量 table 和 hasUninstantiatedSuperclass
  • 如果 table 不存在即说明还不能判定 type 是否遵循 protocol,同时如果 hasUninstantiatedSuperclass 为 false 即有未初始化的父类没有被搜索到,因此需要再次搜索一次并初始化所有的父类

关于 WitnessTable 的定义可以参考 Metadata.h 源文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// A witness table for a protocol.
///
/// With the exception of the initial protocol conformance descriptor,
/// the layout of a witness table is dependent on the protocol being
/// represented.
template <typename Runtime>
class TargetWitnessTable {
/// The protocol conformance descriptor from which this witness table
/// was generated.
ConstTargetMetadataPointer<Runtime, TargetProtocolConformanceDescriptor>
Description;

public:
const TargetProtocolConformanceDescriptor<Runtime> *getDescription() const {
return Description;
}
};

using WitnessTable = TargetWitnessTable<InProcess>;

swift_conformsToProtocolMaybeInstantiateSuperclasses

通过上面的代码,我们可以发现 Swift 协议一致性检查的核心逻辑是 swift_conformsToProtocolMaybeInstantiateSuperclasses 方法,限于篇幅就不再把完整代码贴出来了,感兴趣的读者可以去一探究竟

swift_conformsToProtocolMaybeInstantiateSuperclasses

swift_conformsToProtocolMaybeInstantiateSuperclasses 主要有三条执行路径,分别是

  • findConformanceWithDyld

    /// Query dyld for a protocol conformance, if supported. The return

    /// value is a tuple consisting of the found witness table (if any), the found

    /// conformance descriptor (if any), and a bool that’s true if a failure is

    /// definitive.

    Swift Runtime 会先从 dyld 中查询协议一致性的数据。如果查找成功,会得到一个 witness table 和协议一致性描述符的元组以及一个查询是否失败的 bool 值。

    • getDyldOnDiskConformance

      在 getDyldOnDiskConformance 内部我们可以看到也有两种不同的策略

      • 先查找磁盘缓存,如果没找到,再查找内存缓存
      • 先查找内存缓存,如果没找到,再查找磁盘缓存

      对应到代码层面,查找磁盘缓存的方法是 getDyldOnDiskConformance,查找内存缓存的方法是 getDyldSharedCacheConformance。

      • getDyldOnDiskConformance

        根据 foreignTypeIdentity 是否为空来调用 dyld 底层方法 _dyld_find_foreign_type_protocol_conformance_on_disk_dyld_find_protocol_conformance_on_disk

      • getDyldSharedCacheConformance

        根据 foreignTypeIdentity 是否为空来调用 dyld 底层方法 _dyld_find_foreign_type_protocol_conformance_dyld_find_protocol_conformance

      虽然 _dyld_find_protocol_conformance 系列方法尚未开源,但是我们可以在 mac 的终端种运行 nm /usr/lib/dyld | grep _dyld_find_protocol_conformance | c++filt 命令来证实 dyld 中确实存在

1
2
3
4
5
6
7
8
9
10
11
12
13
> nm /usr/lib/dyld | grep _dyld_find_protocol_conformance | c++filt
00037ed6 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int)
0006fd4a t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.1)
0006fd75 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.2)
00037b48 t dyld4::APIs::_dyld_find_protocol_conformance(void const*, void const*, void const*) const
0000000000039758 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int)
000000000007f413 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.1)
000000000007f436 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.2)
00000000000393d8 t dyld4::APIs::_dyld_find_protocol_conformance(void const*, void const*, void const*) const
000000000003d36c t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int)
0000000000077bc4 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.1)
0000000000077bec t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.2)
000000000003cf98 t dyld4::APIs::_dyld_find_protocol_conformance(void const*, void const*, void const*) const

值得一提的是,在 iOS 15 的 Swift Runtime 中,findConformanceWithDyld 方法内部并没有 onDisk 方法簇的调用,也就是说 iOS 16 上有了 dyld 关于协议一致性的磁盘缓存。而 dyld 关于协议一致性的内存缓存也是在 Swift 5.4 才加入的。

  • searchInConformanceCache

    /// Search for a conformance descriptor in the ConformanceCache.

    /// First element of the return value is true if the result is authoritative

    /// i.e. the result is for the type itself and not a superclass. If false

    /// then we cached a conformance on a superclass, but that may be overridden.

    /// A return value of { false, nullptr } indicates nothing was cached.

    如果在 dyld 的磁盘和内存缓存中都没有找到协议一致性的数据,就会进入到第二个步骤 searchInConformanceCache 中。如果没有找到缓存,则返回 {false, nullptr} ,如果找到了并且刚好是要验证的 type 遵循了协议的话,返回的第一个参数就是 true,如果是 type 的父类上有协议一致性的缓存,则返回 false。

    searchInConformanceCache 的逻辑不是很复杂,这里直接贴出具体的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

static std::pair<bool, const WitnessTable *>
searchInConformanceCache(const Metadata *type,
const ProtocolDescriptor *protocol,
bool instantiateSuperclassMetadata) {
auto &amp;C = Conformances.get();
auto origType = type;
auto snapshot = C.Cache.snapshot();
for (auto type : iterateMaybeIncompleteSuperclasses(type, instantiateSuperclassMetadata)) {
if (auto *Value = snapshot.find(ConformanceCacheKey(type, protocol))) {
return {type == origType, Value->getWitnessTable()};
}
}

// We did not find a cache entry.
return {false, nullptr};
}

struct ConformanceState {
ConcurrentReadableHashMap<ConformanceCacheEntry> Cache;
//...
}

可以看到,searchInConformanceCache 是直接在 Conformances 的 Cache 上进行的查询,而这里的 Cache 是一个 ConcurrentReadableHashMap 结构,这个结构来自于 Mike Ash 大神 的 PR,比之前的缓存方案快了 13 倍,其核心优势在于并行的读取而不用加锁。

ConcurrentReadableHashMap

值得一提的是,这里的缓存是在内存中的,每次启动 app 后都需要重新生成。你可以在 Xcode 中设置 _ZN5swift25ConcurrentReadableHashMapIN12_GLOBAL__N_121ConformanceCacheEntryENS_11StaticMutexEE4findINS1_19ConformanceCacheKeyEEENSt3__14pairIPS2_jEERKT_NS4_12IndexStorageEmS9_ 符号断点来定位到这个 snapshop.find 方法。这个方法接收一个 ConformanceCacheKey 然后返回一个 ConformanceCacheEntry。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ConformanceCacheKey {
const Metadata *Type;
const ProtocolDescriptor *Proto;

ConformanceCacheKey(const Metadata *type, const ProtocolDescriptor *proto)
: Type(type), Proto(proto) {
assert(type);
}

friend llvm::hash_code hash_value(const ConformanceCacheKey &amp;key) {
return llvm::hash_combine(key.Type, key.Proto);
}
};

通过上面的代码,我们可以分析得出 ConcurrentReadableHashMap 中存储的缓存的 key 是一个类型/协议对。对同一个协议不同类型的一致性检查并不会命中缓存。

  • Linear scan of conformances

    线性扫描是最坏的情况,但是在为每个类型/协议对填充缓存之前这是必经的步骤。Swift 在创建了一个 Mach-O Section - __TEXT.__swift5_proto 来存储一系列的指针,这些指针指向了二进制中所有的协议一致性数据。每个类型/协议对都会生成一份协议一致性数据从而让 Runtime 来确定某个类型是否遵循了对应的协议。

    MachO ConformanceSection

    1
    2
    3
    4
    5
    6
    /// The Mach-O section name for the section containing protocol descriptor
    /// references. This lives within SEG_TEXT.
    #define MachOProtocolsSection &quot;__swift5_protos&quot;
    /// The Mach-O section name for the section containing protocol conformances.
    /// This lives within SEG_TEXT.
    #define MachOProtocolConformancesSection &quot;__swift5_proto&quot;

    由于需要对所有加载的动态库需要做一层遍历,并且还要在每个动态库内部做一层协议一致性数据的扫描,因为时间复杂度就来到了 O(n^2) ,显然这对性能并不友好。

Swift 协议检查优化方案

那么问题来了,我们能不能提前计算好这些「元数据」呢?

答案是可以的,基于 Swift 新的运行时,协议检查所需的「元数据」会在 dyld 加载启动时用到的所有动态库之前,作为 dyld 启动闭包的一部分去提前计算出来。

关于 dyld 和启动闭包,感兴趣的读者可以参考 Staic linking vs dyld3

同时,Apple 也有关于启动优化的专题 Session - WWDC17 - App Startup Time: Past, Present and Future (如果链接失效,可以下载 WWDC App for macOS 后搜索关键字观看)

dyld3 后有了启动闭包的概念,启动闭包中预先处理了所有可能影响启动速度的 search path、@rpaths 和环境变量,然后分析 Mach-O 的 Header 和依赖,并完成了所有符号查找的工作。最后将这些结果创建成了一个启动闭包,最终通过启动闭包来达到启动提速的效果。

Apple 在 Swift 协议一致性检查上所做出的优化不需要升级工程的最低部署版本,只需要 App 运行在 iOS 16、tvOS 16 或者是 watchOS 9 上就可以享受到 Swift 协议检查的优化,进而提升你的 App 启动速度。

相比于类型更加安全、语法更加现代的 Swift ,Objective-C 近些年来基本上是处于停滞的发展状态。但是今年 Apple 带来了 Objective-C 生态中可以说是近些年来最为令人振奋的改进和提升。包括消息发送的优化、Retain & Release 调用优化和 AutoRelease 自动省略优化。

Objective-C 消息发送

对于 iOS 老司机来说,Objective-C 的消息发送是一个老生常谈的话题。对于初学者来说,要理解一个简单的方法调用背后的底层实现细节,就必须对整个消息发送流程有着足够清晰和深入的认知。推荐感兴趣的读者阅读 Objective-C 消息发送与转发机制原理 一文。

objc_msgSend

Objective-C 的消息发送和转发流程可以概括为:消息发送(Messaging)是 Runtime 通过 selector 快速查找 IMP 的过程,有了函数指针就可以执行对应的方法实现;消息转发(Message Forwarding)是在查找 IMP 失败后执行一系列转发流程的慢速通道,如果不作转发处理,则会打日志和抛出异常。

提到消息发送,就不得不提 objc_msgSend 函数。在 Objective-C 的世界里面,基本上所有的方法调用都会转化为消息发送,而消息发送的必经之路就是 objc_msgSend 。 相信有经验的开发者都知道 objc_msgSend 是基于汇编实现的,在 M1/M2 系列芯片统治 ARM 架构的当下,我们重点关注 objc_msgSend 在 arm64 上的实现。

虽然我们现在都知道 objc_msgSend 方法是用汇编来实现的,但是 Apple 为什么要采取这样的方案呢?其实最主要的原因有两点:

  • 无法通过 C 语言来实现一个具有保留未知参数并且可以跳转到任意函数指针的函数
  • objc_msgSend 的执行速度非常重要,所以它的每一条指令都要尽可能地高效让能尽可能更快的运行。

在 Objective-C 整个消息发送过程中,基于汇编实现的 objc_msgSend 属于 fast path,其目的是查询方法缓存,如果没有找到,则会进入基于 C 实现的 slow path 来进行方法的动态解析,如果动态解析失败则进行方法的转发,如果转发也失败了则会最终进入到经典的 unrecognized selector sent to instance 流程中。

ARM 64 有 31 个 64 位宽的寄存器,我们可以通过 x0 到 x30 来访问这些寄存器。如果只访问低 32 位部分的话就可以通过 w0 到 w30。寄存器 x0 到 x7 用于存储函数接收到的前 8 个参数。这也就是说 objc_msgSend 方法在 x0 中接收 self,在 x1 中接收 selector _cmd。” value=”> 摘要:今年 AppleObjective-C 运行时和链接器底层做了重大优化,包括 Swift 协议检查、Objective-C 消息发送、RetainRelease 底层优化以及 Autorelease 自动省略优化。以往开发者往往需要使用各种奇淫技巧来优化包大小,而在 2022 年的当下,Apple 从汇编代码以及编译器、链接器层面做出的优化,就能自动让 App 的包体积得到减小。

本文基于 WWDC22 - Improve app size and runtime performance 进行创作

作者:leejunhui,就职于杭州云梯科技公司,iOS / React Native / React 开发者,目前专注于大前端技术栈,曾参与 WWDC21 内参 - 探索 Foundation 新增功能 的创作。

审核:

吕孟霖,就职于字节跳动 TikTok iOS 团队,对 App 稳定性与性能感兴趣

王浙剑(Damonwong),老司机技术社区负责人、WWDC22 内参主理人,目前就职于阿里巴巴。

前言

作为 iOS 开发者,我们每天都会与 Swift 或 Objective-C 打交道。在编写完代码之后,我们需要通过 Xcode 进行编译,然后运行在真机或者模拟器上面。这一看似习以为常的操作依赖于编译器和 Swift 或 Objective-C 的运行时。

今年 Apple 在 Swift 和 Objective-C 的编译器和运行时上面做了许多优化和调整,使得基于 Xcode 14 开发或者以 iOS 16, tvOS 16, watchOS 9 为最低支持版本的 App 可以获得包大小的优化和 Runtime 性能的提升。值得一提的是,本文不会有新的 API,也不会涉及语法变动和新的 Xcode Build Setting 内容。

对于 Runtime 感兴趣的读者可以查阅下方的文档。

Objective-C Runtime 官方文档

Swift Runtime 官方文档

今年 Apple 在编译器和运行时带来的优化包括以下四个方面

  • Swift 协议检查
  • Objective-C 消息发送
  • Retain 和 Release 调用
  • Autorelease 自动省略

这些优化不需要修改你的代码,因为 Apple 做出的改动对于开发者来说是不可见的,你几乎不需要付出任何成本就可以获得这些优化。

Swift 协议检查

什么是协议检查

Protocol 概念不论是在 Objective-C 中还是 Swift 中都是十分基础却又不可忽视的一大特性。自 Swift 诞生以来,iOS 生态圈内对于面向协议编程(POP)的追捧和热度持续攀升。因为随着软件复杂度的提高,如何保持各个模块之间高内聚、低耦合就成为了每个软件工程师值得思考的问题。在 Swift 的世界里面,Protocol 可以说是无处不在,整个 Swfit 最核心的编程理念中就包括了面向协议编程。

Swift 中关于 Protocol 的语法想必读者应该都已经熟练掌握了,下面我们从实际的代码中来理解什么是「协议检查」。

1
2
3
protocol CustomLoggable {
var customLogString: String { get }
}

上面的代码定义了一个叫做 CustomLoggable 的协议,见名知意,这个协议的目的是实现自定义的输出,遵循该协议的类型具有 customLogString 这个只读计算属性。

1
2
3
4
5
6
7
func log(value: Any) {
if let value = value as? CustomLoggable {
...
} else {
...
}
}

我们定义了一个 log 方法,这个方法中针对遵循 CustomLoggable 协议的对象进行了经典的 if let 操作。

1
2
3
4
5
6
7
8
struct Event: CustomLoggable {
var name: String
var date: Date

var customLogString: String {
return &quot;\(self.name), on \(self.date)&quot;
}
}

接着我们定义了一个遵循 CustomLoggable 的 Event 结构体,这个结构体有 name 和 date 两个属性,同时为了遵循 CustomLoggable 协议,定义了 customLogString 属性的 getter 方法。

1
2
let event = ...
log(value: event)

然后我们将 Event 结构体的实例传给 log 方法,当我们执行这段代码的时候,log 方法通过使用 as 运算符来检查我们传入的 value 是否遵循了 CustomLoggable 方法。

关于 is、as 的区别,感兴趣的读者可以参考 Type casting in swift : difference between is, as, as?, as!

上面代码中对于 CustomLoggable 协议的检查,编译器会尽可能在编译时优化掉。但编译器并不总是有足够的上下文信息来完成这项优化。因此,借助于在编译时计算出的协议检查「元数据」,协议的遵循性检查常常发生在运行时。有了「元数据」之后,运行时就知道特定对象是否真正遵循了 CustomLoggable 协议。

MetaData in Runtime

协议检查的「元数据」一部分是在编译时产生的,但是相当大的一部分只能在 App 启动时得到,特别是使用泛型的时候。

Swift 协议检查存在的问题

由于需要在 App 启动时会计算协议检查所需的 「元数据」,当代码中大量使用了协议之后,对启动时间的影响将不再是微乎其微,而有可能达到客观的数百毫秒级别。在真实世界的 App 中,计算「元数据」的耗时甚至会占到启动时间的一半。

那么为什么 Swift 协议检查会有这样的性能问题呢?在 Swift 5.4 的更新文档中,Apple 就已经提到过针对 Swift 协议检查有过一次性能优化。

在 Swift 5.4 中,运行时的协议一致性检查明显更快,这要归功于更快的哈希表实现来缓存以前的查找结果。特别是,这加快了常见的运行时 as?as! 转换操作。

Swift 5.4 Release Notes

App launch time visualization

The Surprising Cost of Protocol Conformances in Swift 一文中的截图所示,在 App 的启动过程中,swift_conformsToProtocol 所花费的时间已经达到了 100+ ms 的级别。众所周知,Apple 官方给出的建议启动时长是控制在 400 ms 以内。

协议一致性检查底层解析

对于协议一致性检查的底层实现,我们可以在 Swift 源码中得到答案。

swift_conformsToProtocolImpl

我们来到 ProtocolConformance.cpp 源文件中,然后定位到 swift_conformsToProtocolImpl 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

static const WitnessTable *
swift_conformsToProtocolImpl(const Metadata *const type,
const ProtocolDescriptor *protocol) {
const WitnessTable *table;
bool hasUninstantiatedSuperclass;

// First, try without instantiating any new superclasses. This avoids
// an infinite loop for cases like `class Sub: Super<Sub>`. In cases like
// that, the conformance must exist on the subclass (or at least somewhere
// in the chain before we get to an uninstantiated superclass) so this search
// will succeed without trying to instantiate Super while it's already being
// instantiated.=
std::tie(table, hasUninstantiatedSuperclass) =
swift_conformsToProtocolMaybeInstantiateSuperclasses(
type, protocol, false /*instantiateSuperclassMetadata*/);

// If no conformance was found, and there is an uninstantiated superclass that
// was not searched, then try the search again and instantiate all
// superclasses.
if (!table &amp;&amp; hasUninstantiatedSuperclass)
std::tie(table, hasUninstantiatedSuperclass) =
swift_conformsToProtocolMaybeInstantiateSuperclasses(
type, protocol, true /*instantiateSuperclassMetadata*/);
return table;
}

swift_conformsToProtocolImpl 函数的实现很容易理解,它接收两个参数,分别是一个类型字段 type 和一个协议描述符 protocol,返回 WitnessTable 类型的实例对象。

  • 声明 WitnessTable 类型的实例 table
  • 声明是否有未初始化的父类布尔值 hasUninstantiatedSuperclass
  • 先调用一次 swift_conformsToProtocolMaybeInstantiateSuperclasses 方法,传入 type 和 protocol,并在第三个参数传入 false 表示不去处初始化父类。根据代码中的注视我们不难看出,对于 class Sub: Super<Sub> 这种场景这里的 false 可以避免出现无限循环的情况。这里 swift_conformsToProtocolMaybeInstantiateSuperclasses 方法的调用会更新前面声明的两个变量 table 和 hasUninstantiatedSuperclass
  • 如果 table 不存在即说明还不能判定 type 是否遵循 protocol,同时如果 hasUninstantiatedSuperclass 为 false 即有未初始化的父类没有被搜索到,因此需要再次搜索一次并初始化所有的父类

关于 WitnessTable 的定义可以参考 Metadata.h 源文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// A witness table for a protocol.
///
/// With the exception of the initial protocol conformance descriptor,
/// the layout of a witness table is dependent on the protocol being
/// represented.
template <typename Runtime>
class TargetWitnessTable {
/// The protocol conformance descriptor from which this witness table
/// was generated.
ConstTargetMetadataPointer<Runtime, TargetProtocolConformanceDescriptor>
Description;

public:
const TargetProtocolConformanceDescriptor<Runtime> *getDescription() const {
return Description;
}
};

using WitnessTable = TargetWitnessTable<InProcess>;

swift_conformsToProtocolMaybeInstantiateSuperclasses

通过上面的代码,我们可以发现 Swift 协议一致性检查的核心逻辑是 swift_conformsToProtocolMaybeInstantiateSuperclasses 方法,限于篇幅就不再把完整代码贴出来了,感兴趣的读者可以去一探究竟

swift_conformsToProtocolMaybeInstantiateSuperclasses

swift_conformsToProtocolMaybeInstantiateSuperclasses 主要有三条执行路径,分别是

  • findConformanceWithDyld

    /// Query dyld for a protocol conformance, if supported. The return

    /// value is a tuple consisting of the found witness table (if any), the found

    /// conformance descriptor (if any), and a bool that’s true if a failure is

    /// definitive.

    Swift Runtime 会先从 dyld 中查询协议一致性的数据。如果查找成功,会得到一个 witness table 和协议一致性描述符的元组以及一个查询是否失败的 bool 值。

    • getDyldOnDiskConformance

      在 getDyldOnDiskConformance 内部我们可以看到也有两种不同的策略

      • 先查找磁盘缓存,如果没找到,再查找内存缓存
      • 先查找内存缓存,如果没找到,再查找磁盘缓存

      对应到代码层面,查找磁盘缓存的方法是 getDyldOnDiskConformance,查找内存缓存的方法是 getDyldSharedCacheConformance。

      • getDyldOnDiskConformance

        根据 foreignTypeIdentity 是否为空来调用 dyld 底层方法 _dyld_find_foreign_type_protocol_conformance_on_disk_dyld_find_protocol_conformance_on_disk

      • getDyldSharedCacheConformance

        根据 foreignTypeIdentity 是否为空来调用 dyld 底层方法 _dyld_find_foreign_type_protocol_conformance_dyld_find_protocol_conformance

      虽然 _dyld_find_protocol_conformance 系列方法尚未开源,但是我们可以在 mac 的终端种运行 nm /usr/lib/dyld | grep _dyld_find_protocol_conformance | c++filt 命令来证实 dyld 中确实存在

1
2
3
4
5
6
7
8
9
10
11
12
13
> nm /usr/lib/dyld | grep _dyld_find_protocol_conformance | c++filt
00037ed6 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int)
0006fd4a t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.1)
0006fd75 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.2)
00037b48 t dyld4::APIs::_dyld_find_protocol_conformance(void const*, void const*, void const*) const
0000000000039758 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int)
000000000007f413 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.1)
000000000007f436 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.2)
00000000000393d8 t dyld4::APIs::_dyld_find_protocol_conformance(void const*, void const*, void const*) const
000000000003d36c t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int)
0000000000077bc4 t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.1)
0000000000077bec t dyld4::APIs::_dyld_find_protocol_conformance_on_disk(void const*, void const*, void const*, unsigned int) (.cold.2)
000000000003cf98 t dyld4::APIs::_dyld_find_protocol_conformance(void const*, void const*, void const*) const

值得一提的是,在 iOS 15 的 Swift Runtime 中,findConformanceWithDyld 方法内部并没有 onDisk 方法簇的调用,也就是说 iOS 16 上有了 dyld 关于协议一致性的磁盘缓存。而 dyld 关于协议一致性的内存缓存也是在 Swift 5.4 才加入的。

  • searchInConformanceCache

    /// Search for a conformance descriptor in the ConformanceCache.

    /// First element of the return value is true if the result is authoritative

    /// i.e. the result is for the type itself and not a superclass. If false

    /// then we cached a conformance on a superclass, but that may be overridden.

    /// A return value of { false, nullptr } indicates nothing was cached.

    如果在 dyld 的磁盘和内存缓存中都没有找到协议一致性的数据,就会进入到第二个步骤 searchInConformanceCache 中。如果没有找到缓存,则返回 {false, nullptr} ,如果找到了并且刚好是要验证的 type 遵循了协议的话,返回的第一个参数就是 true,如果是 type 的父类上有协议一致性的缓存,则返回 false。

    searchInConformanceCache 的逻辑不是很复杂,这里直接贴出具体的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

static std::pair<bool, const WitnessTable *>
searchInConformanceCache(const Metadata *type,
const ProtocolDescriptor *protocol,
bool instantiateSuperclassMetadata) {
auto &amp;C = Conformances.get();
auto origType = type;
auto snapshot = C.Cache.snapshot();
for (auto type : iterateMaybeIncompleteSuperclasses(type, instantiateSuperclassMetadata)) {
if (auto *Value = snapshot.find(ConformanceCacheKey(type, protocol))) {
return {type == origType, Value->getWitnessTable()};
}
}

// We did not find a cache entry.
return {false, nullptr};
}

struct ConformanceState {
ConcurrentReadableHashMap<ConformanceCacheEntry> Cache;
//...
}

可以看到,searchInConformanceCache 是直接在 Conformances 的 Cache 上进行的查询,而这里的 Cache 是一个 ConcurrentReadableHashMap 结构,这个结构来自于 Mike Ash 大神 的 PR,比之前的缓存方案快了 13 倍,其核心优势在于并行的读取而不用加锁。

ConcurrentReadableHashMap

值得一提的是,这里的缓存是在内存中的,每次启动 app 后都需要重新生成。你可以在 Xcode 中设置 _ZN5swift25ConcurrentReadableHashMapIN12_GLOBAL__N_121ConformanceCacheEntryENS_11StaticMutexEE4findINS1_19ConformanceCacheKeyEEENSt3__14pairIPS2_jEERKT_NS4_12IndexStorageEmS9_ 符号断点来定位到这个 snapshop.find 方法。这个方法接收一个 ConformanceCacheKey 然后返回一个 ConformanceCacheEntry。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ConformanceCacheKey {
const Metadata *Type;
const ProtocolDescriptor *Proto;

ConformanceCacheKey(const Metadata *type, const ProtocolDescriptor *proto)
: Type(type), Proto(proto) {
assert(type);
}

friend llvm::hash_code hash_value(const ConformanceCacheKey &amp;key) {
return llvm::hash_combine(key.Type, key.Proto);
}
};

通过上面的代码,我们可以分析得出 ConcurrentReadableHashMap 中存储的缓存的 key 是一个类型/协议对。对同一个协议不同类型的一致性检查并不会命中缓存。

  • Linear scan of conformances

    线性扫描是最坏的情况,但是在为每个类型/协议对填充缓存之前这是必经的步骤。Swift 在创建了一个 Mach-O Section - __TEXT.__swift5_proto 来存储一系列的指针,这些指针指向了二进制中所有的协议一致性数据。每个类型/协议对都会生成一份协议一致性数据从而让 Runtime 来确定某个类型是否遵循了对应的协议。

    MachO ConformanceSection

    1
    2
    3
    4
    5
    6
    /// The Mach-O section name for the section containing protocol descriptor
    /// references. This lives within SEG_TEXT.
    #define MachOProtocolsSection &quot;__swift5_protos&quot;
    /// The Mach-O section name for the section containing protocol conformances.
    /// This lives within SEG_TEXT.
    #define MachOProtocolConformancesSection &quot;__swift5_proto&quot;

    由于需要对所有加载的动态库需要做一层遍历,并且还要在每个动态库内部做一层协议一致性数据的扫描,因为时间复杂度就来到了 O(n^2) ,显然这对性能并不友好。

Swift 协议检查优化方案

那么问题来了,我们能不能提前计算好这些「元数据」呢?

答案是可以的,基于 Swift 新的运行时,协议检查所需的「元数据」会在 dyld 加载启动时用到的所有动态库之前,作为 dyld 启动闭包的一部分去提前计算出来。

关于 dyld 和启动闭包,感兴趣的读者可以参考 Staic linking vs dyld3

同时,Apple 也有关于启动优化的专题 Session - WWDC17 - App Startup Time: Past, Present and Future (如果链接失效,可以下载 WWDC App for macOS 后搜索关键字观看)

dyld3 后有了启动闭包的概念,启动闭包中预先处理了所有可能影响启动速度的 search path、@rpaths 和环境变量,然后分析 Mach-O 的 Header 和依赖,并完成了所有符号查找的工作。最后将这些结果创建成了一个启动闭包,最终通过启动闭包来达到启动提速的效果。

Apple 在 Swift 协议一致性检查上所做出的优化不需要升级工程的最低部署版本,只需要 App 运行在 iOS 16、tvOS 16 或者是 watchOS 9 上就可以享受到 Swift 协议检查的优化,进而提升你的 App 启动速度。

相比于类型更加安全、语法更加现代的 Swift ,Objective-C 近些年来基本上是处于停滞的发展状态。但是今年 Apple 带来了 Objective-C 生态中可以说是近些年来最为令人振奋的改进和提升。包括消息发送的优化、Retain & Release 调用优化和 AutoRelease 自动省略优化。

Objective-C 消息发送

对于 iOS 老司机来说,Objective-C 的消息发送是一个老生常谈的话题。对于初学者来说,要理解一个简单的方法调用背后的底层实现细节,就必须对整个消息发送流程有着足够清晰和深入的认知。推荐感兴趣的读者阅读 Objective-C 消息发送与转发机制原理 一文。

objc_msgSend

Objective-C 的消息发送和转发流程可以概括为:消息发送(Messaging)是 Runtime 通过 selector 快速查找 IMP 的过程,有了函数指针就可以执行对应的方法实现;消息转发(Message Forwarding)是在查找 IMP 失败后执行一系列转发流程的慢速通道,如果不作转发处理,则会打日志和抛出异常。

提到消息发送,就不得不提 objc_msgSend 函数。在 Objective-C 的世界里面,基本上所有的方法调用都会转化为消息发送,而消息发送的必经之路就是 objc_msgSend 。 相信有经验的开发者都知道 objc_msgSend 是基于汇编实现的,在 M1/M2 系列芯片统治 ARM 架构的当下,我们重点关注 objc_msgSend 在 arm64 上的实现。

虽然我们现在都知道 objc_msgSend 方法是用汇编来实现的,但是 Apple 为什么要采取这样的方案呢?其实最主要的原因有两点:

  • 无法通过 C 语言来实现一个具有保留未知参数并且可以跳转到任意函数指针的函数
  • objc_msgSend 的执行速度非常重要,所以它的每一条指令都要尽可能地高效让能尽可能更快的运行。

在 Objective-C 整个消息发送过程中,基于汇编实现的 objc_msgSend 属于 fast path,其目的是查询方法缓存,如果没有找到,则会进入基于 C 实现的 slow path 来进行方法的动态解析,如果动态解析失败则进行方法的转发,如果转发也失败了则会最终进入到经典的 unrecognized selector sent to instance 流程中。

ARM 64 有 31 个 64 位宽的寄存器,我们可以通过 x0 到 x30 来访问这些寄存器。如果只访问低 32 位部分的话就可以通过 w0 到 w30。寄存器 x0 到 x7 用于存储函数接收到的前 8 个参数。这也就是说 objc_msgSend 方法在 x0 中接收 self,在 x1 中接收 selector _cmd

1
2
3
4
5
6
7
8
9
10
/********************************************************************
*
* id objc_msgSend(id self, SEL _cmd, ...);
* IMP objc_msgLookup(id self, SEL _cmd, ...);
*
* objc_msgLookup ABI:
* IMP returned in x17
* x16 reserved for our use but not used
*
********************************************************************/

上面的注释来自于最新的 objc4-818.2 中的 objc-msg-arm64.s 汇编源文件。

众所周知,每个 Objective-C 对象都有自己所属的类,而每个 Objective-C 的类都有一系列的方法。而每个方法都会有一个 selector 、一个指向方法实现的函数指针以及一些元数据。而 objc_msgSend 的任务就是接收一个对象和 selector ,查找对应方法的函数指针,然后跳转过去进而调用方法的具体实现。

下面我们以一个实际的例子来窥探 objc_msgSend 现阶段存在的问题。

1
2
3
4
5
6
7
8
9
10
11
12
// Method calls using objc_msgSend

NSCalendar *cal = [self makeCalendar];

NSDateComponents *dateComponents = [[NSDateComponents alloc] init];
dateComponents.year = 2022;
dateComponents.month = 6;
dateComponents.day = 6;

NSDate *theDate = [cal dateFromComponents:dateComponents];

return theDate;

上面的代码首先创建了一个 NSCalendar 对象 cal ,然后创建了一个 NSDateComponents 对象,并声明了 2022-6-6 作为日期值。最后通过调用 cal 对象的实例方法 dateFromComponents 得到一个 NSDate 对象并返回。

Assembly Code - Part 1

我们将目光放到编译器生成的汇编代码部分上。我们可以看到,左侧每一行方法的执行几乎都会对应到右侧汇编代码中对 _objc_msgSend 的调用,即使像我们通过点语法去设置 dateComponents 对象的属性这样的场景。这是因为 Objective-C 是一门动态语言,在代码编译时我们并不知道需要调用哪个方法,所以只能在运行时通过 objc_msgSend 来做这件事情。

对于 ARM 64 汇编感兴趣的读者可以参考 iOS 开发同学的 arm64 汇编入门

Assembly Code - Part 2

对于 objc_msgSend 来说,我们需要告诉它 selector 是什么,以及是在什么对象身上去调用 selector,所以如上图所示,在真正执行 bl _objc_msgSend 之前,还需要做一些准备工作。

1
2
3
adrp x1, [selector &quot;dateFromComponents&quot;]
ldr x1, [x1, selector &quot;dateFromComponents&quot;]
bl _objc_msgSend
  • adrp 指令

在了解 adrp 指令之前,首先要了解 adr 指令。adr 指令是小范围的地址读取指令,adr 指令将基于 PC 相对偏移的地址值读取到寄存器中。

PC 寄存器中存的是当前执行的指令的地址。在 arm64 中,软件是不能改写 PC 寄存器的。

而 adrp 是以页为单位的大范围的地址读取指令,这里的 p 就是 page 的意思。这里的汇编伪代码表达的含义就是将 dateFromComponents 选择器基于的地址读取出来存到 x1 寄存器中。

  • ldr 指令

ldr 指令的作用是取内存中的数据,放到另一个寄存器里。

  • bl 指令

bl 是带返回操作的跳转指令,bl 会将下一条指令的地址存储到 lr(x30) 寄存器中。在跳转之前,会在寄存器 r14 中保存 PC 的当前内容,因此可以通过将 r14 的内容重新加载到 PC 中,来返回到跳转指令之后的那个指令处执行。

上面的三条汇编指令体现在应用程序最终生成的二进制中,会占用一定的空间。在 ARM64 架构上,每条指令占用 4 个字节。因此,每一条 objc_msgSend 的调用都会有 12 个字节的空间开销。从单条指令来看这不是什么大问题,但是从更宏观的角度来分析,每一个方法调用,每一个属性的设置背后都伴随了一个 12 字节的 objc_msgSend 的指令调用,对于整个 App 的包大小是有着不小的影响的。

Assembly Code - Part 3

Selector Stub 优化方案

我们在上一小节中可以看到,在真正执行每条 _objc_msgSend 汇编指令之前,都需要两条汇编指令共计 8 个字节来专门为 selector 做准备工作。有趣的是,对于任何的 selector 来说,这两条汇编指令是一模一样的。所以基于这一点,Apple 带来了 Selector Stub 优化方案。

Assembly Code - Part 4

因为 _objc_msgSend 指令前两条的准备指令对于不同的 selector 都是一样的操作,我们可以对于每个 selector 只执行一条指令,从而达到优化 objc_msgSend 指令调用的大小开销。

Assembly Code - Part 5

如上图所示,我们将

1
2
3
adrp x1, [selector &quot;dateFromComponents&quot;]

ldr x1, [x1, selector &quot;dateFromComponents&quot;]

这两条指令提取后重构到 _objc_msgSend$dateFromComponents 指令中,然后原来的对 bl _objc_msgSend 的调用变成了对 _objc_msgSend$dateFromComponents 指令的调用。这个新的 _objc_msgSend$dateFromComponents 就是 selector stub 。但在 selector stub 内部,我们仍然需要调用 _objc_msgSend 指令,而在 _objc_msgSend 内部,又需要额外的几个字节来执行命令,这就是 Call stub 。

选择合适的优化策略

上一小节中的优化方案是尽可能的共享最多的代码,使得这些方法尽可能达到一个比较小的规模。但这会带来另外一个问题,那就是原本的一次 objc_msgSend 调用会变成了两次背靠背调用,这反过来又会对程序整体的性能造成影响。因此,我们基于上面的方案迭代出另一个版本来改进这一点。

Assembly Code - Part 5

如上图右侧汇编伪代码所示,我们将我们之前创建两个 stub 方法合并为一个,通过这样的重构,我们的代码更加紧凑,同时也避免了过多的指令跳转。

Apple 为我们提供了两个优化策略:

  • 策略一:通过 selector stub + call stub 的组合来尽可能的共享代码,达到汇编指令在最终二进制文件中占用更小的目的。该策略专注于针对 App 包大小进行优化,需要通过设置 -objc_stubs_small 链接器 flag 获得最极致的包大小优化效果。
  • 策略二:将两个 selector stub 合并为一个,减少汇编层面 bl 指令的调用从而提升性能,兼顾包大小优化的同时保证最佳的性能,这是默认的策略,无需手动开启。

Apple 给我们的建议是除非受到了非常严重的 App 包大小限制问题,尽量使用策略二来保证性能不受影响,所以这也就是为什么默认是包大小和性能的平衡策略。

通过 Objective-C 消息发送上的优化,在 ARM64 上之前 12 个字节的开销被压缩到了 8 个字节。这可以带来最高 2% 的包大小优化效果。即使你的 App 的最低部署版本低于 iOS 16 ,只要是基于 Xcode 14 进行编译的话,就可以自动享受到 Apple 给我们带来的优化。

Retain & Release 调用

Xcode 14 对于 Retain 和 Release 的开销也进行了针对性的优化,从之前的 8 个字节开销降低到了 4 个字节。Retain 和 Release 和消息发送一样,几乎也是无处不在的,所以,这项针对性的优化最高也可以带了 2% 的包大小优化收益。但和消息发送不同,这项优化依赖于 Runtime 的支持,因此只有将最低支持版本迁移到 iOS 16、tvOS 16 或者是 watchOS 9 才能生效。

objc_retain 和 objc_release

Retain Release - Part 1

让我们回到上一节的示例代码中,我们已经讨论了 objc_msgSend 的调用。但熟悉自动引用计数 ARC 的读者应该都了解编译器会帮助我们插入大量的 retain / release 调用来确保程序中对象的内存何时释放。

每当我们通过 alloc 、new 、copy 创建了一个对象后,该对象的引用计数就会加一,在底层是通过 _objc_retain 指令实现的。

感兴趣的读者可以参考 Advanced Memory Management Programming Guide 以及 Automatic Reference Counting - Swift

Retain Release - Part 2

而当对象离开了自己的作用域,需要被销毁的时候,引用计数就会减一,在底层是通过 _objc_release 指令实现的。

Retain Release - Part 3

基于 ARC 计数,编译器会帮助优化掉部分 _objc_retain_objc_release 指令调用。但是如上图所示,在方法最后结束前,局部变量 cal 和 dateComponents 并没有作为返回值返回给方法的调用方,因此需要被释放掉来实现内存回收。

Retain Release - Part 4

在底层实现上,objc_retain 和 objc_release 都是普通的 C 函数,接收唯一的参数 - 要被释放掉的对象。而由于 ARC 的存在,编译器会插入对这两个 C 函数的调用,并传入对应的指针对象。而为了遵循底层 ABI 定义的 C 函数的调用约定,我们需要更多的代码执行这些调用来达到将对象指针传入正确的寄存器中的目的。体现在汇编代码层面就是上图中各种 mov 指令。

自定义调用约定

Retain Release - Part 5

Apple 基于此进行了针对性的「自定义调用约定」优化。如上图所示,通过自定义专门的 objc_retain 和 objc_release 调用约定,我们可以根据对象指针的位置来使用正确的版本,这样我们就可以避免额外的 mov 指令所带来的开销。虽然这只是一个很细微的优化,但正如我们前面所讲的,对于整个 App 来说,这项优化伴随着无处不在的 retain 和 release 调用是有着量变引起质变的效果的。

Autorelease 自动省略

Apple 今年针对 Autorelease 自动省略的优化分为两个方面。

  • 受益于 Runtime 的更新,Autorelease 自动省略更高效。
  • 在此基础之上,受益于编译器的更新,最低发布版本为 iOS 16、tvOS 16 或者是 watchOS 9 的 App 会自动获得包大小减少 2% 的优化。

什么是 Autorelease 自动省略

在充分理解 Apple 针对 Autorelease 自动省略做出的优化前,我们先简单温习一下什么是 Autorelease 自动省略。

Auto Release - Part 1

还是基于之前的代码,如上图所示,getWWDCDate 方法返回了临时创建的 theDate 对象。然后 event 对象调用了 getWWDCDate 方法,随后声明了 theWWDCDate 对象,让其指向了方法的返回值 theDate 对象。

Auto Release - Part 2

对于 getWWDCDate 方法的调用方,ARC 会插入 retain 语句。

Auto Release - Part 3

而对于被调用方即 getWWDCDate 方会插入 release 语句,因为 theDate 对象离开了其作用域。但我们并不能真正马上就 release 掉 theDate 对象,因为该对象并没有其它的引用。

Auto Release - Part 4

如果我们在此时 release 了它,在我们完成 getWWDCDate 方法的调用前 theDate 对象就会被销毁,这并不是我们所期望的结果。

Auto Release - Part 5

所以一个特殊的约定就是插入 autorelease 语句,方法的调用方就可以紧接着对方法返回的对象进行 retain 操作。

事实上 Runtime 并不会保证真正的 release 操作何时发生,但只要确保方法返回前临时对象不被销毁,我们就可以在随后使用方法返回的临时对象并对其执行 retain 操作。

Autorelease 操作并不是没有开销的,Autorelease 省略就是专门来进行优化这项开销的。要了解它是如何工作的,让我们基于上面示例代码中的 return 语句查看其汇编实现。

Auto Release - Part 6

当我们调用 autoreleease 之后,我们视角就需要来到 Objective-C 运行时了,此时有意思的事情就会发生了。

Objective-C 运行时需要知道我们正在返回一个 Autorelease 的对象。而为了实现这一点,编译器会生成一个我们不会用到的特殊标记。通过这个特殊标记,运行时就知道当前是否符合 Autorelease 省略的条件,随之而来的是我们稍后要执行的 retain 操作。

Auto Release - Part 7

如上图所示,我们可以看到,在 getWWDCDate 方法 return 的地方生成了对应的汇编指令 b _objc_autoreleaseReturnValue,这里就相当于告诉了 Runtime 我们正在返回一个需要 Autorelease 的对象,我们不会在方法返回前释放掉这个对象。

随后,来到 getWWDCDate 方法的调用方,对应的汇编指令是 bl _getWWDCDate,前面我们已经提到过 bl 指令是带返回的跳转指令。所以相当于是先执行的 bl _getWWDCDate 指令,再执行的 b _objc_autoreleaseReturnValue 指令。这就对应上了调用一个方法,方法内部执行完成之后,代码控制权又回到了方法调用方的流程。

紧随其后的是 mov x29, x29 指令,x29 表示是对通用寄存器 r29 的按 64 位访问操作。而 r29 寄存器又被成为 fp(frame pointer) 寄存器。了解栈结构和 iOS 内存分布的读者应该都知道栈是从高地址往低地址增长的,而 fp 就指向了高地址,也就是栈顶的位置。但是乍看起来这句 mov x29, x29 似乎毫无意义,因为这相当于将 fp 上的值移动到了 fp 上面。这里的操作的意义并不在于 fp 的值移动,而是本身这句指令的二进制存储以十六进制表示之后就是 0xAA1D03FD,但是除此之外,我们并没有得到有价值的信息。

单看这几条汇编指令可能不太清晰,下面就让我们直接从源码入手来窥探底层的细节。

1
2
3
4
5
6
7
8
// Prepare a value at +1 for return through a +0 autoreleasing convention.
id
objc_autoreleaseReturnValue(id obj)
{
if (prepareOptimizedReturn(ReturnAtPlus1)) return obj;

return objc_autorelease(obj);
}

代码来自于 objc4-818.2 源码中的 NSObject.mm 源文件。

我们可以看到,objc_autoreleaseReturnValue 函数会接收一个对象,然后根据 prepareOptimizedReturn 方法返回值结果判断是否需要进行优化,如果需要优化,则增加一个 +1 的标志位,不放入自动释放池中;如果不需要优化,则会将对象放入自动释放池中,等待下一次 RunLoop 结束后自动释放池的 Pop 操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Prepare a value at +0 for return through a +0 autoreleasing convention.
id
objc_retainAutoreleaseReturnValue(id obj)
{
if (prepareOptimizedReturn(ReturnAtPlus0)) return obj;

// not objc_autoreleaseReturnValue(objc_retain(obj))
// because we don't need another optimization attempt
return objc_retainAutoreleaseAndReturn(obj);
}

// Same as objc_retainAutorelease but suitable for tail-calling
// if you don't want to push a frame before this point.
__attribute__((noinline))
static id
objc_retainAutoreleaseAndReturn(id obj)
{
return objc_retainAutorelease(obj);
}

代码来自于 objc4-818.2 源码中的 NSObject.mm 源文件。

同样的,objc_retainAutoreleaseReturnValue 方法也会判断传入的对象是否可以进行优化,如果优化成功,就增加一个 +0 标志位,不放入自动释放池;如果不能优化,则放入自动释放池中并 retain。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Try to prepare for optimized return with the given disposition (+0 or +1).
// Returns true if the optimized path is successful.
// Otherwise the return value must be retained and/or autoreleased as usual.
static ALWAYS_INLINE bool
prepareOptimizedReturn(ReturnDisposition disposition)
{
ASSERT(getReturnDisposition() == ReturnAtPlus0);

if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) {
if (disposition) setReturnDisposition(disposition);
return true;
}

return false;
}

代码来自于 objc4-818.2 源码中的 objc-object.h 源文件。

重点来了,关键函数是 callerAcceptsOptimizedReturn,传入的参数值是__builtin_return_address(0)

  • __builtin_return_address

这个内建函数原型是 char *__builtin_return_address(int level) ,作用是得到函数的返回地址,参数表示层数,如 __builtin_return_address(0) 表示当前函数体返回地址,传 1 是调用这个函数的外层函数的返回值地址,以此类推。

由于 prepareOptimizedReturn 函数是 ALWAYS_INLINE 的,所以该方法返回的就是(objc_autoreleaseReturnValue / objc_retainAutoreleaseReturnValue)的函数返回地址,即变量创建的函数返回地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
// __arm__
# elif __arm64__

static ALWAYS_INLINE bool
callerAcceptsOptimizedReturn(const void *ra)
{
// fd 03 1d aa mov fp, fp
// arm64 instructions are well-aligned
if (*(uint32_t *)ra == 0xaa1d03fd) {
return true;
}
return false;
}

代码来自于 objc4-818.2 源码中的 objc-object.h 源文件。

显然,在 callerAcceptsOptimizedReturn 函数的实现里面,我们可以看到 mov fp, fp 汇编指令的真实代码是什么样的,就是比较 ra 这个指针对象的值与 0xaa1d03fd 是否相等。到这里我们就解答了上面 mov x29, x29 汇编指令的作用了。

Thread Local Storage(TLS)线程局部存储,目的很简单,将一块内存作为某个线程专有的存储,以 key-value 的形式进行读写。

在返回值身上调用 objc_autoreleaseReturnValue 方法时,runtime 将这个返回值 object 储存在 TLS 中,然后直接返回这个 object(不调用 autorelease);同时,在外部接收这个返回值的 objc_retainAutoreleasedReturnValue 里,发现 TLS 中正好存了这个对象,那么直接返回这个 object(不调用 retain)。
于是乎,调用方和被调方利用 TLS 做中转,很有默契的免去了对返回值的内存管理。
黑幕背后的 Autorelease

在 objc4 之前,objc_autoreleaseReturnValue / objc_retainAutoreleaseReturnValue 会将传入的 obj 利用 TLS 存储,在 objc_retainAutoreleasedReturnValue / objc_unsafeClaimAutoreleasedReturnValue 中 根据 key 拿到原来的 objc 进行比较,如果相同就什么都不做。
objc4 做了优化,不再存储原始 obj,objc_autoreleaseReturnValue / objc_retainAutoreleaseReturnValue 只存储了一个标志位 ReturnDisposition,objc_retainAutoreleasedReturnValue / objc_unsafeClaimAutoreleasedReturnValue 根据标志位来进行判断是否需要 retain 或 release。
iOS 内存管理思考 - autorelease

Auto Release - Part 8

接着,运行时会以数据的形式加载这个特殊的标记指令 0xAA1D03FD,然后比较是不是所期望的特殊标记指令从而达到 Autorelease 省略的效果。关于 _objc_retainAutoreleasedReturnValue 的更多底层实现细节可以参考 how-does-objc_retainautoreleasedreturnvalue-work 一文。

Auto Release - Part 8

经过比较之后,如果匹配成功,则表示编译器告诉运行时我们正在返回一个随后马上会被 retain 的临时变量。最后这就可以让我们达到省略或移除互相匹配的 autorelease 和 retain 代码。这就是 Autorelease 省略。

Autorelease 自动省略优化方案

但是由于将代码作为数据加载并不是一个十分通用的场景,因此 CPU 并不会对此做出特殊优化。

Auto Release - Part 9

让我们再次回到前面的例子上,我们还是从 autorelease 作为探索的起点。在这个时间点上,我们已经有了一个十分有价值的线索,那就是方法的「返回地址」。通过方法「返回地址」,我们就知道在方法执行完成之后需要执行到哪个地方。所以我们可以持续追踪这个返回地址。值得一提的是,获取返回地址的操作十分得轻量,因为返回地址只是一个指针,我们可以存在一边以备后续流程使用。

Auto Release - Part 10

接着我们将目光离开 autorelease ,回到方法的调用方,当执行了 retain 操作之后,我们重新回到了运行时中。新的魔法开始了。

如上图绿色箭头所示,在此时,我们可以获取指向当前返回地址的指针。

Auto Release - Part 11

随后,我们只需要在运行时里面比较黄色箭头指针(之前执行 autorelease 操作时保存的函数地址)和绿色箭头的指针(执行 retain 操作时获得的函数返回地址)即可判断是否需要进行 Autorelease 省略。因为我们这里进行的操作只是两个指针的比较,这是十分轻量的操作。我们不需要进行高昂的内存访问。

Auto Release - Part 11

最重要的是,我们不再需要通过以数据的形式加载特殊的标记指令来进行比较,我们可以删除掉上图中 mov x29, x29 这条指令。这让我们在代码上节省了一定的大小开销。

总结

在 WWDC 22 上,Apple 发布了很多新功能、新特性和新 API。而本文所涉及到的内容就相对来说更加底层,更加的无感知。

基于新的 Xcode 14 进行编译并且 App 运行在最新的 OS 上,你可以得到:

  • Swfit 协议检查更加高效
  • Autorelease 自动省略速度更快
  • 消息发送 stub 带来的底层优化,最多可以压缩 2% 的 App 大小

如果基于最新的 iOS 16、tvOS 16 或者 watchOS 9 ,你可以得到

  • 通过减少 retain 和 release 底层汇编代码指令的大小,你可以获得额外的 2% 包大小减少的优化。

最后,阅读完本文之后,如果你想深挖底层实现的细节可以观看 WWDC22 - Link fast: Improve build and launch time 来更加全面和深入的了解静态链接、动态链接的前世今生以及各自的特点和 Apple 带来的极致优化。

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道