您的位置:  首页 > 技术杂谈 > 正文

技术专栏丨Rust 语言简介及其在 Fabarta 技术栈中的应用

2024-03-12 20:00 https://my.oschina.net/u/6523763/blog/11046941 Fabarta 次阅读 条评论

导读:Rust 是一门注重性能和安全的系统编程语言,通过其独特的所有权系统、借用系统和类型系统,成功地解决了传统系统编程中的许多难题。其开发者友好的语法、丰富的标准库和强大的社区支持,使得 Rust 成为当今编程领域中备受关注的语言之一。

01 引言

Rust 已经不算是一门年轻的语言了,其诞生时间跟 Go 语言差不多。2006 年 Rust 作为 Graydon Hoare 的个人项目出现,2007 年 Google 开始设计 Go。但很明显,Go 的发展要好得多。Rust 在 2015 年才发布了 1.0 版本,而 Go 在 2016 年已经成为了 TIOBE 的年度语言。相较而言 Rust 的发展和前景似乎不怎么好,但其实这与 Rust 语言的定位有非常大的关系。Rust 最初是作为一种在系统编程领域里替代 C/C++ 而出现的语言,其发展自然要缓慢许多。因为在系统编程领域,每走一步都要求非常扎实。

我对 Rust 印象比较深刻的有两件事情:首先是看到一篇文章称其学习曲线非常陡峭,当时就比较好奇一门语言可以难到什么程度。其次则是因为 Linus Torvalds 决定在 Linux Kernel 里添加对 Rust 的支持。Linus 以严苛出名,能受到 Linus 的青睐绝对不是一件容易的事情,这说明 Rust 这门语言必然有独到之处。

最近几年,微软、AWS 等大型商业公司逐渐开始使用 Rust 来编写或重写重要系统。开源界很多重视安全因素的组件,如 sudo/su 也在使用 Rust 进行重写。Rust 除了在系统编程领域变得流行起来,也是 WASM 领域里的推荐语言。这一方面说明 Rust 语言已经逐步成熟,另一方面也说明了 Rust 有非常强的表现能力,在各个领域都能胜任。所以 Fabarta 在开发多模态智能引擎 ArcNerual 时,经过多方面的权衡后选择了 Rust 语言。

在过去的一年多的时间里,团队从 0 到 1 开始学习并应用 Rust,在开发效率、安全性、并发、异步编程等领域都有深入的实践,我们成功发布了 ArcNerual 多模态智能引擎(ArcNerual 详情可以参见附录 1、附录 2)。回过头来看,这是一个非常成功的决定。团队成员之前大多具备 C/C++ 的背景,在上手速度上会有一些优势,后期也有一些 Java/前端背景的同学介入,在其他团队成员的帮助下,上手时间均不超过三个月,实践下来并不像网上一些文章写的那样有非常陡峭的学习曲线。

本文旨在简要叙述 Rust 语言的特性、优势以及实际生产项目中需要的一些注意事项,以便帮助您评估目前团队背景、适合领域、上手成本、熟悉周期、收益等各个方面的内容,最终可以合理决策是否要在新的项目中引入 Rust 语言。

02 Rust 语言概述

Rust 的发展经历了多个阶段,从最初的实验性质到逐渐成熟为一门系统级编程语言。Rust 最初只是 Graydon Hoare 的个人项目,他那时是 Mozilla 的雇员。所以后来 Mozilla 公司开始支持这个项目,并于 2010 年 5 月首次公开发布。在其早期阶段,Rust 主要是作为 Mozilla 的研究项目,致力于解决在编写浏览器引擎(如 Firefox 的 Gecko 引擎)时所面临的内存安全和并发性等挑战。

后来,其独特的所有权系统和内存安全性引起了业界的广泛关注。2010 年之后,Rust 的开发逐渐成为一个开源社区驱动的过程,而不再仅仅是 Mozilla 的内部项目。随着社区的不断壮大和创始人的离去,Rust 逐渐超越了单一公司的项目,成为一个开放、多元的编程语言社区,并不受个别核心人物所控制。

Rust 是一种系统级编程语言,其设计目标可以主要概括为以下三个方面:

  • 安全性(Safety): Rust 的最重要设计目标之一即是提供高水平的内存安全性与并发安全性。 通过所有权系统、借用检查器和生命周期机制,Rust 在编译时能够防止许多常见的内存错误,如空指针引用和缓冲区溢出。这使得开发者能够编写更加健壮、可靠的代码,减少了许多传统系统级编程语言中容易出现的安全漏洞,当然从某些方面来说,安全性保证引入是 Rust 难以学习的主要原因,但只要熟悉并运用得当,这会是一个极大的优势,实际上 C++ 也开始讨论引入内存安全机制。

  • 高生产力(Productive)"能编译就能工作"。 C/C++ 的程序员会清楚的知道编译通过只是很小的一步,后续还有非常经典的类似“Segmentation fault”错误等着去排查与解决,但 Rust 在这方面给了开发者非常强的信心,叠加零成本抽象原则,开发者可以放心的将内存、并发等问题交给 Rust ,从而极大的提升开发效率。

  • 实用性(Practicality): Rust 的设计旨在成为一门实用的系统级编程语言,"you can do anything with Rust"。 Rust 不仅具有对底层硬件的直接控制能力,同时允许开发者使用高级抽象来表达复杂概念。它在性能和开发者友好性之间找到平衡,使得开发者能够编写高性能的代码,同时又不牺牲开发者的便利性。

受益于 LLVM(Low Level Virtual Machine) 这样的编译器基础设施,Rust 在这些设计目标的指导下实现了现代语言的所有特征:

  • 静态类型系统:在编译时检查类型,这有助于提前捕获错误,提高代码的稳定性和可维护性。Rust 有一套强大的类型系统来帮助我们正确地编写代码。

  • 自动内存管理:减轻开发者手动管理内存的负担。现代语言一般通过垃圾回收(garbage collection)机制来实现,Rust 使用了独特的所有权与借用检查系统,在性能上更有优势。

  • 模块化设计:模块化对软件工程的重要性不言而喻,在此基础之上,Rust 更进一步提供了先进的包管理与构建工具 Cargo,可以非常方便的管理模块及依赖。

  • 丰富的编程范式:无论是过程式还是面向对象编程和函数式编程,Rust 均提供了强大的支持,这使得开发者能够选择适合特定问题的编程风格。

  • 异常处理:站在巨人的肩膀上,提供统一、高效的机制来处理异常,使得开发者能够更容易地识别和处理运行时错误。

  • 多线程和并发支持、跨平台性。

除此之外,Rust 有一些独特或值得强调的特征:

  • 所有权系统:独特的所有权系统确保内存管理的安全性,避免了悬垂指针和内存泄漏等问题。

  • 借用和生命周期:引入借用和生命周期的概念,使得在不移交所有权的情况下安全地访问和修改数据。

  • 零成本抽象:允许开发者使用高层次的抽象,但不引入额外的运行时开销,保持了系统编程语言的高性能特性。

  • 模式匹配:强大的模式匹配语法使得开发者能够优雅地处理各种复杂的数据结构和状态。

  • 异步编程支持:async/await 语法和 Future trait 的引入使得异步编程更加易读和高效。

  • 元编程:独特而强大的宏编程系统。

  • 友好的 FFI(Foreign Function Interface):允许 Rust 与其他编程语言特别是 C 语言进行交互。从而实现跨语言的协同开发与利用现有资产。

在本文中,我们主要通过所有权系统、借用和生命周期、零成本抽象这几个特性来深入观察一下 Rust 语言, 其他的一些特性可以关注本专栏后续的专题解读文章。

03 Rust 语言特性

所有权系统

所有权系统可以说是 Rust 中最为独特和核心的功能了,它旨在解决内存安全性和并发性问题。正是所有权概念和相关工具的引入,Rust 才能够在没有垃圾回收机制的前提下保障内存安全。同时,这也是大家认为 Rust 难学的根本原因,因为它对整个语言产生了影响,让 Rust 看起来和其他语言非常不一样。

所有权系统本身并不复杂,简单的说,就是在 Rust 中,Rust 中的每一个值都有一个对应的变量作为它的所有者,在同一时间内,值有且仅有一个所有者,举一个简单的例子:

let s1 = String::from("hello, world!");
let s2 = s1;
println!("{}, world!", s1);  // 编译出错, s1 不再可用了。

如果上面这个简单的例子让你有新奇感,这就说明了 Rust 语言的不同。所有权系统的引入,让 Rust 默认使用一种叫做“移动”的语义。在上面这个例子中,s1 被移动到了 s2,要理解移动及其成本,你需要理解一个变量所占用的内存什么时候在栈上分配、什么时候在堆上分配以及他们在内存中的大致布局。这与拥有垃圾回收机制的语言是不同的,也是系统编程语言里需要时刻关注的概念。

毫无疑问,所有权系统的引入增加了编程时的复杂度与一些内存拷贝操作(相对于 C/C++来说),但这个成本是必要的。所有权系统定义了一套规则,定义了在程序执行过程中如何传递、借用和释放这些值。关键的概念包括“借用”、“生命周期”。接下来,我们来进一步看看。

借用和生命周期

借用和引用

不是所有的地方都需要移动所有权,借用和引用正是为了在不移动所有权的情况下访问值。借用可以是不可变的(&T)或可变的(&mut T),容易推导出一个变量可以有多个不可变借用,但只允许同时有一个可变的借用:

  • 不可变借用:使用 &T 语法,允许多个地方同时引用相同的值,但这些引用是只读的,不能修改原始值。这有助于避免数据竞争和并发问题。
fn main() {
    let original = 42;
    let reference = &original;  // 不可变借用
    println!("The original value is: {}", original);
    println!("The reference value is: {}", reference);
}
  • 可变借用:使用 &mut T 语法,允许代码获取对值的可变引用,但在同一时间只能有一个可变引用存在。这避免了数据竞争,确保在任何时候只有一个地方能够修改值。
fn main() {
    let mut original = 42;
    let reference = &mut original;  // 可变借用
    *reference += 10;  // 修改原始值
    println!("The modified value is: {}", original);
}

生命周期

生命周期是 Rust 用来管理引用有效性的一种机制。它们是一系列标注,用于指示引用在代码中的有效范围,以便编译器在编译时检查引用的合法性。简单的说 Rust 会在发生借用的地方分配一个生命周期,从而进行引用有效性的检查。早期的 Rust 使用一个非常简单的基于词法的方法分配生命周期,有很多地方依赖开发者进行标注,给开发者造成了较大的限制与负担。但随着 RFC-2094-nll(Non Lexical Lifetimes)的优化,Rust 编译器的推导能力越来越强,需要开发者标注的地方已经大大减少。

生命周期使用单引号来表示,比如下面的函数中,Rust 本身没有办法知道 s1 和 s2 的生命周期,需要依赖开发者指出,其中 'a 即为生命周期的标注:

fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

生命周期的引入使得 Rust 能够在不放弃内存安全性的同时,允许灵活的数据引用和传递。同时编译器能够在编译时进行检查,确保代码的正确性,是借用检查的幕后功臣。针对生命周期引入的一些限制及学习成本, Rust 成立了 Polonius 工作组来对借用检查进行进一步优化,相信很快开发者不再需关注这个概念,让生命周期真正退居幕后。

零成本抽象

零成本抽象是 Rust 中的一项基本原则,体现在语言设计的各个方面。零成本抽象是指在高级别表达代码概念时,底层生成的机器码能够保持高效执行,而不引入运行时性能开销。这一特性使得开发者能够以更抽象、更通用的方式编写代码,而不必担心抽象带来的性能损耗,“在编译时能解决的,不留到运行时”

这体现在非常多的方面:

  • 比如通过所有权系统解决自动内存管理,而不是引入垃圾回收。

  • Rust 中的泛型(Generics)允许开发者编写适用于不同数据类型的通用代码。通过在编译时实例化泛型代码,Rust 可以生成与手动编写特定类型代码相媲美的性能,同时确保代码的通用性和灵活性。

  • 引入了 trait 的概念,使得开发者能够定义抽象接口。通过 trait,可以在不同类型上实现相同的行为,而不会引入额外的运行时开销。这种方式允许代码更好地适应多态性,同时保持高性能。

  • Rust 强大的宏系统(Macros)允许开发者编写模式匹配和代码生成的通用模板,这些宏在编译时展开,生成实际的代码。通过宏,可以在代码中引入更高层次的抽象,而不会导致性能损耗。

这些零成本抽象的机制与原则,使得 Rust 成为一门同时追求性能和抽象的现代编程语言,在保证开发效率的同时不损失性能。

04 Fabarta 对 Rust 的应用

在实现 ArcNerual 的时候,我们的开发语言选型主要考虑几个方面:

  • 不希望手动管理内存以提升开发效率以及避免由内存引起的安全性及错误。

  • 由于是底层数据库系统,所以也不想引入垃圾回收器而造成的不可预见的系统卡顿。

  • 有活跃的社区与丰富高质量的三方库。

  • 方便利用庞大的其他语言特别是 C/C++ 资产。

  • 在满足上述条件的情况下,尽可能的高性能。

从以上这几个方面出发,我们没有费特别多的工夫就选定了 Rust。选择一门新的语言,在最开始写代码的时候,总是会写成自己最熟悉的那门语言。以我们团队为例,团队成员大多具有 C/C++ 背景,所以最开始的代码就是 C/C++ 风格,然后随着不断的熟悉与深入,开始慢慢形成一些最佳实践。一些方案会根据编写的系统、团队的偏好、系统的压力而有所不同。从 Fabarta 的实践来看,我们认为以下几个问题在最初就需要特别注意。

编程范式 / 设计模式的选择

在很多语言中这都不是一个问题,或者语言本身已经帮你做出了选择。比如 C++/Java 中,一般都会选择面向对象的模式。但是 Rust 中稍有不同,他并没有提供一种主要的编程范式。你可以根据项目的需求和编写代码的上下文选择不同的范式或模式,以充分发挥 Rust 的灵活性和表达力。Rust 支持多种编程范式,包括面向对象、函数式、过程式等,同时还具有强大的模式匹配和零成本抽象的能力,而不必拘泥于其中的一种,这会让你的代码更加简洁高效。

比如在面向对象编程中,我们经常使用继承来实现多态。但 Rust 并不支持继承,而是使用 trait 来提供动态分发机制。比如在 ArcNerual 中我们会提供很多数据库算子,这些算子具有相同的行为,我们将这些相同的行为抽象为 trait:

pub trait Processor {
    fn do_process(&self) {
        ...
    }
} 

不同的算子会依赖不同的数据:

struct VertexScan {
    ...
}

struct Filter {
    ...
}

但它们有相同的行为:

impl Processor for VertexScan {
    fn do_process(&self) {
        ...
    }
}

impl Processor for Filter {
    fn do_process(&self) {
        ...
    }
}

我们可以使用动态分发模式来实现多态:

fn run_processor(op: &dyn Processor) {
    op.do_process();
    ...
}

这一方面算是一种对象编程的模拟,动态分发相对来说会有一些运行时开销,编译期的一些优化比如内联优化无法在动态分发的代码中实现,但是通过 Rust 的模式匹配,完全可以将上述行为转为静态分发,在上面的例子中,我们事先知道有哪些算子,所以可以使用 Rust 强大的 enum:

pub enum AnyProcessor {
    VertexScan(VertexScan),
    Filter(Filter),
    ...
}

impl Processor for AnyProcessor {
    fn do_process(&self) {
        match self {
            AnyProcessor::VertexScan(vertex_scan) => vertex_scan.do_process(),
            AnyProcessor::Filter(filter) => filter.do_process(),
            ...
        }
    }
}

而这些增加的代码可以通过宏的方式轻松去除。在 ArcNerual 的实现过程中,我们充分使用了 Rust 多范式的灵活性来降低代码的复杂度与提升性能。业界在不同的领域也沉淀出了不同的模式,比如游戏编程里提出了一种 ECS (Entity-Component-System)模式,团队在使用 Rust 之初,可以多思考这个问题,结合 Rust 的多范式编程,会对我们解决问题有非常大的帮助。

并发模式的选择

出于以下几个原因的考虑,与编程范式一样,Rust 支持多种并发模式并把选择权也交给了开发者:

  • Rust 需要有控制底层的能力,所以要提供和 OS 进程/线程相对应的概念。

  • Rust 不能有一个特别大的运行时,所以没有像 Go 一样提供协程(早期曾经有过 green thread,已经去除)。

  • 强调零成本抽象,不需要为用不到的能力付费。

所以你需要据自己的需求选择相应的并发模型。如果是计算密集型程序,那简单地使用操作系统多线程模型就足够了;如果是 IO 密集型,可能事件驱动模型就能满足需求。

在 Rust 生态系统中,异步编程变得越来越普遍,特别是在处理网络和大规模并发的应用程序中。对于 ArcNerual 来说,有大量的 IO 操作,也有大量的计算操作,所以我们选择了异步模式并使用了 tokio 运行时,这里的关键是要尽早做出选择。虽然可以在使用异步模型时,同时使用其他并发模型,但强烈建议不要这么做或者考虑清楚后再决定。因为在本质上,同步和异步是两套不兼容的并发模型。异步中引入同步代码是一个非常大的风险,可能会引起各种意料之外的状况。在选定异步模式后,对于使用到的锁、通信等依赖包也需要审慎引入。在 ArcNerual 的开发过程中,我们就遇到过因为引入不适用于异步编程的库而引起程序阻塞。关于异步编程与 tokio 方面的实践可以参见文末附录 3。

safe/unsafe 的使用

在最初 Rust 的借用检查器可能会让你感到莫名的阻力,Rust 社区也常常有这方面的讨论,比如在《Rust Koans》(详见附录 4) 这篇文章中就生动的描述了这种感觉。当 C/C++ 程序员发现有 unsafe Rust 的时候可能会眼前一亮,终于回到了熟悉的领域,但是,团队必须明确在什么场景下可以用 unsafe Rust,根据我们实践的经验,初期仅建议在调用外部代码的时候使用,后期可以在性能优化的时候有针对性的进行引入,需要及早和团队明确,防止 unsafe 代码失控。

内存分配

过去系统级编程语言中一直倾向于由程序员来管理内存,这主要是基于性能方面的考量,因为一次内存分配操作相对来说是比较耗时的。有一些语言比如 Zig,强调没有隐式内存分配,每一个内存分配操作均需要程序员来操作并带入自定义内存分配器。Rust 作为一门以自动内存管理为重要特征的系统级编程语言,其实是综合了几种语言的特性,目前在内存分配方面整体显得比较复杂而凌乱,这也是 Rust 社区目前面临的一个重要课题。Rust 最初使用的内存分配器是 Jemalloc,后来由于 Jemalloc 的多平台支持问题、Rust 程序嵌入问题等原因,切换为各个系统的默认分配器。

目前 Rust 会使用系统的默认分配器,在 Linux 上就是 ptmalloc2。相对来说其性能不如 tcmalloc/jemalloc,Rust 提供了相关接口,可以通过代码进行更改,比如在 ArcNerual 中我们通过如下代码将全局内存分配器设置为 Jemalloc:

use jemallocator::Jemalloc;

#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;

除了更改全局分配器以外,一些容器还支持传入自定义的 allocator,比如 Vec 的完整定义是:

pub struct Vec<T, A = Global>
where
    A: Allocator,
{ /* private fields */ }

同时提供了 new_in / with_capacity_in 函数可以传入 allocator, 比如:

#![feature(allocator_api)]

use jemallocator::Jemalloc;
let mut vec: Vec<i32, _> = Vec::new_in(Jemalloc);

这样可以指示 vec 在分配内存时从指定的 allocator 里分配。不过这里除了写起来有点麻烦外,更大的问题在于不是所有的数据结构都支持自定义 allocator, 比如 String 目前就不支持自定义 allocator。当然可以通过一些 unsafe 的代码来达成自定义分配器的目标,但无疑代码写起来要复杂得多:

 let size = 1024;
 let layout = std::alloc::Layout::from_size_align(size, 8).unwrap();
 let ptr = unsafe { std::alloc::alloc(layout) };

 let mut s = unsafe {
     let slice = std::slice::from_raw_parts_mut(ptr, layout.size());
     String::from_utf8_unchecked(slice.to_vec())
 };

 s.push('1');
 ...

而且需要自己管理内存,不符合我们使用 Rust 的大原则。相对来说,目前在 Rust 语言层面提供的内存分配机制不是很完善。这些问题从 2015 年就开始讨论,但目前一直没有太大的进展。最近有 rust wg-allocators 工作组的人员表示要重新考虑 allocator trait(详见附录 5、附录 6),期望后续在内存分配方面有所进展。

如果对内存分配有很高的要求,我们建议一方面可以选择一个熟悉的分配器并根据业务场景进行调优,另一方面则是在应用层面进行优化,比如引入 arena 分配器等。

05 总结与展望

总体而言,Rust 是一门注重性能和安全的系统编程语言,通过其独特的所有权系统、借用系统和类型系统,成功地解决了传统系统编程中的许多难题。其开发者友好的语法、丰富的标准库和强大的社区支持,使得 Rust 成为当今编程领域中备受关注的语言之一。

Fabarta 这一年多的实践下来,有一些感悟希望能帮到你决定是否投入 Rust 语言:

  • 第一还是要坚持没有银弹。 如果想通过换一门语言来解决所有问题,那么 Rust 并不能。

  • 但是,工具特别是语言会极大的影响团队能力。 简单来说,团队使用工具的能力边界基本上就是团队的能力边界。在这方面,Rust 提供了非常宽广的范围。

  • 团队的背景决定了 Rust 的学习曲线。 出于系统级编程语言的特性,Rust 虽然不需要手动管理内存,但如果清楚背后的细节,学习曲线虽高于其他语言但并不算陡峭。

  • Rust 具有现代语言的所有特征,周边工具比如代码风格检查、测试、三方生态都非常丰富与活跃,我们最初对这方面有一些担心,但实践下来完全没有问题。

  • Rust 可以极大地提升开发效率与系统稳定性,进而给团队往前走的信心。 ArcNerual 在过去一年成功发布了多个大版本,整体进展甚至超过了团队之前的预期,Rust 的采用是我们成功的必要条件之一。

Rust 仍然在高速发展,目前随着微软、AWS 等大型商业公司的采用,拉平学习曲线、更好的异步编程、更优的内存分配、更丰富的生态支持等都在计划或实施中,在嵌入式、Linux 内核中也有很大的进展。对于 Fabarta 来说,我们也将继续在 Rust 里深入并不断拓展产品与团队能力边界。

附录

1.杨成虎:存储&计算是过去,记忆&推理才是未来 

2.一文读懂 Fabarta ArcGraph 图数据库丨技术专栏 

3.探索 Tokio Runtime丨技术专栏

4.Rust Koans:https://users.rust-lang.org/t/rust-koans/2408/1

5.allocator trait:https://github.com/rust-lang/rust/issues/32838

6.allocator trait:https://shift.click/blog/allocator-trait-talk/

本文作者

谭宇

Fabarta 资深技术专家

目前主要专注于 AI 时代的多模数据库引擎 ArcNeural 的建设。加入Fabarta 之前曾任阿里云资深技术专家,主攻数据库、云计算与数字化转型方向。是 Tair / OceanBase 早期开发团队成员;曾负责建设阿里巴巴集团数据库 PaaS 平台,带领团队完成了阿里巴巴数据库的容器化、存储计算分离、在离线混部等重大变革;在阿里云全球技术服务部期间提出并建设了飞天技术服务平台,对企业数字化转型有深刻的理解并有丰富的实践经验。

展开阅读全文
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接