Swift如何实现Rust无法实现的动态链接(2019)

2021-02-21 07:48:40

对于那些不关注Swift开发的人来说,ABI稳定性一直是其最雄心勃勃的项目之一,并且可能是其定义功能,它最终在Swift 5中发布了。结果是我发现无休止地令人着迷,因为我认为Swift在没有太多妥协的情况下将ABI稳定性的概念推到了比任何一种语言都更高的水平。

因此,我决定写一堆Swift ABI有趣的高级细节。这不是Swift的ABI的完整参考,而是对其实现策略的抽象介绍。如果您真的想确切知道它如何分配寄存器或名称,请查看其他地方。

同样,出于我为什么要编写此书的上下文,我自然倾向于将Swift和Rust的设计进行比较,因为这是我帮助开发的两种语言。还有一些人喜欢抱怨Rust不会打扰到ABI稳定性,而且我认为研究Swift的功能有助于阐明原因。

本文分为两个部分:背景和详细信息。如果您对产生健壮的动态链接的系统界面所固有的问题非常满意,请随时跳到细节。

如果您不熟悉类型布局,ABI和调用约定的基本概念,建议阅读我撰写的有关类型布局和ABI与Rust相关的基本概念的文章。

同样感谢Swift开发人员回答了我所有的问题并纠正了我的误解!

我知道很多人并不真正遵循Swift,并且如果没有语言背景的某些背景知识,可能很难理解他们的真正成就,因此这里是TL的语言的形状:

强调"值语义"类被可变地共享和装箱(使用ARC),破坏了值的语义(甚至可能导致数据争用)

不必担心完全理解所有这些内容,我们将继续研究真正重要的内容及其含义。

当Swift开发人员谈论" ABI稳定性"他们想到的只有一件事:他们希望MacOS和iOS的本机系统API用Swift编写,并让您动态链接到它们。这包括动态链接到Swift标准库的单个系统范围的副本。

好的,那么什么是动态链接?就我们的目的而言,它是一个系统,您可以根据接口的某些抽象描述来编译应用程序而无需提供接口的实际实现。这会产生一个应用程序,由于缺少其实现的一部分,它本身将无法正常运行。

为了正常运行,它必须告知系统动态链接器其需要实现的所有接口,我们称其为动态库(dylib)。假设一切正常,那么这些实现将与应用程序和“一切正常”挂钩。

动态链接对系统API非常重要,因为它可以在不重新构建在其上运行的所有应用程序的情况下更新系统的实现。只要应用程序符合针对其构建的接口,应用程序就不会在乎它们将获得何种实现。

通过使每个应用程序共享相同的库实现,Apple还可以显着减少系统的内存占用(Apple在其移动设备上非常在乎这一点)。

由于Swift是AOT编译的,因此应用程序和dylib都必须在将它们链接在一起之前很长时间就如何与另一方进行通信做出一系列假设。这些假设就是我们所谓的ABI(应用程序的二进制接口),并且由于它需要在很长一段时间内保持一致,因此ABI最好是稳定的。

因此,动态链接是我们的目标,而ABI稳定性只是达到此目的的一种手段。

如果您可以定义这些详细信息并且永不破坏它们,那么您将拥有稳定的ABI,并且可以执行动态链接。 (忽略将dylib和应用程序同时构建且与ABI稳定性无关的琐碎情况。)

现在要弄清楚,从技术上讲ABI稳定性不是编程语言的特性。它实际上是系统及其工具链的属性。为了理解这一点,让我们看一下历史上ABI稳定性和动态链接的最大拥护者:C。

所有主要的OS都将C用于其动态链接的系统API。由此我们可以得出结论,C具有稳定的ABI。但是这里有个问题:如果您在Ubuntu上编译一些用于动态链接的C代码,那么编译后的工件就无法在MacOS或Windows上运行。哎呀,即使您针对64位Windows进行编译,它也无法在32位Windows上运行!

为什么?因为ABI是平台定义的。甚至没有必要将其记录下来。平台供应商可能只要求您使用恰好实现其稳定ABI的特定编译器工具链。

(事实证明,这实际上是Apple平台上Swift的稳定化ABI的现实。它们并没有得到适当的记录,xcode只是实现了它,开发人员将尽最大努力不破坏它。 #39;不反对记录文档,它只是很多工作,并且可以优先考虑运输。幸运的是,我并不真正关心细节,也不关心MacOS和Windows上的ABI之间的区别。 iOS或Apple以外的其他实现,因此我可以继续说一下Swift的ABI,这不会有问题。)

但是,既然如此,为什么平台供应商不为许多其他语言提供稳定的ABI?事实证明,这里的语言并非完全无关。尽管ABI不是该部分对于C本身,它对这个概念相对友好。不是很多其他语言。

为了理解C为什么对ABI稳定性友好,让我们看一下C不太友好的老大哥。

模板化的C ++函数无法动态链接其实现。如果我为您提供了提供以下声明的系统头,则您将无法使用它:

这是因为它没有符号。 C ++模板是经过单态编译的,这是一种奇特的说法,即使用它们的方法是将所有模板替换为特定值,然后复制粘贴实现。

因此,如果我想调用process< int>(0),则需要有可用int替换T进行复制粘贴的实现。需要在编译时实现的实现完全破坏了动态链接的概念。

现在,也许平台可以保证它已经预编译了几个单态实例,所以可以说process int的符号。和处理< bool>可用。您可以进行这项工作,但是实际上该函数将不再具有意义上的模板化,因为只有这两个明确祝福的替换才有效。

现在,标头可能只包含模板的实现,但真正要保证的是该特定实现将始终有效。头文件的未来版本可能会引入新的实现,但是一个健壮的系统将必须假定应用程序可以同时使用其中之一,甚至可以同时使用两者。

这与C宏或内联函数没有什么不同,但我认为它'博览会说,模板在C ++中有点重要。

例如,大多数平台提供了C标准库的动态链接版本,每个人都使用它。另一方面,C ++' S标准库ISN'动态链接非常有用;它'字面上称为标准模板库!

尽管有这个问题(以及许多其他人),C ++可以动态链接并以ABI稳定的方式使用!它'它只是由于限制,它最终看起来更像是C接口。

惯用毒殖类似于动态链接(它也使用单数化),因此ABI稳定的锈病也将仅最终确切地支持C样界面。 Rust在很大程度上刚刚接受了这一事实,将其关注其他问题。

秘密在于两种语言分歧的地方:活力。 Rust是一种非常静态和明确的语言,反映了其开发人员和早期采用者的敏感性。 Swift'开发人员首选更具动态和隐含的设计,所以它们所做的更具动态和隐式的设计。

事实证明,隐藏实施细节并在运行时执行更多工作对于动态链接非常友好。谁' D' VE IDS IDENT动态连接是动态的吗?

它实际上是相当微不足道的,可以动态链接一个系统,其中所有实施细节隐藏在均匀性和动态之后。在极端情况下,我们可以制作一个系统,其中一切都是不透明的指针,而且只有一个函数,只需发送包含命令的字符串的函数。这样的系统会有一个非常简单的abi!

的确,在90年代,随着Microsoft拥抱COM和Apple拥抱Objective-C作为使用简单而强大的ABI构建系统接口的方法,在这个方向上有了很大的推动。

但是Swift并没有这样做。 Swift尽最大努力来生成与Rust或C ++所期望的代码可比的代码,而它是如何实现的,才使得它的ABI如此有趣。

值得注意的是,Swift开发人员在一个主要方面不同意Rust和C ++代码生成的正统观念:他们更在乎代码大小(如生成的可执行代码数量)。更具体地说,他们更关心有效利用cpu指令高速缓存的原因,因为他们认为这样对系统范围的电源使用效果更好。考虑到苹果公司使用电池供电的设备套件,支持这种担忧在很大程度上是有意义的。

第三方开发人员更不必担心这一点,因为他们自然只会控制设备上运行的软件的一小部分,而典型的基准测试策略并不能真正抓住这一变化。您的应用程序运行速度更快,但使某些后台服务的响应速度降低,并损害了电池寿命。"因此,C ++和Rust不可避免地向着“更多代码,更快”的方向发展。

这就是说,为ABI稳定性着想而做出的一些妥协实际上只是被认为是可取的。

我从来没有从Swift或Foundation员工那里得到任何关于此问题的具体数字,一定会喜欢的!苹果公司员工在阅读这篇文章时大声疾呼。

Swift开发人员在其文档中很好地涵盖了这个主题。我只是给出一个简化的版本,重点放在基本动机上。

弹性是Swift动态链接故事背后的核心概念。这意味着默认情况下,当实现以保留API的方式更改时,ABI可以抵抗破坏(没有东西可以保存破坏API的更改)。这使开发人员可以创建动态链接的和习惯用法的库,这些库仍然可以轻松地扩展其实现。

这与C相反,C仅使创建具有适当警惕性和远见的稳定ABI成为可能。这是因为C要求您预先确定接口的许多ABI详细信息,即使您不确定它们也是如此。如果您不想提交这些详细信息,则必须更改API的形状以隐藏它们。

当编译为dylib时,Swift默认默认隐式隐藏尽可能多的细节,要求您通过添加批注选择加入保证。至关重要的是,这些注释不会影响API的形状,它们只是“”。用于优化ABI,但要以弹性为代价。

此外,可以在发布库后添加一些ABI批注,而不会破坏旧的ABI。使用新注释编译的应用程序能够使用该信息来更快地运行,但要以与旧版本库的兼容性为代价。

(看来,Swift开发人员用完了时间/资源,并在这个部门相当合理地偷工减料。似乎可以以向后兼容的方式完成的一些注释最终被添加了。嗯,pobody nerfect。)

现在让我们说我们意识到该函数还应该提供有关上次修改时间的信息:

//版本2 typedef struct {int64_t last_modified_time; // 64位足够清晰... int64_t size;} FileMetadata; bool get_file_metadata(char *路径,FileMetadata *输出);

糟糕,我们搞砸了我们的ABI!我们的假设调用者正在堆栈中分配FileMetadata,因此他们假设该文件具有特定的大小和对齐方式。此外,他们直接访问size字段,他们假定此字段位于结构中的特定偏移处。

我们的变更违反了这两个假设。这不一定必须发生。我们可以采用几种常见的方法来进行此更改。例如,我们可以有:

不幸的是,所有这些都要求我们具有远见卓识,并且还必须改变用户使用我们的API的方式。从某种意义上说,该API变得越来越"惯用"。以适应未来的变化。此外,即使我们最终确定API足够完整以保证其详细信息,我们也将永远担负这种复杂性。

//版本1 public struct FileMetadata {public var size:Int64} public func getFileMetadata(_ path:String)-> FileMetadata?

//版本2 public struct FileMetadata {public var lastModifiedTime:Int64 //仅添加此字段,它就是public var size:Int64} public func getFileMetadata(_ path:String)-> FileMetadata?

不幸的是,在当前版本中使用@frozen属性保证FileMetadata的布局将是ABI的重大突破。希望在本文末尾会很清楚。

好的!现在,对于细节,我实际上将忽略实际细节,而是讨论其背后的高级想法。

再一次,请随时查看Swift的用于管理abi弹性的注释文档。这涵盖了很多动机以及可以做什么和不能做什么的细节。

默认情况下,由dylib定义的类型具有弹性布局。这意味着该类型的大小,对齐方式,步幅和其他居民并不是应用程序静态知道的。要获取该信息,它必须在运行时向dylib询问该类型的值见证表。

"见证表"是Swift的最终vtables术语。这些表的获取和布局方式的详细信息并不会引起我的兴趣,因此我将不进行讨论。

好的,实际上,有趣的是,Swift需要能够在运行时生成见证表来处理以下事实:面对通用代码的动态链接,无法静态地预测通用类型的替换,但是超越自己。

值见证人表只是您可能想知道的关于任何类型的基本内容的表格,就像使用Java的对象类型一样。因此,它具有所有内容,例如大小,对齐方式,步幅,额外的居民,移动/复制构造函数(用于ARC)和析构函数。

在这一点上,那些具有语言设计经验的人可能会怀疑这导致必须将弹性类型装箱并作为指针传递。这些怀疑确实是正确的……但并非完全如此。

看到弹性布局真正有趣的地方在于,它只是应用程序被迫处理的事情,而且处理方式非常有限。在dylib的边界内,所有它自己的实现细节都是静态已知的,该类型的处理就好像它没有弹性。

在dylib内部,有弹性的结构被内联存储,存储在堆栈中,按值传递,甚至被标量化。但是,一旦我们离开了dylib,就必须做其他事情。

我们可以通过在边界进行昂贵的类型布局转换来实现此目标,但是我们不这样做!弹性边界的两侧的类型布局始终相同!

此处的关键见解是实际上可以相对轻松地动态地进行内联布置。内存分配器和指针并不关心静态布局,它们只适用于完全无类型的大小,对齐方式和偏移量。因此,只要您具有所有相关的值见证人表,一切就可以正常工作,并且具有比平常更多的动态值。

真正的主要问题是堆栈分配。 llvm确实不喜欢动态堆栈分配。是的,alloca确实存在,但是有点混乱。我相信Swift开发人员设法使其始终保持工作状态以实现灵活的布局,但是在下一节中我们不会看到它的一些表亲。在一般情况下,实际上需要将局部变量装箱到堆上。为方便起见,我将这些动态堆栈分配统称为“盒装”。

最重要的是,此框不会更改布局,仅更改局部变量的存储位置以及如何在调用约定中传递局部变量(稍后会详细介绍)。同样,一旦存在某种间接,所有内容仍将以内联方式存储。因此,诸如Array< MyResilientStruct>之类的已经附带了间接性的类型将被添加。或MyResilientClass不需要其他分配,因此不需要更改ABI。

我遗漏了一些关键细节,但是让我们在查看多态泛型时解决了这些关键细节,因为事实证明它们非常相似,但是也更有趣!

与Rust和C ++不同,Rust和C ++必须对每个泛型/模板替换进行单一化(复制+粘贴)实现,Swift能够将泛型函数编译成一个可以动态处理每个替换的实现。

多晶型实施可以' t被系列或优化以及单数(没有JIT),所以Swift编译器仍然单一的东西,当它可能并且似乎有利可图。但是我们'重新制作达达布,所以它的公共API不可能。

事实证明,多态编译的通用代码与处理弹性类型的代码非常相似。在这两种情况下,AREN' T静态名称的基本值 - 目击属性,静态堆叠值需要拳击。通常需要能够找到通用类型和#39; S协议实现。我们可以从类型和#39; S协议见表中获取,可以使用我们使用的相同机器获取的表格见表。

弹性/多晶型机械解决了对象安全问题的大块,这些问题严重限制了生锈' Swift将这些协议称为类型或只是存在的,具体取决于您的要求谁。实际上具有符号的通用代码意味着它没有问题在vtable中填充。弹性布局消除了动态&#34的问题;按值"操纵自我和任何相关类型。

存在性是堆栈分配的真正棘手的案例,因为它们可以防止调用者在拨打呼叫之前了解返回值的大小,并且真的搞砸了AlloCa。因此,一旦存在涉及,AlloCa熄灭了窗户,需要发生实际拳击。

函数签名中的关联类型仍然阻止所存在的存在,因为它会产生与ABI无关的基本类型系统问题。 MyProtocol的每个实例都可以具有不同的相关类型,您可以' t让他们混淆。不,我不会进入如何迅速

......