下载本文的 PDF 版本 PDF

一个新的 Objective-C 运行时:从研究到生产

向后兼容性始终胜过新功能。


David Chisnall,剑桥大学


在我离开学术界,在剑桥说服我回来之前,我写的最后一篇论文是描述一个新的 Objective-C 运行时,供 Étoilé 项目使用。1 一个 Objective-C 实现需要两个组件:一个运行时库,它实现了语言的动态部分;以及一个编译器,它发出对这个库的调用。

当我在 2009 年写那篇论文时,Objective-C 有两个选择:苹果的堆栈,它有许多新功能,但其实现依赖于一些专有功能和仅随 Darwin 提供的功能;以及自由软件基金会维护的 GCC(GNU 编译器集合)堆栈。GCC 实现有几个限制,尤其是许可证,它实际上阻止运行时库与 GCC 以外的任何编译器一起使用(这种情况在后来的版本中得到了解决)。

Étoilé 的目标之一是任何程序都不应包含超过 1,000 行不可重用的代码。这通常需要来自领域特定语言的大量帮助,而这正是我花费大部分时间的研究领域。我已经在 GCC 运行时之上实现了一个概念验证的 Smalltalk 编译器,但它有一些限制。Smalltalk 是一种简单的语言——在一个赌注下创建的,即可以在一张纸上完全指定一种有用的语言——其对象模型类似于 Objective-C。因此,Smalltalk 实现提供了一个相对简单的演示,表明可以使用共享对象模型来支持其他语言。

我们希望直接且无需任何桥接,使用相同的底层对象模型来支持基于原型的语言,例如 Io 或 JavaScript。这项工作受到了 Viewpoints Research Institute 的 COLA(组合对象 Lambda 架构)模型的启发。2

快进几年,Étoilé 现在正在使用 GNUstep Objective-C 运行时,许多其他项目,包括社区开发和商业项目,也在使用它。这不是我在 2009 年的论文中描述的那一个。我们是如何走到这一步的,以及在将项目中的想法付诸实践并构建可以在生产中使用的东西时,需要做出哪些妥协?

向后兼容性不仅仅是王者。它是整个朝廷

正如您从这样的项目中期望的那样,Étoilé 运行时的目标之一是描述系统在没有遗留约束的情况下会是什么样子。其愿望是支持 Objective-C 中的所有源代码语言构造,而无需担心维护二进制兼容性。

这种方法在开发人员中非常受欢迎,但在那些负责分发二进制文件的人中则不太受欢迎。大多数 Objective-C 代码链接到多个框架(库);要求所有这些框架都被重新编译以升级其中一个框架是不受欢迎的。

苹果公司设法进行了一次打破世界的 ABI(应用程序二进制接口)更改——即使发布了所有自有框架的更改前和更改后版本,也花了三个主要的操作系统版本才让每个人都更新到新的 ABI。对于那些没有牢牢掌握整个生态系统的人来说,全新的 ABI 是不可能的。

因此,我们决定从头开始使用 GNUstep Objective-C 运行时,实现与 GCC 运行时相同的 ABI(和 API),但逐步添加 Étoilé 运行时的功能。有些功能可以添加,有些则不行。

GNUstep 运行时是 Étoilé 运行时的精神继承者,并共享一些代码,但它被设计为 GCC 运行时的直接替代品,因此保留了向后兼容性。

对象模型

由于需要保持二进制兼容性,GNUstep Objective-C 运行时无法采用与 Étoilé 运行时相同的对象模型。Étoilé 运行时从基于原型的模型开始,然后在顶部分层类。对基于原型的语言(包括 JavaScript 和 Self)的实验表明,这种级别的灵活性在运行时层可能不是必需的。给定一种灵活的基于原型的语言(例如 JavaScript),典型的程序员做的第一件事是在顶部实现一个不太灵活的基于类的模型。大量的 JavaScript 框架提供了现成的类模型,以使 JavaScript 开发人员的生活更轻松。Google 的 Dart——一种被设计为 JavaScript 后继者的语言——回归到基于类的模型作为核心模型,这表明其设计者发现基于类的模型对于大多数 JavaScript 程序员来说更容易。

更有趣的是,人们实际利用原型全部功能的地方往往相对较少,而且不在性能关键的代码中——例如,为用户界面对象创建一次性委托。这与苹果 Newton 团队的发现相符,该团队建议对模型使用基于类的语言,对视图使用基于原型的语言,从而消除了对控制器的需求。

Self VM(虚拟机)和最近的 Google V8 JavaScript VM 都使用了隐藏类转换。这种技术将基于原型的模型映射到基于类的模型。考虑到这一点,GNUstep 运行时协助希望提供基于原型模型的编译器更有意义,而不是直接提供这样的模型。

因此,GNUstep 运行时中的对象模型在很大程度上与传统的 GCC 模型相同,但有一些重要的变化。在传统的 Objective-C 中,与 Smalltalk 中一样,每个对象的第一个实例变量是isa指针,它指向对象的类。在较新版本的 Objective-C 中,直接访问此指针已被弃用,转而调用运行时库函数。这有多种优点,将在本文后面描述,但第一个优点是这意味着您可以使此指针指向其他内容。

运行时支持的隐藏类概念用于支持原型。隐藏类仅在运行时内部可见,因此调用object_getClass()将返回超类。此函数是用户代码查找对象类的受支持方式,并在实现+class方法中使用。

这允许,例如,插入对象的隐藏类并修改方法,因此只有此对象实例而不是任何其他实例获得该方法。运行时还支持克隆函数,该函数创建一个新的对象,其隐藏类继承自原始对象,从而允许差异继承。

隐藏类也用于实现关联引用功能,该功能实际上允许在运行时向对象添加额外的属性。这意味着差异继承可以自动与属性以及方法一起工作。

这些功能允许非常低效但功能齐全的基于原型的面向对象实现。通过少量额外的(静态或运行时)分析,编译器可以删除一些冗余类,并将具有(大部分)相同属性集和相同方法集的对象折叠到单个类的实例中。GNUstep 运行时(尚未)这样做,但 Self 和 V8 VM 做了,所以这是可能的。

方法查找

在任何 Smalltalk 系列语言(如 Objective-C)中,消息发送都分两个概念步骤进行。第一个是从选择器(方法名称)到实现该方法的功能或闭包的映射。第二个是调用该方法。

这些步骤可以通过多种方式组合。在非常静态的语言(如 C++)中,编译器将选择器-类对映射到vtable中的偏移量,然后在调用站点嵌入查找。这是可行的,因为查找只是一些指令。在 GCC 运行时中,顺序是调用objc_msg_lookup(),它将返回一个函数指针,然后调用这个函数指针。NeXT/Apple 运行时将这两个步骤合二为一——调用objc_msgSend()函数。

方法查找性能对于后期绑定的动态语言(如 Smalltalk 和 Objective-C)至关重要。在各种运行时中,通常有 10-20% 的总时间用于执行查找,因此查找性能的微小变化可能非常明显。

Étoilé 运行时所做的最大更改之一是消息查找机制。首先,它使每个对象都可以拥有自己的消息查找函数。其次,它使查找函数返回一个槽结构,而不是一个方法。槽结构的目的是使使用无锁算法安全地缓存查找成为可能。

槽包含一个版本字段,每当方法查找失效时,该字段都可以递增。基本更新序列是

1. 查找旧槽。

2. 如果该槽由您正在修改的类拥有,则只需修改该槽,无需缓存失效。

3. 如果不是,则为当前类添加一个新槽并递增旧槽的版本。

在每个缓存的调用站点,您都可以执行以下序列

1. 读取缓存的槽。

2. 从缓存的槽中读取版本。

3. 读取缓存的版本。

4. 比较两个版本,如果需要,执行完整查找。

GNUstep 运行时也支持这种相同的机制,以及一些优化传递,这些优化传递将根据一些启发式方法自动插入缓存。例如,如果您有一个循环,那么编译器将在循环迭代之间缓存循环内的方法查找。测试表明,消息发送的成本降至仅比函数调用成本高出约 50%。缓存检查在 TLB(转换后备缓冲区)和缓存使用方面也比完整查找更便宜,因此微基准测试之外的改进可能会更大。

修改查找

允许对象拥有自己的查找机制的主要动机之一是希望允许多个对象模型共存。实际上,这很少有用,并且每次调用的额外开销是不值得的。可以通过二级分派系统实现类似的机制,其中失败的方法查找调用一个允许转发等的标准方法。但是,我们确实对查找做了一个更改,该更改可以在多种语言之间共享:添加对小型对象的支持。

大多数 Smalltalk 实现都有一个SmallInt类,它将整数隐藏在对象指针内部。新的运行时在 32 位系统上支持一个这样的类,在 64 位系统上支持七个。运行时没有定义这些类的语义,但如果低位不为 0,则方法查找仅从表中加载类。

与为灵活性和理论性能而设计的 Étoilé 运行时不同,GNUstep 运行时的设计考虑了实际的、可衡量的性能。经过一些测试,我们确定值得采用 NeXT 的方法来实现单步objc_msgSend() 函数。这无法在 C 中实现,因为它必须使用传递给它的所有参数调用查找的函数;因此,它必须在汇编中实现。

最初的 GCC 运行时避免了这种情况,因为每个架构和调用约定的组合都需要这种汇编实现。在 90 年代初期,这是一个重大问题,当时这意味着多达 30 种不同的实现。现在 x86、x86-64 和 ARM 占了绝大多数用户,因此为这些平台提供快速路径并为其他平台保留两阶段查找就足够了。可以根据需要添加其他平台。

经验教训

从研究原型(Étoilé 运行时)到发布版本(GNUstep 运行时)的路径涉及完全重写和重新设计。这不一定是坏事:构建原型的部分目的是了解什么有意义,什么没有意义,并调查在您控制整个系统的情况下什么是可行的,但在生产中不一定可行。

最重要的教训是相对较早地发现,无论开发人员声称多么有冒险精神,向后兼容性始终胜过新功能。除非有简单的迁移路径,否则新系统注定要失败。新的运行时可以与使用旧版本 GCC 编译的代码一起工作,但它需要一个新的编译器才能使用更高级的功能。

第二个重要的教训是,虽然通用解决方案对于项目来说很好,但产品通常需要针对通用案例子集的好解决方案。对于 Objective-C 运行时用户来说,更通用的对象模型是一个有趣的奇观,而更通用的对象模型与显着更快的消息发送相结合,是切换的令人信服的理由。

参考文献

1. Chisnall, D. 2009。现代 Objective-C 运行时。《对象技术杂志》8(1): 221-240;www.jot.fm/issues/issue_2009_01/article4/

2. Piumarta, I., Warth, A. 2006。开放的、可扩展的对象模型。Viewpoints Research Institute 技术报告 TR-2006-003-a;http://www.vpri.org/pdf/tr2006003a_objmod.pdf

喜欢它,讨厌它?请告诉我们

[email protected]

DAVID CHISNALL 是剑桥大学的研究员,他在那里从事编程语言设计和实现工作。在完成博士学位并到达剑桥大学之间的几年里,他花了几年时间进行咨询,在此期间,他还撰写了关于 Xen 以及 Objective-C 和 Go 编程语言的书籍,以及撰写了许多文章。他还为 LLVM、Clang、FreeBSD、GNUstep 和 Étoilé 开源项目做出了贡献,并且他还跳阿根廷探戈舞。

© 2012 1542-7730/12/0700 $10.00

acmqueue

最初发布于 Queue 第 10 卷,第 7 期
数字图书馆 中评论这篇文章





更多相关文章

Matt Godbolt - C++ 编译器中的优化
在为编译器提供更多信息方面需要权衡:这可能会使编译速度变慢。链接时优化等技术可以让您两全其美。编译器中的优化不断改进,即将到来的间接调用和虚函数分派方面的改进可能很快会带来更快的多态性。


Ulan Degenbaev, Michael Lippautz, Hannes Payer - 垃圾回收作为合资企业
跨组件跟踪是解决跨组件边界的引用循环问题的一种方法。只要组件可以形成任意对象图,并且在 API 边界之间具有重要的所有权,就会出现此问题。CCT 的增量版本已在 V8 和 Blink 中实现,从而能够以安全的方式有效且高效地回收内存。


David Chisnall - C 不是一种低级语言
在最近的 Meltdown 和 Spectre 漏洞之后,值得花一些时间研究根本原因。这两种漏洞都涉及处理器推测性地执行超出某种访问检查的指令,并允许攻击者通过侧信道观察结果。导致这些漏洞以及其他几个漏洞的功能被添加进来,是为了让 C 程序员继续相信他们正在用低级语言编程,而这种情况已经几十年没有发生了。


Tobias Lauinger, Abdelberi Chaabane, Christo Wilson - 你不应该依赖我
大多数网站都使用 JavaScript 库,其中许多库已知是易受攻击的。了解问题的范围,以及包含库的许多意想不到的方式,只是改进情况的第一步。这里的目标是,本文中包含的信息将有助于为社区提供更好的工具、开发实践和教育工作。





© 保留所有权利。

© . All rights reserved.