Rust是一种新的系统编程语言,它为c提供了一种实用而安全的替代方案。Rust的独特之处在于,它在没有运行时开销的情况下加强了安全性,最重要的是,没有垃圾收集的开销。虽然零成本安全本身就很显著,但我们认为Rust的超级力量超越了安全。特别是,Rust的线性类型系统实现了传统语言(包括安全语言和不安全语言)无法有效实现的功能,这极大地提高了系统软件的安全性和可靠性。我们展示了这类功能的三个例子:零拷贝软件故障隔离、高效静态信息流分析和自动检查点。虽然这些能力长期以来一直是系统研究的焦点,但它们的实际应用受到高成本和复杂性的阻碍。我们认为,随着Rust的采用,这些机制将变得商品化。
几十年来,系统开发人员选择C作为编写低级系统的唯一工具。尽管在编程语言方面有很多进步,但干净的操作系统[3]、管理程序[2]、键值存储[26]、web服务器[30]、网络[6]和存储[38]框架仍然是用C开发的,C是一种编程语言,它在很多方面更接近汇编而不是现代高级语言。
如今,运行不安全代码的代价很高。例如,2017年,公共漏洞和暴露数据库列出了217个在Linux内核[8]中启用特权升级、拒绝服务和其他漏洞的漏洞,其中三分之二可以归因于使用了不安全的语言[5]。这些错误包括在操作系统内核的复杂并发环境中,与对象生命周期、同步、边界检查等复杂细节的低级推理有关的人为错误。更糟糕的是,指针别名、指针算术和不安全类型强制转换的普遍使用使现代系统超出了软件验证工具的能力范围。
为什么我们还用C呢?历史原因是表现。传统上,安全语言依赖于托管运行时,以及特定的垃圾收集(GC)来实现安全性。尽管GC有很多进步,但对于那些被设计为使现代网络链接和存储设备饱和的系统来说,它的开销仍然是禁忌的。例如,要使10Gbps网络链路饱和,内核设备驱动程序和网络堆栈的预算为每1K包835 ns(或在2GHz机器上1670个周期)。由于内存访问延迟为96-146 ns [28], I/O路径允许在关键路径中出现少量缓存丢失——GC的开销是不允许的。
为了性能而牺牲安全是合理的,还是我们应该优先考虑安全并接受它的开销?最近编程语言的发展表明,这可能是一个错误的困境,因为在不牺牲任何一方的情况下,既能实现性能又能实现安全是可能的。通过综合线性类型[41]和语用语言设计的旧思想,取得了突破,导致了锈语[18]的发展。Rust通过一个受限制的所有权模型来强制类型和内存安全,在这个模型中,内存中的每个活动对象都有一个唯一的引用。这允许静态跟踪对象的生命周期,并在不使用垃圾收集器的情况下重新分配它。该语言的运行时开销仅限于数组边界检查,这在大多数情况下可以通过使用迭代器来避免。
在本文中,我们通过论证Rust的优势超越了安全性,从而加强了它作为一种系统编程语言的案例。我们认为Rust的线性类型系统支持传统编程语言中缺失的功能(包括安全的和不安全的)。我们确定了这类功能的三种类型:隔离、分析和自动化。
隔离。软件故障隔离(SFI)在软件中围绕程序模块强制执行类似进程的边界,而不依赖于硬件保护[42]。SFI虽然提高了系统的安全性和可靠性,但难以有效实施。现有的SFI实现不支持通过引用跨保护边界发送数据,因为这使发送方能够维护对数据的访问。因此,需要一个副本来确保隔离,这使得此类解决方案在线速率系统中不可接受。Rust的单一所有权模式允许我们实现零拷贝的SFI。Rust编译器可以确保,一旦指针被跨越隔离边界传递,发送方就不能再访问它。我们的SFI实现(第3节)引入了每个受保护的方法调用90个周期的开销,在正常执行期间运行时开销为零。
分析。编程语言文献描述了许多语言扩展,并关联了强制传统类型安全之外的安全性和正确性属性的静态分析[9,29,39]。例如,静态信息流控制(IFC)通过跟踪通过程序[29]传播的敏感数据来加强机密性。许多这样的分析由于别名的存在而变得复杂,例如,在IFC的情况下,向对象写入敏感数据使得该数据可以通过对象的所有别名访问,因此可以改变多个变量的安全状态。现代混叠分析以牺牲精度为代价来提高效率,这给精确的IFC带来了很大的障碍。通过限制别名,Rust回避了这个问题。我们将在第4节中通过基于精确但可伸缩的程序分析为Rust创建一个IFC扩展原型来说明这一点。
自动化。许多安全和可靠性技术,包括事务、复制和检查点,通过遍历内存中的指针链接的数据结构来内部操作程序状态。对于任意用户定义的数据类型,如果存在别名,那么自动执行该操作可能会很复杂。例如,在检查点期间,对一个对象的多个引用的存在可能会导致创建多个对象副本(图3)。现有的解决方案要求开发人员手动编写检查点代码或修改应用程序以使用特殊的可检查点数据结构库。在第5节中,我们提出一个Rust库,它以一种高效且线程安全的方式向任意数据结构添加检查点功能。
以上所讨论的特性几十年来一直是系统研究的焦点;但其成本高、复杂,阻碍了实际应用。我们认为,随着Rust作为一种系统编程语言的采用,这些机制将变得商品化。我们通过讨论Rust中的SFI、IFC和检查点的原型实现来支持我们的论文。
Rust的优势是以学习一门新语言并将软件移植到它上为代价的,需要处理有限且不断进化的Rust生态系统,以及由于必须遵守Rust有限的所有权模式而增加的设计复杂性。我们相信,这些开销在需要不受损害的安全性和性能的应用程序中是合理的。事实上,我们认为强迫开发人员明确资源所有权是系统编程中的一个很好的实践。与此同时,Rust显然不是快速创建原型、编写脚本和其他非性能关键任务的最佳语言。我们希望我们在本文中所做的观察将有助于Rust在系统程序员的工具包中找到合适的位置。
Rust的设计基于对线性类型[41]、仿射类型、别名类型[43]和基于区域的内存管理[40]的大量研究,并受到诸如sing#[16]、Vault[17]和Cyclone[23]等语言的影响。在Rust之前,Singularity OS[16]将线性类型引入了系统研究。Singularity的sing#语言支持一种混合类型系统,在这种系统中,通过为每个进程分配自己的垃圾收集堆,传统类型被用来强制进程之间的软件故障隔离。线性类型专门用于通过共享交换堆进行零复制的进程间通信。在第3节中,我们提出了一个使用线性类型实现隔离和通信的解决方案。
Rust通过其所有权模型支持自动内存管理,而无需垃圾收集器。当一个变量绑定到一个对象时,它获得对象的所有权。当变量超出作用域时,对象将被释放。或者,所有权可以转移到另一个变量,从而破坏原始绑定。也可以在不破坏绑定的情况下临时借用对象。借的可见性仅限于声明它的语法作用域,不能超过主绑定的作用域。下面的代码片段说明了Rust的所有权模型:
单一所有权消除了指针的别名,使它不可能实现纯Rust中的双链表这样的数据结构。该语言提供了两种机制来弥补这一限制。首先,Rust嵌入了一个不安全的子集,它不受单一所有权限制的约束,并且被用于实现Rust标准库的部分内容,包括链表。本文中描述的技术依赖于Rust类型系统的属性,该属性只适用于该语言的安全子集。在本文的其余部分中,我们假设不安全代码仅限于可信库。其次,通过使用引用计数类型Rc或Arc包装对象,Rust支持安全的只读别名。当写别名是必要的,例如,访问共享资源,单个所有权可以通过额外包装对象与互斥类型动态强制。与传统语言相比,这种形式的别名在对象的类型签名中是显式的,它使我们能够以一种特殊的方式处理此类对象,如第5节所述。
随着Rust的成熟,我们现在可以将线性类型应用到广泛的系统任务中。多个项目已经证明Rust适合构建底层高性能系统,包括嵌入式[25]和传统操作系统[10]、网络功能框架[31]和浏览器引擎[35]。虽然这些系统主要利用了Rust的类型和内存安全,有效地将其作为C的安全版本使用,但我们关注的是Rust超越类型和内存安全的能力。
在精神上与我们类似的是Jespersen等人[22]关于Rust的会话类型通道的工作,它利用线性类型来保证编译时遵守特定的通信协议。目前有大量关于安全系统编程语言的研究,包括C语言的安全方言[9,23]以及Go和Swift等替代语言。虽然Rust与这些语言的比较超出了本文的范围,但我们指出,与Rust不同的是,它们都依赖于对自动内存管理的运行时支持,无论是垃圾收集还是普及引用计数。
我们认为Rust能够以比任何主流语言更低的开销实现软件故障隔离(SFI)。SFI在软件中封装了不可信的扩展,而不依赖于硬件地址空间。虽然现代的SFI实现支持低成本隔离,例如浏览器插件[44]和设备驱动程序[15],但它们的开销在需要跨保护边界进行高吞吐量通信的应用程序中是不可接受的。例如,考虑网络处理框架,如Click[24]或NetBricks[31],它们通过过滤器管道转发数据包。安全性和容错考虑要求将每个管道阶段隔离在自己的保护域中。传统的SFI架构通过将隔离组件发出的内存访问限制在其私有堆中来实现这一点[15,19,44]。跨越保护边界发送数据需要复制,这在线路速率系统中是不可接受的。另一种备选架构[27]使用共享堆,用当前拥有该对象的域的ID标记堆上的每个对象。这避免了复制,但由于对每个指针解引用执行标记验证,导致运行时开销超过100%。
rust使SFI无需复制或标记。类型安全通过确保软件组件只能访问从内存分配器获得的对象或由其他组件显式授予它的对象,从而为SFI提供了基础。此外,Rust的单一所有权模型强制要求,在将对象引用传递给函数或通道后,调用者将失去对对象的访问权,因此既不能观察也不能修改其他组件拥有的数据(Rust允许的安全只读共享除外)。
一个完整的SFI解决方案所缺少的是一个管理平面,通过清理和恢复失败的域来控制域生命周期和通信,对跨域调用执行访问控制策略等。我们演示了如何将这些机制作为一个库在Rust中实现。我们的实现很简单,因为它依赖于Rust的固有功能。我们的结果的意义在于,它提供了一个建设性的证据,证明Rust能够实现故障隔离,包括跨越隔离边界的安全通信,而开销可以忽略不计。据我们所知,这是任何编程语言中第一个演示这些属性的SFI实现。
我们的SFI库导出两种数据类型:保护域(pd)和远程引用(references)。所有pd使用一个公共堆来分配内存;然而,它们不共享任何数据。pd只通过对refs的方法调用进行交互。远程调用的参数和返回值遵循通常的Rust语义:在调用期间,目标PD可以访问借来的引用;所有其他参数将永久改变它们的所有权。唯一的例外是远程引用:rref所指向的对象停留在其原始域中,只能通过远程调用从持有该引用的域访问。
rref被实现为智能指针(图1)。当创建一个rref时,原始对象引用存储在与域相关的引用表中。此引用充当远程调用的代理。返回给用户的rref包含指向引用表的弱指针[12]。弱指针不能阻止它所指向的对象被撤销,必须在使用前升级为强指针。通过引用表代理远程调用使域的所有者能够完全控制其接口和生命周期。
例如,它们可以拦截细粒度访问控制的远程调用,或者通过从引用表中删除远程引用的代理来完全撤销远程引用。在后一种情况下,将来试图调用rref将无法升级弱指针,并将返回一个错误。下面的清单说明了域和参考文献的使用:
通过清除引用表,可以自动释放域拥有的所有内存和资源。我们使用这种机制来实现故障恢复。当域内发生恐慌(例如,由于边界检查或断言违反)时,我们首先将调用线程的堆栈展开到域入口点[11],并向调用者返回一个错误代码。接下来,我们将清除域引用表,最后运行用户提供的恢复函数,从干净状态重新初始化域。恢复过程可以重新填充引用表,从而使故障对域的客户机透明。
我们的SFI实现引入了通过代理间接调用的开销。此外,我们使用线程本地存储[7]来存储当前保护域的ID。我们在运行在8核Intel Xeon E5530 2.40GHz服务器上的NetBricks网络功能框架[31]上下文中评估了这一开销。NetBricks是在Rust中实现的,执行性能与优化的C框架相当。它从DPDK[6]中按用户定义的大小批量检索数据包,并将它们提供给管道,管道在开始下一个批次之前处理该批次。批处理通过函数调用在管道阶段之间传递。NetBricks利用线性类型来确保任何时候只有一个管道阶段可以访问批处理。当Panda等人把这种机制称为故障隔离时,NetBricks不支持故障遏制或故障恢复。
我们使用SFI库在单独的保护域中隔离每个管道组件,用远程调用替换函数调用。我们通过构建一个空过滤器管道来衡量隔离的成本,该管道转发成批的包,而不对它们做任何工作。我们改变管道的长度和每个批次的包的数量,并测量处理一个批次的平均周期数有和没有保护。两者之间的差异除以管道长度给我们提供了远程调用比常规函数调用的开销。我们发现这个开销与管道长度无关,因此图2显示了长度为5的结果。开销从1包批的90个CPU周期增长到256包批的122个周期,这大约是2或3次L3缓存访问的成本。我们将这种增长归因于更高的缓存压力,因为从DPDK中检索更多的包。为了更好地理解这些数字,我们将它们与现实的、轻量级的网络功能中批处理的成本进行比较——磁悬浮负载均衡器[13]的NetBricks实现,它被证明与优化的C版本[31]具有竞争力。对于大于32个包的批,隔离的开销可以忽略不计(低于1%)(图2)。最后,我们通过在null-filter中模拟恐慌,并测量捕获它、清理旧域和创建新域所需的时间,来衡量恢复成本。恢复平均需要4389个周期。
我们认为Rust能够实现精确和有效的静态信息流控制(IFC)。IFC为不受信任的模块提供了强大的安全保障,确保它们不会通过未经授权的通道泄漏敏感数据[29,45]。为此,程序输入被分配了安全标签。程序输出通道也被分配标签,这绑定了通过通道发送的数据的保密性。编译器或验证器跟踪敏感数据在程序中的流动,方法是在每个表达式的结果中加入参数的标号上限,最终证明程序遵守通道边界。此检查必须静态执行,以避免运行时验证的开销,并防止运行时未采用的程序路径导致泄漏。
我们用一个缓冲区的示例实现来说明IFC背后的思想,该缓冲区提供了用于追加和打印其内容的方法:
下面的程序创建一个空缓冲区,并将一个秘密值和一个非秘密值附加到它(我们引入了一种新的Rust注释,将安全标签附加到变量上)。然后它尝试打印缓冲区的内容:
println!()宏将数据输出到一个不受信任的终端;因此,它只允许非机密参数(相应的注释没有显示)。该程序违反了这个约束,将敏感数据写入存储,然后试图将其打印出来。这种违规可以通过有效的静态分析检测到,该分析跟踪进出缓冲区的数据流。具体来说,在第15行中,缓冲区的内容被污染为secret,这将触发第16行中的错误。
在传统的程序设计语言中,指针混叠使信息流分析变得复杂。我们通过在第17行中为上面的程序引入一个更微妙的漏洞来演示这一点。注意,我们的缓冲区实现使用从客户端接收到的第一个值向量在内部存储数据(第6行),然后向其追加新数据(第7行)。我们利用这种行为,首先向空缓冲区写入一个非秘密向量(第14行),将秘密数据附加到缓冲区(第15行),最后打印出非秘密向量的修改内容,它现在将缓冲区中的秘密数据别名化(第17行)。因此,程序不是直接将敏感数据写入未经授权的输出通道,而是为对象创建一个别名,直到对象通过不同的别名获得敏感数据,然后通过原始别名泄漏数据。
Rust通过设计来防止这种利用,因为它们侵犯了单一所有权。在本例中,第17行被编译器拒绝,因为它试图访问nonsec变量,该变量的所有权在第14行中转移到了append方法。相比之下,在常规语言中检测此类泄漏需要跟踪所有指针别名,并将通过一个别名对安全标签所做的任何更改反映给所有其他别名。别名分析在理论上是不可确定的,在实践中很难有效地进行而不损失精度。
别名分析的另一种选择是安全类型系统,其中对象的类型包括其不能更改的安全标签,使别名安全[29]。在我们的示例中,将低安全性类型赋给非机密vector,将高安全性类型赋给buf变量,在第6行提示编译器拒绝向后者写入前者的别名的尝试。相反,我们必须分配一个新的vector对象,并将v实参的内容复制过来。虽然基于类型的方法支持快速编译时分析,但它引入了额外的内存分配和复制开销,这在行速率系统中可能是不可接受的。
通过消除混叠,Rust能够进行有效的静态信息流分析,同时允许安全标签在运行时更改。我们将IFC表述为对程序[34]的抽象解释的验证问题。我们用它的安全标签表示抽象域中每个变量的值。输入变量使用用户提供的标签进行初始化。安全值上的算术表达式是通过计算其参数的上界抽象出来的。引入辅助程序计数器变量,通过标记变量上的分支来跟踪信息流。我们验证生成的抽象程序,以确保写入输出通道的标签不会超过用户提供的通道边界。我们的方法类似于Zanioli等人的[45],但没有昂贵的别名分析步骤。
我们已经为Rust实现了一个最小的概念验证IFC。我们的原型依赖于Rust宏来将程序转换为它的抽象解释。目前还没有Rust的专用验证器。因此,我们使用Rust前端的早期版本扩展了SMACK验证器[32]。SMACK验证工具链构建在Rust也使用的LLVM编译器基础设施之上。因此,通过对Rust程序验证的初步支持来扩展SMACK是相对简单的。
我们在Rust中实现并验证了一个简单的安全数据存储,它代表多个客户端存储数据,同时防止非特权客户端读取属于特权客户端的数据。安全标签边界是在示例程序中通过使用断言指定的。作为完整性检查,我们在实现中的安全访问检查中植入了一个bug。SMACK发现了被注入的错误,从而增加了我们对验证过程的信心。即使没有别名分析,验证对于大型程序来说也是昂贵的。进一步的改进可以通过组合推理实现:在没有混叠的情况下,每个函数对安全标签的影响仅限于其输入参数,并可以通过分析函数的代码与程序的其余部分隔离来总结。
许多提高系统性能和可靠性的技术都取决于自动操作内存中的程序状态的能力。特别是检查点[14]、事务[20,21,33]、复制[36,37]、多版本并发[1,4]等,都涉及到程序状态的快照部分。这又需要遍历内存中的指针链接的数据结构。理想情况下,人们希望为任意用户定义的数据类型自动生成该功能。然而,在存在混叠的情况下,以一种健壮的方式进行操作可能会很复杂。
例如,考虑一个检查网络防火墙状态的任务,该网络防火墙由基于包头的快速规则查找尝试索引的规则组成(图3a)。try的多个叶子可以指向同一规则,导致在指针遍历期间多次遇到该规则,潜在地导致规则的冗余副本,如图3b所示。在常规语言中,为了避免这种情况,必须记录遍历期间到达的每个对象的地址,并根据记录集检查新遇到的对象。这有一个明显的缺点,即增加检查点的CPU和内存开销。另一个复杂问题与外部指针对对象部分进行别名有关。这样的指针并不拥有它们所指向的数据,在指针遍历过程中必须以特殊的方式进行处理,例如,事务系统可以将指针的目标添加到事务集。然而,传统语言没有提供标识此类指针的方法。
Rust极大地简化了这个问题:默认情况下,Rust中的所有引用都是它们所指向对象的唯一所有者,并且可以安全地遍历,而不需要额外的检查。当存在混叠时,混叠在对象的类型签名中是显式的:只有在引用计数类型(Rc, Arc)中包装的对象才能被混叠。因此,Rc和Arc包装器为处理混叠提供了一个方便的地方,只需要对用户代码进行最小的修改,而无需进行昂贵的查找。
为了支持这种观察,我们为Rust实现了一个自动检查点库。我们开发了一个特征(特征类似于Java接口),称为Checkpointable,有两个方法:checkpoint()和restore()。我们引入了一个编译器插件,它归纳地为由标量值和对其他检查点类型的引用组成的类型生成这个trait的实现。接下来,我们提供一个自定义实现的Checkpointable Rc(弧同样可以扩展),它将第一次的内部标志设置检查点()对象和检查这个标志,以避免创建额外的副本,当图遍历对象通过一个不同的别名。我们的库为任意用户定义的数据类型添加了检查点功能;特别是,它正确而有效地检查具有内部别名的对象
Rust代表了语言设计领域中的一个独特点,为无法负担垃圾收集成本的系统带来了类型和内存安全的好处。我们探索Rust除了安全之外的好处。我们展示了Rust使系统程序员能够实现强大的安全性和可靠性机制,如SFI、IFC和自动检查点,这比任何常规语言都更有效。这只是冰山一角:我们相信在真实系统的背景下对线性类型的进一步探索将产生更多改变游戏规则的发现。
一个有希望的方向是形式验证,包括自动验证和用户引导验证。别名分析是软件分析中复杂性和不精确性的主要来源。Rust减轻了验证器解决内存混叠的负担,使验证更快更准确。这在系统中有许多应用程序,从经过验证的内核扩展到完全验证的管理程序、嵌入式操作系统等等。