iOS

解读:2016WWDC App运行理论和pre-main()阶段

2018-04-27  本文已影响33人  小飞鱼_love

Apple 2016WWDC介绍

App 运行理论

Mach-O 术语

Mach-O 是针对不同运行时可执行文件的文件类型,截图如下:

1、Executable: 应用的主要二进制(比如.o文件)

2、Dylib: 动态链接库(又称 DSO 或 DLL)

3、Bundle: 资源文件,不能被链接的 Dylib,只能在运行时使用 dlopen() 加载

4、Image: executable,dylib 或 bundle

5、Framework: 包含 Dylib 以及资源文件和头文件的文件夹

Mach-O 镜像文件:

Mach-O 被划分成一些 segment,每个 segement 又被划分成一些 section。

segment 的名字都是大写的,且空间大小为页的整数。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。

section 虽然没有整数倍页大小的限制,但是 section 之间不会有重叠。

几乎所有 Mach-O 都包含这三个段(segment): __TEXT,__DATA 和 __LINKEDIT:

1、__TEXT 包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。

2、__DATA 包含全局变量,静态变量等。可读写(rw-)。

3、__LINKEDIT 包含了加载程序的『元数据』,比如函数的名称和地址。只读(r–)。

Mach-O Universal 文件:

Mach-O Universal文件,将多种架构的 Mach-O 文件合并而成。它通过 Fat Header 来记录不同架构在文件中的偏移量,Fat Header 占一页的空间。

按分页来存储这些 segement 和 header 会浪费空间,但这有利于虚拟内存的实现。

虚拟内存:

虚拟内存就是一层间接寻址(indirection)。软件工程中有句格言就是任何问题都能通过添加一个间接层来解决。虚拟内存解决的是管理所有进程使用物理 RAM 的问题。通过添加间接层来让每个进程使用逻辑地址空间,它可以映射到 RAM 上的某个物理页上。这种映射不是一对一的,逻辑地址可能映射不到 RAM 上,也可能有多个逻辑地址映射到同一个物理 RAM 上。针对第一种情况,当进程要存储逻辑地址内容时会触发 page fault;第二种情况就是多进程共享内存。

对于文件可以不用一次性读入整个文件,可以使用分页映射(mmap())的方式读取。也就是把文件某个片段映射到进程逻辑内存的某个页上。当某个想要读取的页没有在内存中,就会触发 page fault,内核只会读入那一页,实现文件的懒加载。

也就是说 Mach-O 文件中的 __TEXT 段可以映射到多个进程,并可以懒加载,且进程之间共享内存。__DATA 段是可读写的。这里使用到了 Copy-On-Write 技术,简称 COW。也就是多个进程共享一页内存空间时,一旦有进程要做写操作,它会先将这页内存内容复制一份出来,然后重新映射逻辑地址到新的 RAM 页上。也就是这个进程自己拥有了那页内存的拷贝。这就涉及到了 clean/dirty page 的概念。dirty page 含有进程自己的信息,而 clean page 可以被内核重新生成(重新读磁盘)。所以 dirty page 的代价大于 clean page。

Mach-O 镜像 加载:

所以在多个进程加载 Mach-O 镜像时 __TEXT 和  __LINKEDIT 因为只读,都是可以共享内存的。而 __DATA 因为可读写,就会产生 dirty page。当 dyld 执行结束后,__LINKEDIT 就没用了,对应的内存页会被回收。

安全:

ASLR(Address Space Layout Randomization):地址空间布局随机化,镜像会在随机的地址上加载。这其实是一二十年前的旧技术了。

代码签名:可能我们认为 Xcode 会把整个文件都做加密 hash 并用做数字签名。其实为了在运行时验证 Mach-O 文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT 中。这使得文件每页的内容都能及时被校验并确保不被篡改。

从 exec() 到 main():

exec() 是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用 ASLR)。并将起始位置到 0x000000 这段范围的进程权限都标记为不可读写不可执行。如果是 32 位进程,这个范围至少是 4KB;对于 64 位进程则至少是 4GB。NULL 指针引用和指针截断误差都是会被它捕获。

dyld 加载 dylib 文件:

Unix 的前二十年很安逸,因为那时还没有发明动态链接库。有了动态链接库后,一个用于加载链接库的帮助程序被创建。在苹果的平台里是 dyld,其他 Unix 系统也有 ld.so。 当内核完成映射进程的工作后会将名字为 dyld 的Mach-O 文件映射到进程中的随机地址,它将 PC 寄存器设为 dyld 的地址并运行。dyld 在应用进程中运行的工作是加载应用依赖的所有动态链接库,准备好运行所需的一切,它拥有的权限跟应用一样。

下面的步骤构成了 dyld 的时间线:

这一阶段dyld会分析应用依赖的dylib。从主执行文件的 header 获取到需要加载的所依赖动态库列表,而 header 早就被内核映射过,然后它需要找到每个dylib,然后打开文件读取文件起始位置,找到其mach-o文件,打开和读取这些文件并验证其有效性,接着会找到代码签名注册到内核,最后对dylib的每一个segment调用mmap(),应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。

一般情况下,iOS应用会加载100-400个dylibs,其中大部分是系统库,这部分dylib的加载系统已经做了优化。

Fix-ups:

在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是 Fix-ups。代码签名使得我们不能修改指令,那样就不能让一个 dylib 的调用另一个 dylib。这时需要加很多间接层。

现代 code-gen 被叫做动态 PIC(Position Independent Code),意味着代码可以被加载到间接的地址上。当调用发生时,code-gen 实际上会在 __DATA 段中创建一个指向被调用者的指针,然后加载指针并跳转过去。

所以 dyld 做的事情就是修正(fix-up)指针和数据。Fix-up 有两种类型,rebasing 和 binding。

Rebasing 和 Binding

Rebasing:在镜像内部调整指针的指向

Binding:将指针指向镜像外部的内容

ObjC Runtime:

Objective-C 中有很多数据结构都是靠 Rebasing 和 Binding 来修正(fix-up)的,比如 Class 中指向超类的指针和指向方法的指针。

ObjC 是个动态语言,可以用类的名字来实例化一个类的对象。这意味着 ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时,其定义的所有的类都需要被注册到这个全局表中。

C++ 中有个问题叫做易碎的基类(fragile base class)。ObjC 就没有这个问题,因为会在加载时通过 fix-up 动态类中改变实例变量的偏移量。

在 ObjC 中可以通过定义类别(Category)的方式改变一个类的方法。有时你想要添加方法的类在另一个 dylib 中,而不在你的镜像中(也就是对系统或别人的类动刀),这时也需要做些 fix-up。

ObjC 中的 selector 必须是唯一的。

Initializers:

到了这一阶段,dyld开始运行程序的初始化函数,调用每个Objc类和分类的+load方法,调用C/C++ 中的构造器函数(用attribute((constructor))修饰的函数),和创建非基本类型的C++静态全局变量(通常是类或结构体)。Initializers阶段执行完后,dyld开始调用main()函数。

pre-main()主要流程:

pre-main阶段优化:

优化介绍:

App启动流程:

无论对于系统的动态链接库还是对于App本身的可执行文件而言,他们都算是image(Mach-O 镜像),而每个App都是以image(Mach-O 镜像)为单位进行加载的

pre-main()阶段测量启动时间:

控制台打印出来的pre-main()耗时(Apple这个Demo有点过于夸张...):

1、加载 Dylib阶段可优化项:

加载系统的 dylib 很快,因为有优化。但加载内嵌(embedded)的 dylib 文件很占时间,所以尽可能把多个内嵌 dylib 合并成一个来加载,或者使用 static archive。使用 dlopen() 来在运行时懒加载是不建议的,这么做可能会带来一些问题,并且总的开销更大。

所以,依赖的dylib越少越好。在这一步,我们可以做的优化有:

1.1、尽量不使用内嵌(embedded)的dylib,加载内嵌dylib性能开销较大

1.2、合并已有的dylib和使用静态库(static archives),减少dylib的使用个数

1.3、懒加载dylib,但是要注意dlopen()可能造成一些问题,且实际上懒加载做的工作会更多

2、Rebase/Binding阶段可优化项:

Rebaing 消耗了大量时间在 I/O 上,而在之后的 Binding 就不怎么需要 I/O 了,而是将时间耗费在计算上。所以这两个步骤的耗时是混在一起的。

所以,指针数量越少越好。在这一步,我们可以做的优化有:

2.1、减少ObjC类(class)、方法(selector)、分类(category)的数量

2.2、减少C++虚函数的的数量(创建虚函数表有开销)

2.3、使用Swift structs(内部做了优化,符号数量更少)

3、ObjC Setup阶段可优化项:

大部分ObjC初始化工作已经在Rebase/Bind阶段做完了,这一步dyld会注册所有声明过的ObjC类,将分类插入到类的方法列表里,再检查每个selector的唯一性。

在这一步倒没什么优化可做的,Rebase/Bind阶段优化好了,这一步的耗时也会减少。

4、Initializer阶段阶段可优化项:

使用 +initialize 来替代 +load

不要使用 __atribute__((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_once(),pthread_once() 或 std::once()。也就是在第一次使用时才初始化,推迟了一部分工作耗时。

隐式初始化:

对于带有复杂(non-trivial)构造器的 C++ 静态变量:

在调用的地方使用初始化器。

只用简单值类型赋值(POD:Plain Old Data),这样静态链接器会预先计算 __DATA 中的数据,无需再进行 fix-up 工作。

使用编译器 warning 标志 -Wglobal-constructors 来发现隐式初始化代码。

使用 Swift 重写代码,因为 Swift 已经预先处理好了,强力推荐。

不要在初始化方法中调用 dlopen(),对性能有影响。因为 dyld 在 App 开始前运行,由于此时是单线程运行所以系统会取消加锁,但 dlopen() 开启了多线程,系统不得不加锁,这就严重影响了性能,还可能会造成死锁以及产生未知的后果。所以也不要在初始化器中创建线程。

所以在这一步(Initializer),我们可以做的优化有:

4.1、少在类的+load方法里做事情,尽量把这些事情推迟到+initiailize

4.2、减少构造器函数个数(用attribute((constructor))修饰的函数),在构造器函数里少做些事情

4.3、减少C++静态全局变量的个数

pre-main()阶段优化后效果:

上一篇 下一篇

猜你喜欢

热点阅读