WWDC2016-406-Optimizing App Startup Time

本篇主要是WWDC2016中第406个Session中讲述内容的一个概要。这个Session主要讲优化app启动时间,包括理论部分和实际部分。

理论部分

一、Mach-O文件:运行时可执行文件的文件类型

  • 类型:
  1. 可执行文件。Executable — Main binary for application.
  2. Dylib — Dynamic library.(aka DSO or DLL)
  3. Bundle — Dylib that can not be linked. 只能在运行时用dlopen()函数打开。
  • Images — Any executable dylib or bundle.
  • Framework — Dylib with directory for resources and headers.
  • 图像格式:分成3段,每一段都是页面大小的倍数。
    下面这个例子:TEXT段是3页,DATA和LINKEDIT各占3页。

    页面大小由硬件决定,arm64处理器页面大小是16K,其他的是4K。

    实际上,几乎每个二进制文件都包含着三段。

    • __TEXT:Has headers, code, and read-only constants.
    • __DATA:Has all read-write content: gloables, static variables, etc
    • __LINKEDIT:Has “meta data” about how to load the program.包含变量函数信息,比如名称和地址。
  • 通用文件:Universal Files.
    假设你有一个Mach-O文件运行在64位(arm64)处理器的设备上,如果你想把它运行在32位(armv7s)的设备上,Xcode里会发生什么变化?

    会生成另一个单独的Mach-O文件。
    然后这两个文件合并生成第三个文件,这个文件就是通用文件。
    通用文件会有一个头文件,占一页大小。

二、虚拟内存:Virtual Memory

  • 意义: 间接层。当所有的进程存在时,该如何管理所有的物理内存?—使用间接层。每一个进程都是一个逻辑地址空间,映射到RAM的某个物理页面。
  • Features:虚拟内存的作用
  1. Page fault。如果一个虚拟内存不映射到任何物理内存,那么访问这个进程 时,就会产生页面错误,内核会停止该进程,并试图找出解决方案。
  2. 多个进程可以共享同一块物理内存,进程共享。
  3. File backed pages:基于文件的映射:不用把整个文件读入RAM,而是调用mmap()函数告诉虚拟内存系统,我想把这个文件映射到进程里的这段地址。
  • Copy-On-Write (COW):写入时复制。写入时复制所做的就是它积极地在所有进程里共享DATA页面,只要进程只读有共享内容的全局变量,但是一旦有进程想要写入其DATA页面,写入时复制开始。内核会把该页面复制,放入另一个物理RAM并重定向映射,所以该进程有了该页面的副本。
  • Dirty vs. clean Pages:脏页面和干净页面。上面的副本被认为是脏页面。脏页面是指含有进程特定信息。干净页面是指内核可以按照需要重新建立的页面,比如重新读取磁盘的时候。脏页面比干净页面昂贵得多。

三、安全如何影响Dyld

  1. ASLR:把加载地址随机化。
  • Adderss Space Layout Randomization.
  • Images load at random address.
  1. Code Signing:代码签名。
  • Content of each page is hashed.
  • Hash is verified on page-in.

四、exec() —> main()

进程是如何启动的

  • exec()是一个系统调用。当你进入一个内核,说:我想把这个进程换成这个新程序。然后内核会抹去整个地址来映射这个新的可执行程序。ASLR会给它分配一个随机地址。下一步是从该随机地址回溯到0地址,如下图:把整个地址标记为不可访问。

    但是Dylib并不用内核来加载,而是用帮助程序来加载:
  • Kernal loads helper program
  • Dyld (Dynamic loader)
  • Executions starts in Dyld (aka LD.SO)
    所以,当内核完成内存映射之后,就把指针指向Dyld,让Dyld来完成进程的启动。它的工作是加载所有依赖的Dylib,让它们准备好开始运行。其加载过程如下:
  • Load dylibs
  1. 读取所有依赖的Dylib。首先从内核中读取已经加载好的主可执行文件。在这个主可执行文件的Header中有所有依赖库的列表。然后打开和运行这些Dylib,验证它是不是一个Mach-文件,找到它的编码签名,在内核里对它进行注册,在该Dylib的每一段调用mmap()函数。
  2. 在加载每个Dylib时,每个Dylib可能还依赖于另一个Dylib,所以需要递归式的把它们一个一个找出来加载到内存。
  • DATA修复
    当所有的Dylib都加载完毕后,他们都是彼此独立的,需要把他们绑在一起。
    为了能让一个Dylib调用另一个Dylib,Code-gen实际上在该Dylib的DATA段里新建了一个指针,该指针指向我们想要调用的那个Dylib的位置,然后代码加载该指针,就可以跳向另一个Dylib。
    这种修复有两种:
  1. 重设基址(Rebase):遍历所有内部数据指针,然后为它们添加一个滑动值。这些指针在段里的位置都编码在__LINKEDIT段里。I/O比较多。
  2. 绑定(Bind):针对那些指向Dylib范围外的指针而言的。其计算复杂度比Rebase要高得多。
  3. ObjC:经过前两步之后,在ObjC运行时还需要一些额外的操作。在ObjC运行时,必须要维护一张表格,包含所有名称及其映射的类。每次加载的名称都将定义一个类,名称需要登记在一个全局表格里
  • Initializer
  1. 进入+load()方法(如果有的话),现在不建议使用。
  2. 调用顺序是从下往上。
  • 跳转到main()函数

四、总结:

实际部分

一、启动速度多快才是好的用户体验?

答:400 ms
应用的launch过程,这里复述了理论部分的内容:

  1. Warm launch:冷启动
    App is not in kernel buffer cache.
    应用在kill掉之后很长一段时间后,或者reboot重启手机之后,再打开应用的时候就是冷启动。
  2. Cold launch:热启动。
    App and data already in memory.
    应用启动时还在内存里,或者之前启动过,然后退出了,此时应用还在内核的磁盘缓存里。

    二、如何测量启动速度?Why is launch slow?我们可以做什么?

    Measuring before main()
    在应用启动并运行之前,一般的方法是无法测量这个时间的。
  • DYLD_PRINT_STATICTICS environment variable
    在Xcode里的Edit Scheme里设置环境变量:

    然后重新开始运行APP,控制台里会打印出在main()之前耗费的时间:

    这个是我的APP所花费的时间。这个测量是支持模拟器运行的,不过最好用真机测试。
  1. dylib loading: OS dylibs 加载很快,因为构建OS时,会预计算大量的dylib数据,但是始终无法全部预算。所以,Embedded dylibs are expensive.
  • 减少使用dylibs:Merge existing dylibs,Use static archives,使用延时加载,但是尽量不要使用dlopen()。
  1. rebase/bind: rebase由于I/O的原因,会比较慢。时间都花费在修复__DATA里的指针。所以方法就是减少需要修复的指针。
  • 减少ObjC类对象和ivars的数量。
  • 减少使用C++虚拟函数。
  • 使用Swift语言 :Swift通常用的数据要少一些。而且Swift更为内联,可以更好地使用code-gen减少消耗。
  1. ObjC setup:它要注册类,处理非脆弱ivar,注册目录,让选择器变成唯一。但是这里一般不需要我们人为的过多的处理。
  2. Initializer:
  • 显示的初始化器:ObjC +load()方法。(如果你是用的话最好换成+initialize()方法,因为不推荐使用+load()方法) 但是这种显示的初始化器最好换成点初始化器:dispatch_once()或者pthread_once()或者std::once()。
  • 隐式初始化器:大部分来自C++的全局变量,带有非默认的初始化器,非默认的构造函数。这里可以用前面提到的点初始化器替代,或者把全局的换成非全局的结构或者指针,指向想要初始化的对象。或者Only set simple values (PODs)。或者用Swift重新编写,Swift的全局变量可以在使用前确保被初始化,其本质还是在后台调用了点初始化器。

    注意:

    1. 不要使用dlopen(),会有死锁或者其他未定义的行为发生,还会导致性能下降。
    2. 不要在初始化器上开启线程。会带来性能下降和正确性的问题。

三、总结