Rust 和Erlang的对比
在我为期两年的电信网络模拟器的程序员生涯中,我将Erlang的并发性、容错性和分布式计算等特性充分利用到了许多CPU密集型应用程序上。
Erlang是一种高级的、动态的、函数式的语言,它提供了轻量级的流程、不变性、位置透明的分布式、消息传递、监督行为等。不幸的是,它在底层的工作中并不是最理想的,而且显然那也不是它们的主要意图。例如,最典型的用例之一就是XML解析,Erlang并不擅长。事实上,XML节必须从命令行或网络中读取,而处理来自Erlang虚拟机之外的任何东西都是繁琐的工作。你可能也了解这个问题。对于这种情况,不妨考虑一下采用不同的语言。特别是,Rust最近由于其混合特性集而走到了前台,它对Erlang的许多方面都有类似的承诺,并且在底级性能和安全性方面增加了额外的好处。
Rust编译成二进制,并在硬件上直接运行,就像你的C/ c++程序一样。它和C/ c++有什么不同? 很多。它的座右铭是这么说的:“Rust是一款运行速度非常快、可以防止段错误,并确保线程安全的系统编程语言”。
本文将重点讨论Erlang和Rust之间的比较,强调它们的相似和不同,研究Rust 的Erlang开发人员和研究Erlang的Rust 开发人员可能都会对它感兴趣。最后一节将详细介绍每种语言能力和缺点。
不变性
Erlang: 变量在Erlang中是不可变的,一旦绑定不能被改变,也不能被重新绑定为不同的值。
Rust: Rust中的变量默认情况下也是不可变的,但是通过向它们添加mut关键字可以很容易地使其变为可变的。Rust还引入了所有权和借出的概念,以有效地管理内存分配。例如,字符串文字存储在可执行文件中,字符串在分配给其他变量时会转移,而像integer(i32,i64,u32…), float(f32,f64)等原始数据类型直接存储在堆栈中。
模式匹配
Erlang: Erlang代码简洁性的优点在于它的模式匹配功能,在任何地方都可以使用case语句以及”=”(等于号),包括函数名、参数数量和参数本身。
Rust: 在let绑定中,=符号可以用于绑定,也可以用于模式匹配。除此之外,Rust match类似于Erlang中的case语句和大多数其他语言中的switch语句,它尝试在多个case中进行模式匹配,然后分支到匹配到的那个。功能/方法重载不是Rust内置的,但它可以使用特征(trait)。确凿(Irrefutable )的模式匹配任何事物,它们一直都会有效。例如: 在let x=5中,x总是与值5绑定。相反,不确凿(refutable )的模式在某些情况下可能会匹配不到。例如:在if let Some(x) = somevalue 中明确地说somevalue应该处理任何除了None之外的值。 确凿的模式可以直接在一个let绑定中使用,而不确凿的模式可以在if let、while let或者 match 结构体中使用。
循环
Erlang: 在Erlang中可以使用递归或列表推导完成循环。
Rust: 在命令式语言中,循环会以如for、while和loop等常见方式出现,并带有基本的循环结构。除此之外,还有迭代器。
闭包和匿名函数
Erlang: Erlang有匿名函数,可以通过用fun和end关键字将代码块框起来声明。所有匿名函数都是使用当前上下文闭包,而且以在相同节点或其他连接节点上跨进程传递。匿名函数为Erlang分布式机制增加了极大的价值。
Rust: Rust也支持使用匿名函数的闭包。这些还可以“捕获”环境,并可以在其他地方执行(在不同的方法或线程上下文中)。匿名函数可以存储在一个变量中,可以作为函数和跨线程的参数传递。
列表和元组
Erlang: 列表是动态的单向链表,可以将任何Erlang数据类型存储为元素。列表中的元素不能通过索引获得,而必须从头开始遍历(不像Rust中的数组)。元组是固定大小的,在运行时不能改变。它们可以是模式匹配的。
Rust: 与Erlang中的列表类似,Rust有向量(vector)和数组。数组是固定大小的,如果在编译时元素的大小是已知的,就可以使用它。向量是内部链表(类似于Erlang中的列表)。当大小动态变化时使用向量,可以是普通的,也可以是双端的。普通向量是单向的,而双端向量是双向链表,可以在两端同时增长。Rust也有在运行时不能改变的元组。如果函数需要返回多个值,则可以使用元组。元组也可以是模式匹配的。
迭代器
Erlang: Erlang中的迭代器是与列表一起使用的。列表模块提供了各种各样的迭代机制,如map、filter、zip、drop等。除此之外,Erlang还支持列表推导,它使用生成器作为列表,并且可以根据谓词对列表中的每个元素执行操作。结果是另一个列表。
Rust: 向量、双端向量和数组可以由迭代器来使用。在Rust中迭代器默认是懒惰的。除非在末尾有收集器,否则源不会被消耗。与传统的循环约束(如循环等)相比,迭代器提供了一种更自然的方式来使用任何列表数据类型,因为它们从来不会超出范围。
Record和Map
Erlang: Record是在编译时定义的固定大小的结构,而Map是动态的,它们的结构可以在运行时声明或修改。Map类似于其他语言中的hashmap,它们被用作键值存储。
Rust: Rust支持在编译时声明结构体。结构体不能在运行时修改,例如,不能添加或删除成员。由于Rust是一种低级语言,所以结构体可以存储引用。引用需要使用生命周期参数来防止悬空引用。Rust有一个标准的集合库,它支持许多其他的数据结构,比如Map、Set、序列等等。所有这些数据结构也可以惰性迭代。
String、Binary和Bitstring
Erlang: Erlang中的字符串只是在单向链表中存储每个字符的ASCII值的列表。因此,在字符串的开头追加字符总是比在它的末尾要容易。在Erlang中Binary很特殊,它们就像一个连续的字节数组,构成字节(8位序列)。Bitstring(位串)是Binary的特殊情况,它存储不同大小的位序列,例如三个1位序列、一个4位序列等。位串的长度不必是8的倍数。String、Binary和Bitstring支持更高级别的便利语法,使模式匹配更容易。因此,如果您正在进行网络编程,那么打包和解包一个网络协议包是很简单的。
Rust: 在Rust中,有两类字符串。字符串字面值既不是存储在堆上,也不是存储在堆栈上,而是直接存储在可执行文件中。字符串字面值是不可变的。字符串可以有动态的大小。在这种情况下,它们存储在堆上,而其引用保存在堆栈上。如果在编译时字符串是已知的,则它们以文字形式存储,而在编译时未知的字符串则存储在堆中。这是一种有效的方法,可以在编译时识别内存分配策略,并在运行时应用它。
生命周期
Erlang: 变量只绑定在函数的范围内,并由特定于当前进程的垃圾收集器释放。因此, 每个变量的生命周期与使用它的函数相同。也就是说,程序应该尽可能地模块化到函数中, 以便有效地使用内存。此外, 您甚至可以使用特殊的触发器来触发垃圾回收,在需要时调用Erlang: gc ()触发垃圾回收.
Rust: Rust没有垃圾回收Rust 使用生命周期来管理内存。一个范围内的每个变量(用花括号或函数的主体进行分隔)都被赋予一个新的生命周期,如果它不是从父进程中借出或引用的话。变量的生命周期不会在该变量被借用的范围结束时结束,它只在父范围的末尾结束。因此,每个变量的生命周期要么由当前范围管理,要么由父作用域管理,由编译器来确保这一点。在编译过程中,Rust暗自注入代码,以便当该变量的生命周期结束时,除去与变量相关的���。这种方法可以避免使用垃圾收集来确定哪些变量可以被释放。通过在函数内管理生命周期,Rust提供了对内存的细粒度控制。与Erlang函数在功能结束时触发垃圾收集的功能不同,在Rust中,您可以使用{}将您的代码划分为多个范围,而编译器将在每个作用域的末尾放置drop代码。
变量绑定、所有权和借出
Erlang: Erlang有一个简单的绑定方式。如果一个变量之前是未绑定的,那么任何一个变量的出现都会被绑定到右边的值,否则它就是模式匹配的。Erlang中的任何类型都可以绑定到一个变量。变量只绑定在它们出现的函数上下文中,并且在不再使用时由特定于当前进程的垃圾收集器释放。数据的所有权不能转移给不同的变量。如果同一个函数上下文中的另一个变量想要拥有相同的数据,那么它必须克隆这个数据。这符合Erlang的不共享任何东西的理念,并使使用克隆值安全地发送到不同的节点或进程而不进行数据竞争。在 Erlang 中, 没有引用, 因此也没有借用。所有数据都被分配到堆上。
Rust: 所有权和借出是Rust中两个强大的概念,使该语言在主流语言中独树一帜。这也正是为什么Rust被认为是低层次无数据竞争语言的非常重要的原因,这可以在不需要垃圾收集器的情况下提供内存安全,从而保证了最小的运行时开销。数据的所有权属于一个变量,这意味着没有其他变量可以共享该数据的所有权。如果需要的话,所有权被转移到一个不同的变量赋值上,旧变量不再有效。如果将变量作为参数发送给函数,则所有权也会被转移。这种操作称为move,因为数据的所有权被转移了。所有权有助于有效地管理内存。
所有权规则:每个值在某一时刻会有一个明确的所有者:如果所有者超出范围,则该值会被垃圾收集。
当一个值的所有权被临时从拥有它的变量中借用到一个函数或一个变量时,就会发生借出了,要么是可变的,要么是不可变的。一旦借用超出了功能或{}分隔块的范围,所有权就会返回。在借用期间,父函数/范围对变量没有所有权,直到被借用的函数/范围结束为止。
借出规则:对于一个变量,可以有任意数量的不可变引用,但是在一个范围内只能有一个不可变的引用。此外,可变和不可变引用不能在一个范围内共存。
引用计数
引用计数用于跟踪其他进程/线程对变量的使用。一个新进程/线程持有该变量时引用计数将增加,当一个进程/线程退出时引用计数将递减。当计数达到0时,值被删除。
Erlang: 当数据在Erlang中跨多个进程传递时,数据通过一条消息传递。这意味着它是被复制到其他进程的堆中的,而不是引用计数。在一个进程内复制的数据由每进程(per-process)垃圾回收器在其生命周期末尾进行垃圾回收。然而,超过64KB大小的binary跨Erlang进程传递时会被引用计数。
Rust: 当数据在线程间共享时,数据不会被复制以提高效率。而是由一个引用计数器包装。引用有些特殊,因为多个可变引用可以传递给多个线程,但同时要对数据同步进行互斥。不可变数据的引用不需要互斥。 所有相关的检查都是在编译时完成的,并有助于防止Rust中的数据竞争。
消息传递
Erlang: Erlang中的消息传递是异步的。假设一个进程向另一个进程发送消息,如果该锁立即可用,则消息会被复制到另一个进程信箱;否则它将被复制到一个堆片段,正在接收的进程将在稍后的时间点得到它。这可以实现真正的异步和数据无竞争行为,尽管代价是在另一个进程的堆中复制相同的消息。
Rust: Rust有通道,就像水在两点之间流动一样。如果在一条小溪上放个东西,它就会流向另一端。每当创建一个Rust通道,就会随之创建一个发射和一个接收处理器。发射处理器用于把消息放到通道上,而接收处理器阅读这些消息。一旦发射器把一个值放到了通道上,那么这个值的所有权就转移给了那个通道,如果有其他的线程从这条通道读取这个值,那么其所有权就转移给了这个线程。当使用通道时,所有权的原则仍然保留,每个值都只有一个所有者。在最后一个线程退出时,资源被垃圾回收。
共享的突变
Erlang: 在Erlang中共享是一种罪,但是Erlang允许使用Erlang Term Storage(ETS)进行控制突变。ETS表可以跨多个表共享,并在内部同步,以防止竞争。ETS可以调优,以带来高的读并发性或高的写并发性。整个表可以附加到一组进程中,如果所有这些进程退出,整个表将被垃圾回收。
Rust: 作为一种低级语言,Rust提供了一种资源共享突变的方式。结合引用计数与互斥量,资源访问与多个线程的突变同步。如果共享相同资源的多个线程退出,资源将被最后一个退出线程垃圾回收。这提供了一种干净、高效的共享、变异和清理资源的方式。
行为
Erlang: 行为是共同模式的形式化。其思想是将一个过程的代码划分为一个通用的部分(行为模块)和一个特定的部分(一个回调模块)。您只需要实现一些回调,并调用特定的API来使用行为。有各种标准的行为,如genserver、genfsm、gensupervisor等。例如,如果您想要一个独立的进程,可以像服务器一样持续运行,侦听异步和同步调用或消息,那么您就可以实现它的genserver行为。它还可以实现自定义行为。
Rust: 如果你有一组在多种数据类型中通用的方法,它们可以被声明为一个特征(trait)。特征是Rust版的接口,它们是可扩展的。Traits消除了对传统方法重载的需求,并提供了一种操作符重载的简单模式。
内存分配
Erlang: 变量在Erlang中是动态强类型的。在运行时不提供类型定义,并且在运行时最小化类型转换以防止类型错误。当程序运行时,变量会在底层OS线程的堆上动态分配,并在垃圾回收时释放。
Rust: Rust是一种静态的,严格的和推断的语言。静态意味着Rust编译器会在编译期间检查类型以防止运行时发生类型错误。有些类型是在编译过程中推断出来的,比如:最初声明为String类型的字符串变量被分配给不同的变量,不需要隐式地声明类型,新变量的数据类型将由编译器本身推断出来。编译器努力确定哪些变量可以分配到堆栈上,哪些变量可以分配到堆上,因此Rust内存分配非常高效和快速。与Erlang不同的是,在很大程度上,Rust使用堆栈分配所有在编译时已知大小的数据类型,而动态数据类型(如Strings,Vectors等)则在运行时在堆上分配。
可扩展性、容错性、分布式
Erlang BEAM是Erlang的一个独特特性。BEAM的构建方式是可扩展性、容错性、分布性、并发性等的基础保证。
Erlang如何扩展?与操作系统中的本地线程不同,BEAM可以支持称为绿色线程的轻量级进程,这些进程通常是使用很少的本地操作系统线程分离出来的。从字面上看,可以从任何一个本地操作系统线程中分离出一百万或更多的Erlang进程。通过将大堆块分配给本地线程并在多个Erlang进程间共享,使这一点成为了可能。每个Erlang进程都会获得一块来存储它的所有变量。由于它的大小可能只有233个字,本地操作系统线程的堆完全可以应对一百万个进程。此外,由于Erlang内置的异步消息传递,进程之间的通信几乎不是瓶颈。一个进程永远不会被阻塞,以便向其他进程发送消息:它或者试图获取对另一个进程信箱的锁定,直接将消息放入其中,或者将消息放入单独的堆片段中,并将该堆片段附加到其他进程堆。Erlang虚拟机还具有内置的分布功能,可以运行进程并以透明的方式跨机器与它们进行交互。
并发在Rust中如何工作?当您使用本机操作系统线程时,它们是由操作系统调度程序调度的。当您使用本机操作系统线程时,它们将由操作系统调度程序调度。例如,如果在Linux下,调度效率随着线程数量而下降。但是,Erlang的BEAM从一个本地操作系统线程中分离出并管理多个绿色线程。在默认情况下,每个进程被指定2000衰减(erlang中的每个操作都有一个衰减预算,其中1衰减大致相当于一个最小函数调用),直到分配的衰减额耗尽前都允许运行,随后抢占为止。抢占时,运行队列中的下一个Erlang进程将被安排运行。这就是每个Erlang进程的调度方式。
BEAM层是如何进行内存管理的?正如我们所提到的,每个本地操作系统线程的堆在多个Erlang进程之间共享。无论何时Erlang进程需要更多内存,它都会在本地操作系统线程堆中查找可用内存并拿到它(如果可用)。否则,根据请求的数据类型,特定的内存分配器服务会尝试使用malloc或mmap从OS获取一块内存。BEAM通过将内存块划分为多个载体块(由分配器管理的内存块的容器)和每个Erlang进程与正确的载体一起提供,从而在多个进程中有效利用了这块内存。根据当前的需要,如从网络套接字中读取大量XML节,BEAM会动态地计算出应该分配多少内存,分配内存的载体数量,GC周期释放之后保持多少载体等等。释放的内存块几乎在重新分配后就会立即合并,这样下一次分配就会更快了。
Erlang垃圾收集如何工作?Erlang提供了一个每进程垃圾回收器,它使用分代标记清除垃圾回收算法。与Erlang内置的不分享方式想配合,收集一个进程的垃圾不会以任何方式干扰其他进程。每个进程都有一个年轻的堆和一个旧堆。年轻堆的垃圾收集更频繁。如果有些数据在两个连续的年轻垃圾回收周期中存活,它将被移至旧堆。只有在达到指定大小后,旧堆才会被垃圾回收处理。
Erlang的容错是如何工作的?Erlang认为失败是不可避免的,它试图做好处理准备。任何普通的Erlang应用程序都需要遵循一个监督层级,在这个层级中,每个Erlang进程都需要一位监督者予以监督。监督者负责根据故障类型重新启动其控制下的工作进程。监督者还可以根据工作人员监控的类型对工作人员配置重启策略,例如一对一(每个工作进程退出仅关系到一个工作进程),一对多(如果一个工作进程退出,则重新启动所有工作进程)等。BEAM提供链接以在进程之间传播退出信号,以及监视器以监视在相同BEAM VM内的进程之间传播的退出信号,并且还可以跨越分布式的BEAM Vm透明地传递位置。Erlang的BEAM还可以一次在一个虚拟机或所有虚拟机上动态加载代码。BEAM负责加载内存中的代码变更并应用它们。告诉BEAM有关加载模块的顺序、状态管理等所需的额外努力,以防止任何未确定的进程状态。
与Erlang相反,Rust在编译程序时完成了大部分工作,而在运行时只做了很少的工作。由于大多数系统编程语言在运行时缺乏内存安全性,因此Rust会尽力确保代码编译完成后在运行时没有问题。虽然BEAM以运行时确保内存安全,但有时开销会变得异常复杂,所以Rust选择在编译时。
Rust的核心语言特性就是旨在尽可能简洁。举个例子:Rust常常在晚上构建具有轻量级的绿色线程(类似于Erlang进程)。在某一时刻,该特性被有意识地删除了,因为它不被视为每个应用程序的通用需求,并且它伴随着一定的运行时成本。相反,该特性可以在需要时通过crate提供。虽然Erlang也可以导入外部库,但其核心功能(如绿色线程)嵌入到VM中了,不能关闭或使用本地线程进行交换。尽管如此,Erlang Vm的绿色线程效率非常高,近几十年早已证明了这一点,关闭它对于选择使用Erlang的人来说不是一个常见的要求。
Rust如何扩展?扩展限制通常取决于通信和分发机制的可用性。至于通信机制,基于消息传递和每个进程垃圾收集和ETS的Erlang模型是否比Rust拥有单一所有权和共享变异的渠道更有效率是值得商榷的。
在Erlang中,任何消息都可以通过复制发送到所有其他进程。垃圾收集器在发送和接收过程中都进行了大量清理工作。而另一边,Rust的渠道是多个生产者和单一消费者。这意味着如果消息发送给消费者,则不会将其复制并将其所有权转移给消费者。然后,消费者在其范围的末尾注入清除代码以回收这个值。通过为所有通道克隆这个值,可以向多个消费者发送相同的消息。在某些情况下,Rust的所有权模型与可预测的内存清理相结合可能会比Erlang的垃圾收集更好。
通信的另一个重要方面是共享突变。从理论上讲,Erlang:的ETS与联合使用Rust共享突变与互斥体和引用计数类似。但是,尽管Rust具有非常细粒度的突变单位,它就像Rust变量一样小,但Erlang的ETS中的突变单位处于ETS表级别。另一个重大差异是Rust缺乏内置的分配机制。
在Rust中是如何���发的?Rust线程默认为本地线程。操作系统使用自己的调度机制来管理它们,因此它是操作系统的属性,而不是语言的属性。拥有本机操作系统线程可以显着提升网络,文件IO,加密等操作系统库的性能。或者,您可以使用一些绿色线程或自带调度程序的协同库,你可以有足够的选择。不幸的是,目前还没有稳定的crate。Rayon是一个数据并行库,它实现了一个工作窃取(work-stealing )算法来平衡本地线程间的负载。
Rust是如何做内存管理的?正如所讨论的,它使用所有权和生命周期的概念进行了大量静态分析,以确定哪些变量可以分配到堆栈以及哪些分配到堆。Rust在这里有一件事做得很好,它尝试在栈上分配尽可能多的数据,而不是在堆上。这在很大程度上提高了内存读取/写入速度。
垃圾收集是怎么做的?如上所述,Rust在编译时标记并确定变量的生命周期。此外,Rust使用的大多数变量都倾向于存在于堆栈中,这更易于管理。在Erlang中,垃圾收集器必须在给定的时间间隔内触发,以便在整个堆中查找未使用的数据,然后释放它。在允许共享引用的语言中,如果没有任何警告,这会变得更加困难,例如Java。垃圾收集持续时间的可预测性很难在这些语言中实现,Java的可预测性比Erlang低,而Rust的可预测性比Erlang更高。
容错是如何工作的?Rust本身没有内置的机制来识别运行时失败并从中恢复。Rust通过Result和Option类型提供了基本的错误处理,但这不能保证永远都能处理每个意外情况,除非您的语言中嵌入了运行时错误管理框架。Erlang在这一点占了上风,通过贯彻使用其监督框架和热代码加载它可以提供至少五个九的正常运行时间。Rust要做到这一点还得再加把劲儿才行。
结论
Erlang和Rust在各自的领域都很强大。Erlang已经存在有很长一段时间了,并且在可扩展性、并发性、分布和容错方面已经证明是一个强大且行业就绪的生态系统。Rust具有自己定义的特性,如能在低层次运行且具有可利用本地性能的高级语言特性,安全编程以及常见特性(如并发支持和针对错误处理的规定)。
在我看来,如果一些非常复杂的用例需要上述所有特性,一个有趣的选择是将Rust与Erlang一起结合起来作为共享库或本机实现的函数(NIF)。所有的数据处理、IO操作、操作系统调用都可以甩给Rust,然后将结果同步回Erlang虚拟机。这样做的目标是使事情更容易。
Rust是Erlang的替代品吗?我的答案是,不是。几十年来,Erlang BEAM已被证明具有出色的可扩展性、并发性、分布性和容错性。Erlang一直都在尝试通过BEAM处理它们,把这许多共性问题提取出来,好让程序员不需要分心去考虑它们,从而可以专注于手头的问题。相反,对于Rust来说,我们可以通过社区创建的crate获得很多选择,但作为程序员,我需要以正确的方式将它们混合使用。Rust的另一大挑战是其陡峭的学习曲线。对于刚刚开始或来自动态编程语言的人来说,这绝对是一个更大的飞跃。简而言之,这两种语言针对不同的受众并解决不同的问题,而将它们擅长的部分捏合起来可能是最好的做法。
关于作者
Krishna Kumar Thokala目前在Thoughttworks担任应用程序开发人员。此前,他作为开发人员曾在Erlang的电信网络模拟器上工作过一段时间,作为架构师,他使用NetConf上的 yang modeling 构建了一个配置管理系统。除了构建软件系统外,机器人技术/电子技术和工业自动化也是他感兴趣的领域。你可以通过这几个社交平台关注他:Medium、LinkedIn、Twitter