前言
上一篇主要是介绍在iOS开发过程中App 启动流程以及优化的问题。这一篇主要是说明dyld 3的变化对App的启动带来的影响。
内容来自:
WWDC 2017 Session 413 App Startup Time: Past, Present, and Future。
英文过关的可以直接去看了,本文可以直接略过。
2017 dyld 3
在 WWDC 2017 上苹果重申了几个在App启动时需要优化的点:
尽量减少嵌入的dylibs的数量
推荐使用系统提供的库尽量减少类和方法的数量
尽量减少初始化函数
尽量使用Swift
Swift没有初始化器。
Swift不允许特定类型的未对齐数据结构(这样的结构会延长启动时间。)
Swift代码更精简,所以性能更好
Instruments 工具
在 iOS11 上面我们可以使用最新提供的Initializer Calls工具分析每个静态初始化器消耗的时间。
dyld的版本
dyld 1.0 (1996–2004)
Shipped in NeXTStep 3.3
Predated POSIX dlopen() standardized
Third-party wrapper functions
Before most systems used large C++ dynamic libraries
Prebinding added in macOS Cheetah (10.0)
在C++动态库大规模使用前编写的,没有用到C++的先进的特性,在静态环境中运行良好,但是在动态环境中可能会降低性能。由于巨大的C++代码库会导致动态链接器要做大量的工作,这样太耗费时间。
在macOS 10.0 发布前,苹果还增加了“预绑定”特性。为系统中的所有的dylib和App找到固定的地址,动态加载器会加载这些地址的所有内容,并且编辑这些二进制数据以获得预计算的地址,这样下次当它将所有数据放入相同地址时,就不必进行任何额外的工作。这会大幅提高速度。但是这也意味着每次App的启动都需要编辑二进制数据,这并不是好的做法。至少从安全性来说是如此。
dyld 2.0 (2004–2007)
Shipped in macOS Tiger
Complete rewrite
- Correct C++ initializer semantics
- Full native dlopen()/dlsym() semantics
Designed for speed
- Limited sanity checking
- Security “Issues”
Reduced prebinding
后来推出了dyld 2 它是macOS Tiger的组成部分。
dyld 2 是 dyld 的完全重写版本。它可以正确支持C++的初始化语义,所以苹果扩展了Mach-O的格式,并更新了dyld 以获取高效的C++库的支持。
dyld 2 具有完整的本地dlopen和dlsym的实现,具有正确的语义,所以苹果弃用了旧版API,这些旧的API依然存在于macOS中,没有加入到任何其他的平台上。
dyld 的设计目标是提高速度,因此仅进行有限的健全性检查。那个时候没有如今这么多的恶意软件,但是现在使用它就可能存在一些安全问题了,所以,苹果又对其进行了改造。由于速度大幅提升,所以苹果可以减少一些预绑定,不是选择编辑App而是编辑系统库,这在软件更新的时候就可以完成。
dyld 2.x (2007–2017)
More architectures and platforms
- x86, x86_64, arm, arm64
- iOS, tvOS, watchOS
Improved security
- Codesigning, ASLR, bounds checking
Improved performance
- Prebinding completely replaced by shared cache
dyld 2.x 增加了大量的基础架构和平台, 都需要新的 dyld 功能。
为了提升安全性,苹果做了很多处理,代码签名,ASLR(地址空间配置随机加载),增加了Mach-O的header中的项目(重要的边界检查功能,可以避免恶意二进制数据的加入)。
苹果还进一步提升了性能,并且通过共享代码替换了预绑定。
Shared Cache
Introduced in iOS 3.1 and macOS Snow Leopard
- Replaced prebinding
Single file that contains most system dylibs
- Rearranges binaries to improve load speed
- Pre-links dylibs
- Pre-builds data structures used dyld and ObjC
Built locally on macOS, shipped as part of all other Apple OS platforms
共享代码最早被引入的是iOS 3.1 和 macOS Snow Leopard. 完全取代了预绑定,它是一个包含了大多数系统dylib的单个文件。由于合并成了一个文件,所以可以进行特定的优化,可以重新调整所有文本段和所有的数据段,重写整个符号表,从而减小体积。这样在每个进程加载的过程中,只需要挂在少量的区域。它允许我们打包二进制数据段以节省大量的RAM。它实际上是dylib预链接器,这里并不会讨论特定的优化结果,但是它在通常的iOS系统上对RAM的节约是显著的(运行时可以节约500M-1GB的内存)。它会预生成数据结构供dyld和Ob-C在运行时使用,这样我们就不必在程序启动的时候做这些事情,这样也会节约更多的RAM和时间。共享代码在macOS上本地生成,运行dyld共享代码将会大幅优化系统性能并带来其他好处。在苹果的其它平台上,Apple会生成共享代码提供给用户。
dyld 3 (2017)
Announcing it today
Complete rethink of dynamic linking
On by default for most macOS system apps in this weeks seed
Will be on be the default for system apps for 2017 Apple OS platforms Will completely >replace dyld 2.x in future Apple OS platforms
dyld 3 是全新的动态链接器,在2017年推出。
它完成改变动态链接概念,将成为大多数macOS系统程序的默认配置,2017 Apple OS平台上的所有系统程序都会默认使用它。在未来的Apple OS平台和第三方程序中,它将会全面取代dyld2,那么我们为什么要再次使用动态链接器呢。
首先,为了性能。性能是一个永恒的主题,我们想要尽量的提高启动速度。
其次是安全性。 前面提到过苹果在dyld2中增加了部分安全特性。但是很难跟随现实情形增强安全性。(苹果做了大量的尝试,但是还是无法实现这个目标),那么是否可以更积极的考虑安全问题并且从设计上提高安全性。
最后是可测试性和可靠性。dyld是不是可以变得更容易被测试。为此苹果发布了很多不错的测试框架,比如XCTest。我们应该使用它们。但是它们依赖于动态链接器的底层功能,将它们的库插入进程,因此它们不能用于测试现有的dyld代码。这让我们难以测试安全性和性能水平。
所以苹果将大多数的dyld移出进程,现在它只是普通的后台程序,可以使用标准测试工具进行测试。这让以后有更多的机会提高速度和性能,另外也允许部分dyld驻留在进程之中。但是驻留部分应该尽可能小,从而减少程序的受攻击面积。
由于需要编写的代码少了,因此会提高启动速度。代码运行速度是前所未有的。
下面就对比一下 dyld 2 和 dyld 3 是如何启动程序的。
在dyld 2中,
首先要分析mach-o文件,弄清楚App需要哪些库,然后这个地方可能需要其他的依赖库,所以要进行递归分析。知道获得所有dylib的完整依赖。普通的iOS程序需要3-600个dylib。
数据庞大,需要大量的处理。
然后我们映射到所有的mach-o文件将它们放入地址空间。
然后执行符号查找。(例如:如果你使用了printf()函数,就会查找printf是否在库系统中,找到它的地址,将它赋值到你的程序中的函数指针)。
然后是绑定和基址重置
复制这些指针,由于使用随机地址,所有指针必须使用基址。
最后,我们可以运行你的所有初始化器。
这时我们准备执行main函数。
这里进行了大量的工作。
那么如何加快这里的速度,将这些步骤移出进程呢?
首先,确定安全敏感性组件。从我们的角度看这是最大的安全隐患之一。
分析mach-o文件头和查找依赖关系。
因为人们可以使用篡改过的mach-o文件头进行攻击。而且,你的程序可能使用了@rpaths,这个是搜索路径,通过篡改这些路径或者将库插到适当的位置可以破坏程序。
因此我们在后台程序的进程之外完成所有的这些工作。然后我们确定大量占用资源的部分,也就是占用缓冲的部分。它们是符号查找,因为在给定的库中,除非进行软件更新或者在磁盘上更改库,符号将始终位于库中的相同的偏移位置。我们已经确定了这些内容,现在来看看它们在dyld3中是怎样的。
我们将这些部分移到上层(图中红色的部分),然后向磁盘写入收尾处理 “Write closure to disk”。这样,启动收尾处理就成了启动程序的重要环节。稍后可以在进程中使用 dyld 3包含的这三个部分,
dyld 3:
- 它是一个进程外mach-o分析器和编译器。
- 也是一个进程内引擎,执行启动收尾处理。
- 也是一个启动收尾缓存服务。
大多数程序启动会使用缓存,但始终不需要调用进程外mach-o分析器或编译器。
启动收尾比mach-o更简单。它们是内存映射文件,不需要用复杂的方法进行分析。
我们可以简单的验证它们,这样可以提高速度。
我们来详细看一下每个部分:
- dyld 3 是进程外mach-o分析器
它解析所有的搜索路径,所有的rpaths,所有的环境变量(它们会影响你的启动),
然后分析mach-o二进制数据,
执行所有的符号查找,
最后利用这些结果创建收尾处理。
它是一个普通的后台程序,让我们提高测试基础架构的性能。
- 也是一个进程内引擎,执行启动收尾处理。
dyld 3是一个小型进程内引擎,这部分驻留在进程中,也是我们通常能看到的部分。它所做的事情是检查启动收尾处理是否正确, 然后映射到dylib之中,再跳转到main函数。
dyld 3不需要分析mach-o文件头或执行符号查找,不用做这些耗时的事情就可以启动应用,这样也就大幅提高了应用的启动速度。
- dyld 3也是一个启动收尾缓存服务。
苹果将系统程序收尾直接加入到共享缓存,我们已使用这个工具在系统中运行和分析每个mach-o文件。我们可以直接将它们放入共享缓存,使它映射到缓存中。所有的dylib都使用它来启动,我们甚至不需要打开其它文件。
对于第三方程序,苹果在程序安装或者系统更新的时候就会生成程序的收尾处理,因为那时候的系统库已经发生更改。默认情况下将在iOS、tvOS、watchOS上生成收尾处理(甚至在程序运行之前)。
在macOS上,由于可以侧向加载程序,如果需要,进程内引擎可以在首次启动时RPC到后台程序。在此之后,能够使用缓存的收尾处理。在苹果的其他平台则不需要这样做。
dyld3 这个新的动态链接器也可能会存在一些问题。
首先 它完全兼容dyld 2.x,所有现有的一些API可能会导致你的程序运行变慢,或者会在dyld 3中使用回退模式。这个问题需要避免。稍候会详细说明。
另外,开发者所做的一些优化,现在可能已经不再需要,因此不需要在这方面花费过多的力气。
另外需要说明的是,苹果将会使用更为严格的链接语义。
很多的语义现在在大多数情况下可以使用,但是现在来看已经不适合甚至还是错误的,
苹果发现有这多这样的情况。所以加入新动态链接器时会使用更为严格的链接语义,目的是为了发现所有的边界例子。
苹果所做的事情就是放入一个工作区以支持这些旧的二进制数据,但是不会在处理更多了。苹果会进行链接或者后续检查,查看开发者使用哪些SDK,然后会禁用新二进制数据的工作区。让开发者能够解决这些问题。新二进制数据将会造成链接器问题。
接下来会讨论数据段中的未对齐指针。
未对齐指针
建设你有一个全局性结构,指向一个函数或者另外一个全局性函数。在你的程序启动之前,苹果必须修复这个指针,在苹果的系统上,指针必须自然对齐以获得最佳性能,修复未对齐指针非常复杂。它们可能 覆盖多个内存页,造成更多的内存页错误和其他问题,这可能会产生与多处理器相关的细微问题。
静态链接器已经忽略这个警告:Id警告,指针地址未对齐。
接下来,我们讨论符号解析。
符号解析
dyld 2执行懒符号解析,dyld必须加载所有符号。这需要占用大量资源,因此应该使用缓存。直接运行现有程序,确实会占用很多资源,将会花费很长的时间。因此苹果使用一种机制,称为 懒符号解析 默认情况下,库中的函数指针,比如printf,并不指向printf,默认情况下,它指向dyld中的一个函数。此函数返回一个指向printf的函数指针,因此启动时,调用printf将会进入dyld,返回printf进行首次调用。然后第二次,你就可以直接调用printf。由于苹果已经缓存并且计算所有符号,因此在程序启动时不会产生额外的开销来绑定它们。当你这样做时,缺失符号的行为将会有所不同,在现有的懒符号机制中,如果缺失一个符号,首次调用,将会正确启动。首次调用该符号,程序将会崩溃。如果使用勤符号,将会立即崩溃。为此,苹果提供了兼容模式。苹果将导致自动崩溃的符号放入dyld 3,如果不能找到你的符号,苹果会绑定该符号,因此首次调用将崩溃。这是现有的SDK的工作模式。在未来的SDK中,苹果会强制预先进行所有的符号解析。缺失一个符号就会崩溃。在开发过程中,开发者应该能发现这些崩溃现象,而不是用户在程序运行时发现它们。现在我们可以模拟这些行为,即:
bind at load:
如果你将它添加到你的调试程序,程序将会变得很慢,因此这个应该只放在调试版本。使用这个以后,你将会获得更可靠的行为。这能让你更好的使用dyld 3。
Dlopen、dlsym、dladdr,在2016年的WWDC中提到过,仅在十分必要的时候才使用它们。它们具有一些容易出错的语义。但是在一些情况下,仍然需要使用它们。特别是使用dlsym找到的符号,我们需要在运行时找到它们。我们不会提前知道这些符号。不能使用prefetching、presearching。当你使用dlopen或者dlsym时,苹果会读入以前未接触过的所有符号表页。这会占用大量资源。因此,苹果可能必须RPC到后台程序。这取决于其复杂程度。苹果正在开发一些更好的替代方法,目前还没有完成。
接下来谈谈dlclose。
dlclose
dlclose是一个误用词。它是一个Unix API。如果在苹果的系统上编写它,苹果会将其命名为dlrelease。因为它实际上并不关闭dylib。它减少refcount计数,如果refcount变为0,将会关闭它。
它的重要性是什么?它并不利于资源管理。如果你有一个库用于特定硬件。你不应该关闭硬件来响应dlclose。因为程序中的其他代码可能会在后台打开硬件。因此你的硬件不会关闭。应该使用显示资源管理。
苹果的平台上还有很多特性,防止dylib被卸载。
举几个例子:
你的dylib中可以有Objective-C类,这将导致dylib不可卸载。
你可以包含Swift类,这也会导致dylib不可卸载。
你可以包含C底层线程或者C++线程本地变量,这些都会导致dylib不可卸载。
因此在具有一些现成Unix程序的macOS上,苹果会保持这个特性,但是由于苹果的所有其他平台上的几乎每个dylib都会这么做,因此并不能在这些平台上有效的工作。因此我们可以将它视为无操作指令,不会在任何平台上进行操作。
最后我们讨论下 dyld all image infos:
dyld all image infos
这是进程中的内在dylib的接口,它来自最初的dyld 1,但它只是内存中的一个结构,而不是API,当只有5个或者10个dylib的时候并没有问题,但是如果有300、400、500个dylib时,这个设计模式将会导致浪费大量内存,苹果需要回收那些内存,苹果需要高性能而且节省内存。在未来的版本中,苹果会取消这个设计,但是同时也会提供一个替代性API,因此,它很少被用到,如果你要使用它,最好知道你为什么要使用它、如何使用它,确保苹果设计的API适合你的用例,有很多功能已经不再适用,不符合你的预期。
最后讨论一下dyld 3的最佳实践:
dyld 3的最佳实践
首先应该确保将bind at load添加到LD FLAGS,而且应该仅在调试版本中才能这样做。
开发者应该修复数据段中的任何未对齐的指针。还有ld的警告信息。 “pointer not aligned at address 0x100001004”。
开发者应该使用Swift的键径功能,消除所有警告错误。你也可以忽略,因为苹果将会解决这个问题。
当你调用dlclose的时候应该确保不依赖于任何正运行的终止函数。
苹果希望知道开发者为何使用dlopen、dlsym、dladdr和all image info结构,以确保苹果的替代性API能够满足开发者的需求。如果它们是POSIX的一部分,将会被保留。这只会造成性能降低,对于all image infos,它将会被取消以节省内存。
总体来说,在App启动时间的优化中,如果想要减少启动时间,那么就不可避免的会牺牲一些快捷开发方式以及延长开发的周期,这和我们所推崇的各种先进的编程理念相违背。开发的便捷通常意味着让系统帮我们做了额外的事情,而系统所做的事情由于兼容性,稳定性的原因很大程度会比我们自己去写要考虑的更多,这也是造成性能损耗的一个方面。
我们正常的开发中经常由于设计模式、项目架构或者离奇的需求的原因不得不采用一些“曲折”的方式来编写代码,有很多优化其实应该在设计需求的时候就应该考虑。不过优化只是产品中的一个点,也不能过分追求性能的优化,毕竟不管是开发还是设计最终都是服务于整个产品,在确保产品稳定的基础上进行适当的优化才是可取的。
关于这个WWDC 2017的启动优化的Session就这么多内容了,剩下的主要集中在main函数执行以后的业务逻辑优化上。这个需要根据公司的具体业务来看,不过总体的原则就是避免启动的时候做耗时操作。
参考的相关资料
优化 App 的启动时间
WWDC 2016 Session 406 Optimizing App Startup Time
WWDC 2017 Session 413 App Startup Time: Past, Present, and Future
今日头条iOS客户端启动速度优化