Swift 性能分析

作者:唐巧,小猿搜题产品技术负责人,6 年 iOS 开发经验,《iOS开发进阶》作者。

Richards 和 Deltablue 是衡量语言运算速度的两个主流的评测代码。Swift 在这两个评测中,显示出远超 Objective-C 的性能(如下图)。特别是 Richards 评测,Swift 比 Objective-C 快了 4 倍。那么,为什么 Swift 这么快呢?

有些人会觉得 Swift 就应该很快,因为它是一个新的、现代的语言。但是别忘了,Swift 其实非常年轻,从 2014 年推出 1.0 版本以来,它才经过了三年的发展。而 Objective-C 作为苹果在过去 30 年来唯一的在 macOS 和 iOS 平台上的语言,经过了大量 Apple 工程师的优化,如果有什么优化 Swift 可以做,为什么 Objective-C 就不能在过去几十年中做到呢?为了解决我自己的这个疑惑,我查阅了一些相关的资料,并做了一些实验。

接下来,我将从编译器优化,内存分配优化,引用计数优化,方法调用优化,面向协议编程的实现细节等方面来介绍 Swift 在性能上所做的努力。最后,我们也会一起看看编译器处理后的源码,加深我们对于这些优化的理解。

编译器优化

Swift 在编译器优化方面引入了 Whole Module Optimizations 的机制(如上图)。这个机制有什么用呢?

在没有这个机制之前,编译器在编译的时候,针对每个源文件,生成对应的目标文件,然后链接器会将目标文件组合起来,最终生成可执行程序。这个过程就像下图那样。每一个 .o 文件就是一个目标文件,目标文件之间无法相互优化。

我们想像这样一个场景,File1.swift 中定义了如下代码:

func min<T : Comparable>(x: T, y: T) -> T {
    return y < x ? y : x
}

这是一个范型的函数,能够支持各种 Comparable 的类型。然后我们如果在 File2.swift 中用到这个函数,假设我们在 File2.swift 中的代码如下:

func test() {
    let x: Int = 1
    let y: Int = 2
    let r = min<Int>(x, y)
}

编译器在处理时,会将 File1.swift 中的范型函数编译成类似这样的代码:

func min<T : Comparable>(x: T, y: T, FTable: FunctionTable) -> T {
    let xCopy = FTable.copy(x)
    let yCopy = FTable.copy(y)
    let m = FTable.lessThan(yCopy, xCopy) ? y : x
    FTable.release(x)
    FTable.release(y)
    return m
}

这当然能正常工作,但是 Int 其实是一个基本类型,如果整个程序只有这一处使用这个范型函数,编译器完全可以把这个 min 函数定制成一个只支持 Int 的版本,像这样:

func min<Int>(x: Int, y: Int) -> Int {
    return y < x ? y : x
}

但是,如果没有 Whole Module Optimizations,编译器在编译 File1.swift 的时候,无法知晓这个函数在别的文件当中的使用方式,就不敢做这样的优化。而如果开启了 Whole Module Optimizations,编译器知道了所有的信息,就可以把这个范型函数优化成只支持 Int 类型的版本。

类似的优化还有很多,下图是开启 Whole Module Optimizations 之后编译的过程。应该说,Whole Module Optimizations 给予了编译器更多的信息,使得编译器可以做更多的基于全局信息的优化。

内存分配和引用计数优化

在 Objective-C 语言中,所有对象都是引用类型,Objective-C 语言使用引用计数来管理对象的生命期。引用计数是一种古老但是有效的内存管理方式,除了需要注意循环引用问题之外,由于 ARC 的存在,大部分程序员并没有特别感受到引用计数的麻烦之处。

但是不得不说,在 Objective-C 语言中,引用计数代码其实无处不在。虽然你可能没有写一行关于引用计数的代码,但是编译器却为你生成了大量相关的引用计数文件。如果你感兴趣,可以尝试用 IDA 反汇编 Objective-C 语言编写的 App 文件,你就可以看到大量的引用计数代码。

下面一段代码,是我曾经逆向分析过的一个 App 的加密函数,你可以到,在短短的 30 行代码中,除了像 objc_msgSend 这种方法调用的代码,就是大量的 objc_retainobjc_retainAutoreleasedReturnValue,以及 objc_release 的代码。

v39 = objc_msgSend(CFSTR("v1/user?id="), "stringByAppendingString:", v37);
v40 = objc_retainAutoreleasedReturnValue(v39);
v41 = v40;
v42 = objc_msgSend(v49, "operationWithPath:params:httpMethod:", v40, 0, CFSTR("POST"));
v43 = v37;
v44 = (void *)objc_retainAutoreleasedReturnValue(v42);
v58 = (int)&_NSConcreteStackBlock;
v59 = -1040187392;
v60 = 0;
v61 = sub_5D478;
v62 = (int)&unk_2E2130;
v63 = objc_retain(v11, sub_5D478);
v51 = (int)&_NSConcreteStackBlock;
v52 = -1040187392;
v53 = 0;
v54 = sub_5D500;
v55 = (int)&unk_2E2150;
v56 = objc_retain(v48, &unk_2E2150);
v57 = v41;
v45 = objc_retain(v41, &selRef_addCompletionHandler_errorHandler_);
objc_msgSend(v44, "addCompletionHandler:errorHandler:", &v58, &v51);
objc_msgSend(v49, "enqueueOperation:", v44);
objc_release(v57);
objc_release(v56);
objc_release(v63);
objc_release(v45);
v38 = (int)v44;
v37 = v43;

这么多的引用计数操作对性能有没有影响?当然有影响!实际上,为了线程安全,每一个对象的引用计数操作,都伴随着锁(Lock)的操作,而这些操作其实都是非常费时的。我们来看下面一个例子:

var array: [TangQiao] = ...

for t in array {
    // increase RC
    // decrease RC
}

假设在这个例子中,我们的类型 TangQiao 是一个 class 类型。那么如果你要遍历这个数组中的每个元素,编译器就会在循环体的内部,对于每一个遍历的元素加上引用计数的增加和减少操作,这其实是特消耗性能的。

所以,大家知道,Swift 引入了值类型,即:struct。struct 有着许多优点,其中一个重要的优点,就是 struct 是不需要引用计数管理的。所以如果你把 TangQiao 这个类改成 struct 类型,那么在遍历数组的过程中,所有的引用计数代码就会从编译器中消失。

但是 struct 的使用也有要注意的地方,如果我们的 struct 中含有大量的引用类型成员,那么在变量复制时,也可能千万大量的引用计数操作。以下是一个例子:

struct TangQiao {
    var website = NSURL(string: "http://blog.devtang.com")
    var name = NSString(string: "tangqiaoboy")
    var addr = NSString(string: "address")
}
var x = TangQiao()
var y = x

在这个例子中,因为 x 变量的三个成员变量都是引用类型,所以其创建时,内存布局会是如下这样:

而当我们调用 y = x 时,由于 x 被复制,所以相关的引用成员的引用计数都需要 +1,内存布局会更新成这样:

这当然是不太能被接受的事情,如果你的 struct 里面有着大量的引用类型的成员,这将意味着你不但无法避免引用计数,而且每次参数传递或复制时都会带来大量的引用计数操作。

对于这种情况,一种有效但是略显丑陋的办法是:引用一个封装类,把这些引用类型的成员再封装一层,像如下这样:

struct TangQiao {
    var member: TangQiaoWrapper = TangQiaoWrapper()
}

class TangQiaoWrapper {
    var website = NSURL(string: "http://blog.devtang.com")
    var name = NSString(string: "tangqiaoboy")
    var addr = NSString(string: "address")
}

var x = TangQiao()
var y = x

有了上面这个 TangQiaoWrapper 类,当发生对象复制时,内存布局中只有 TangQiaoWrapper 的引用计数会变化,其它的引用计数则不会变化,如下图所示:

方法调用优化

从刚刚我们看到的反汇编代码中,我们可以看到,所有的 Objective-C 语言的方法调用,其实都是向相应的对象「发消息」,所以编译之后,都是变成了调用 objc_msgSend 函数,objc_msgSend 函数接受的第一个参数是接受消息的对象,第二个参数是消息的名字,之后接的是消息所带的参数,参数可能有 0 个或多个。

到现在我仍然认为,将函数调用设计成发消息是一个非常有趣的设计,因为它意味着在这种设计上可以为语言本身增加很多动态性的设计。比如:因为消息的名字其实是字符串,所以 Objective-C 语言就可以用变量来传递这个字符串,进而可以实现一些运行时动态调用,语言提供的 NSSelectorFromString 就是协助我们将字符串转换成可以调用的 Selector。另外因为消息调用都是最终到了 objc_msgSend 而不是具体的函数地址,所以这也使得 Objective-C 可以支持方法的动态替换,这些动态性使得 Objective-C 语言变得异常灵活,所以才会出现像 JSPatch 这样优秀的动态补丁下发框架。

但是,这种调用方式因为不是将函数地址硬编码到代码中,所以在调用时,Objective-C 语言需要查表才能够获得真实的调用地址。在苹果的这篇 官方文档 中,苹果详细介绍了整个方法调用时,函数地址的查询过程。苹果也发现这样调用起来很慢,所以为了加速,它会缓存下来方法调用的查询结果,但是即使这样,相对于直接的调用,性能上肯定还是会有一些折扣。

Swift 语言在设计的时候直接放弃了 Objective-C 语言的这个机制。在这一点上,Swift 算是和其它流行的编程语言保持了一致。可以明确的是,这样肯定会有性能上的提升。不过与此同时,Swift 确实也失掉了极大的动态特性,动态修改类的成员函数实现在语言层面上就被封禁死了。我相信在未来 Swift 语言还是会引入不少动态特性,不过这肯定不是 Swift 语言当前的首要目标。

面向协议编程的实现

Swift 鼓励大家使用值类型,也鼓励大家使用协议。但是,这里面有一个不得不考虑的问题需要解决:如何将不同的值类型但是实现了同一个协议的实例,放到同一个数组当中?我们来看一个例子:

protocol Drawable {
    func draw()
}

struct Point: Drawable {
    var x: Int
    var y: Int
    init() {
        x = 1
        y = 2
    }
    func draw() {
        print("Point draw")
    }
}

struct Line: Drawable {
    var x1: Int
    var y1: Int
    var x2: Int
    var y2: Int
    init() {
        x1 = 5
        y1 = 6
        x2 = 7
        y2 = 8
    }

    func draw() {
        print("Line draw")
    }
}

let a: Drawable = Point()
a.draw()

let b: Drawable = Line()
b.draw()

let arr: [Drawable] = [a, b]

在这个例子中,我们定义了一个协议 Drawable,然后值类型 PointLine 都实现了这个协议。在代码的最后,我们申明了一个 [Drawable] 的数组,将 PointLine 的实例都放到了同一个数组中。因为 Point 实例的大小是 2 个 word(每个 word 8 字节),Line 实例的大小是 4 个 word,所以最后我们定义的 Drawable 数组就面临一个挑战:它需要把不同大小的元素放到同一个数组中。

这有什么问题呢?这表示我们无法很方便地定位元素。假如我们的数组真的是把不同大小的元素放到一个数组里面,那就意味着,如果我们想定位到第 i 个元素,我们需要把第 0 ~ i-1 个元素的大小都算出来,这样还可以算出第 i 个元素的内存偏移量。而如果每个元素的大小都是固定的,我们只需要用元素大小乘以 i,就可以算出偏移量来了。

苹果想了一个办法来解决上面提到的问题:使用一个额外的容器(Container)来放每个带有协议的值类型,而数组里面放的,其实是每一个固定大小的容器。下面我就给大家介绍一下详细的实现细节。

这个额外的容器是一个值类型,大小一共是 5 个 word。上图是该结构示意图,其中:

  • 前 3 个 word 叫做 Value Buffer,是用于存放元素的值。
  • 第 4 个 word 叫做 Value Witness Table,用于存放该值类型的创建,复制,回收时的函数地址。
  • 第 5 个 word 叫做 Protocol Witness Table,用于存放协议(Protocol)对应的函数的实现函数地址。

我们知道这个结构中 Value Buffer 一共是 3 个 word,所以如果数组元素存放的是像 Point 这种实例的话,是可以直接存放进去的,就像下图这样。

但是,如果是存放像 Line 这种大于 3 个 word 大小的实例的话,Value Buffer 就不够用了,于是,Swift 会另外在堆中申请一块内存,将值复制过去,然后将这块内存的地址保存到 Value Buffer 的第 1 个 word 中,就像下图这样。

最终,这种设计使得:

  • 数组中每个元素的大小都是固定的 5 个 word,解决了数组元素下标快速定位的问题。
  • 因为有 Value Buffer 的存在,我们可以将不同大小的值类型存放到 Value Buffer 中,小于等于 3 个 word 的值直接存储,更大的则通过保存引用地址的方式存储。
  • 通过 Value Witness Table,我们可以找到这个值类型的相关生命周期的管理函数。
  • 通过 Protocol Witness Table,我们可以找到协议的具体实现函数的地址。

我们来看一个具体的例子,下图是一段使用值+协议来传递参数给函数的例子。

func drawACopy(local : Drawable) {
   local.draw()
}
let val : Drawable = Point()
drawACopy(val)

这段代码在编译器看来,会改写成如下这样,首先,编译器会生成一个 Container 的 struct:

struct ExistContDrawable {
   var valueBuffer: (Int, Int, Int)
   var vwt: ValueWitnessTable
   var pwt: DrawableProtocolWitnessTable
}

正如我们刚刚所说,这个 Container 一共 5 个 word。分别存放 Value Buffer,Value Witness Table 和 Protocol Witness Table。接着,这段代码会被改写成这样:

func drawACopy(val: ExistContDrawable) {
   // 创建容器
   var local = ExistContDrawable()
   // 设置 Value Witness Table 和 Protocol Witness Table
   let vwt = val.vwt
   let pwt = val.pwt
   local.vwt = vwt
   local.pwt = pwt
   // 利用 Value Witness Table 中的函数来赋值
   vwt.allocateBufferAndCopyValue(&local, val)
   // 利用 Protocol Witness Table 来调用 draw 函数
   pwt.draw(vwt.projectBuffer(&local))
   // 利用 Value Witness Table 中的函数来释放内存
   vwt.destructAndDeallocateBuffer(&local)
}

从注释中可以比较清楚地看到,两个 witness table 很好地协助了值类型进行赋值和函数调用。

用 LLDB 来观察 Container 的内存布局

我们可以用 LLDB 的相关执令,在运行时 dump 出变量的内存布局,以便验证 Container 和 Witness table 的存在。

在介绍过程之前,我先介绍几个实用的 LLDB 命令。限于篇幅和重点,这里只做简单的介绍,详细的可以翻查 LLDB 的官方文档

  • breakpoint set -a address,该命令可以在指定位置设置断点,设置之后可以使用 continue 来运行到断点处。
  • x -s8 -c5 -fx address,该命令可以读出 address 所指向地址的内存。
  • di -s address -c 10,该命令可以反汇编 address 所指向的地址开始的汇编代码。
  • re r rdi rsi rdx rcx rax,该命令可以输出相关寄存器的值以及值表示的内容。

好了,接下来我们看看实验用的代码:

protocol Drawable {
    func draw()
}

struct Point: Drawable {
    var x: Int
    var y: Int
    init() {
        x = 1
        y = 2
    }
    func draw() {
        print("Point draw")
    }
}

struct Line: Drawable {
    var x1: Int
    var y1: Int
    var x2: Int
    var y2: Int
    init() {
        x1 = 5
        y1 = 6
        x2 = 7
        y2 = 8
    }

    func draw() {
        print("Line draw")
    }
}

func outputArray(_ arr: [Drawable]) {
    print("output array");
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let a: Drawable = Point()
        a.draw()

        let b: Drawable = Line()
        b.draw()

        let arr: [Drawable] = [a, b]
        outputArray(arr)
    }
}

在这段代码中,我们定义了一个名为 Drawable 的 protocol,然后创建了一个 Drawable 的数组实例,然后我们将代码在 outputArray(arr) 处设置断点,然后运行我们的代码。

为了方便我们观察反汇编的代码,我们可以将 Assistant Editor 的显示切换成显示汇编码,如下所示:

观察汇编码,我们发现真实的函数调用其实在断点之后,如下图所示:

所以,我们先使用刚刚介绍的 breakpoint 命令,执行 breakpoint set -a 0x101b1a244,设置断点在那个位置,然后执行 continue,如下所示:

需要介绍的是,在函数调用时,$rdi 寄存器会保存参数的值,所以我们可以用 x 命令将 $rdi 寄存器地址的 dump 读出来。这样我们得到的结果如下:

(lldb) x -s8 -c20 -fx $rdi
0x6000000c9760: 0x00007fe434f01358 0x0000000200000008
0x6000000c9770: 0x0000000000000002 0x0000000000000004
0x6000000c9780: 0x0000000000000001 0x0000000000000002
0x6000000c9790: 0x0000000000000000 0x0000000101b1e2b8
0x6000000c97a0: 0x0000000101b1e200 0x0000600000031100
0x6000000c97b0: 0x0000000000000000 0x0000000000000000
0x6000000c97c0: 0x0000000101b1e388 0x0000000101b1e208
0x6000000c97d0: 0x0000000000000000 0x0000000000000000
0x6000000c97e0: 0x0000000000000000 0x0000000000000000
0x6000000c97f0: 0x0000000000000000 0x0000000000000000

这里的前 4 个 word 是数组的结构,接下来从 0x6000000c9780 开始的 5 个 word 即为第一个值,它的值为

0x6000000c9780: 0x0000000000000001 0x0000000000000002
0x6000000c9790: 0x0000000000000000 0x0000000101b1e2b8
0x6000000c97a0: 0x0000000101b1e200

然后紧接着的是第二个值,它的内容是:

0x6000000c9780:                    0x0000600000031100
0x6000000c97b0: 0x0000000000000000 0x0000000000000000
0x6000000c97c0: 0x0000000101b1e388 0x0000000101b1e208

很有意思的是,我们就可以很清晰地看到,第一个容器的 Value Buffer 中放的1 和 2 刚好是 Point 的值,而第二个容器的 Value Buffer 中只有第一个变量有值,后面为空,我们猜测这就是 Line 在堆中的地址。我们可以继续用 x 命令将这个地址的值 dump 出来检查,结果如下:

(lldb) x -s8 -c4 -fx 0x0000600000031100
0x600000031100: 0x0000000000000005 0x0000000000000006
0x600000031110: 0x0000000000000007 0x0000000000000008

我们顺利从这个地址中读到了 Line 的值:5,6,7,8,印证了我们的想法。

我们还可以用 di 命令来反汇编这两个容器的第 5 个 Word,看是否是 Protocol Witness Table 的地址,结果如下:

(lldb) di -s 0x0000000101b1e200 -c 2
TestSwift`protocol witness table for TestSwift.Point : TestSwift.Drawable in TestSwift:
    0x101b1e200 <+0>:  addb   %bl, 0x101(%rcx,%rsi,4)
    0x101b1e207 <+7>:  addb   %dl, (%rax)

(lldb) di -s 0x0000000101b1e208 -c 2
TestSwift`protocol witness table for TestSwift.Line : TestSwift.Drawable in TestSwift:
    0x101b1e208 <+0>:  adcb   %bl, 0x101b1(%rdi)
    0x101b1e20e <+6>:  addb   %al, (%rax)

从输出结果中 LLDB 给我们的提示信息来看,我们顺利找到了相关的函数地址。

总结

Swift 语言内部使用了大量创新的设计,使得其在性能上能够相对 Objective-C 语言产生超越,并且它能够很好地支持值类型和面向协议编程。使用 LLDB 相关的指令,可以很方便地帮助我们理解 Swift 对象的内部内存结构,加深理解。

© 著作权归作者所有
这个作品真棒,我要支持一下!
本专栏文章由 @故胤道长、@一缕殇流化隐半边冰霜、@没故事的卓同学、@Onetaway 编辑。关于这本书的任何的意...
3条评论

swift中的协议是不是可以理解成java中的接口

E=mc^2
#2

可以, 但是比接口强大的多

zoofly
#3

可以理解成接口

top Created with Sketch.