跳至内容

构建 JavaScript 编译器的性能追求

最初发布于 https://rustmagazine.org/issue-3/javascript-compiler/

关于性能

在编写 Rust 的两年中,性能已经成为我根深蒂固的一门学问 - 归结为**分配较少的内存**和**使用较少的 CPU 周期**。

然而,如果没有问题域的知识或对潜在解决方案的认识,实现最佳性能可能是困难的。

在以下部分中,我将带你踏上我的性能和优化之旅。我更喜欢通过研究、试验和错误的结合来学习,因此以下各节将按此组织。

解析

Oxc 是一个标准编译器,其中包括抽象语法树 (AST)、词法分析器和递归下降解析器。

抽象语法树 (AST)

编译器的第一个架构设计是其 AST。

所有 JavaScript 工具都在 AST 级别上运行,例如

  • 语法检查器(例如 ESLint)检查 AST 中是否有错误
  • 格式化程序(例如 prettier)将 AST 打印回 JavaScript 文本
  • 压缩器(例如 terser)转换 AST
  • 打包工具连接不同文件中的 AST 之间的所有 import 和 export 语句

如果 AST 不够用户友好,那么构建这些工具将非常费劲。

对于 JavaScript,使用最多的 AST 规范是 estree。我的第一个 AST 版本复制了 estree

rust
pub struct Program {
    pub node: Node,
    pub body: Vec<Statement>,
}

pub enum Statement {
    VariableDeclarationStatement(VariableDeclaration),
}

pub struct VariableDeclaration {
    pub node: Node,
    pub declarations: Vec<VariableDeclarator>,
}

在 Rust 中,声明一棵树相对简单,因为它涉及使用结构和枚举。

内存分配

在编写解析器的几个月时间里,我一直在处理这个 AST 版本。有一天,我决定对其进行概要分析。概要分析程序显示,该程序花费大量时间调用 drop。

💡 AST 节点通过 Box 或 Vec 在堆上分配,它们是单独分配的,因此按顺序删除。

有没有办法减轻这种情况?

因此,在处理解析器时,我研究了其他一些用 Rust 编写的 JavaScript 解析器,主要是 rateljsparagus

这两个解析器都通过生存期注释声明了它们的 AST,

rust
pub enum Statement<'ast> {
    Expression(ExpressionNode<'ast>),
}

并且它们有一个名为 arena.rs 的附带文件。

我不明白它的作用,因此我忽略了它们,直到我开始了解它们对内存池的使用:bumpalotoolshed

总之,内存池预先以块或页分配内存,并在 arena 删除时整体取消分配。AST 分配在 arena 上,因此删除 AST 是一个快速操作。

随之而来的另一个好处是,AST 的构建是按照特定顺序进行的,树的遍历也遵循相同的顺序,从而在访问过程中产生了线性内存访问。由于所有附近的内存将按页读入 CPU 缓存,从而导致更快的访问时间,这种访问模式将是有效的。

遗憾的是,对于 Rust 初学者来说,使用内存池可能具有一定的挑战性,因为所有数据结构和相关函数都需要通过生存期注释进行参数化。我在 bumpalo 里面分配 AST 花了五次尝试。

将 AST 更改为内存池导致大约 20% 的性能提升。

枚举大小

由于 AST 的递归特性,我们需要以某种方式定义类型以避免“没有间接递归”错误

error[E0072]: recursive types `Enum` and `Variant` have infinite size
 --> crates/oxc_linter/src/lib.rs:1:1
  |
1 | enum Enum {
  | ^^^^^^^^^
2 |     Variant(Variant),
  |             ------- recursive without indirection
3 | }
4 | struct Variant {
  | ^^^^^^^^^^^^^^
5 |     field: Enum,
  |            ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 ~     Variant(Box<Variant>),
3 | }
4 | struct Variant {
5 ~     field: Box<Enum>,

有两种方法可以实现这一点。将枚举包装在枚举变量中或将结构字段包装起来。

在 2017 年,我在 Rust 论坛上发现了同样的问题,是否有更好的方式来表示抽象语法树?

Aleksey (matklad) 告诉我们对枚举变量进行封装以保持 Expression 枚举较小。但这意味着什么?

事实证明,Rust 枚举的内存布局取决于其所有变量的大小,其总字节大小取决于其最大的变量。例如,以下枚举将占用 56 个字节(1 个标签字节、48 个有效字节和 8 个对齐字节)。

rust
enum Enum {
    A, // 0 byte payload
    B(String), // 24 byte payload
    C { first: String, last: String }, // 48 byte payload
}

在典型的 JavaScript AST 中,Expression 枚举包含 45 个变量,Statement 枚举包含 20 个变量。如果不通过枚举变量进行封装,它们将占用超过 200 个字节。这 200 个字节必须传递出去,还必须在每次执行 matches!(expr, Expression::Variant(_)) 检查时进行访问,这对于性能而言不太友好且难以缓存。

因此,为了让内存访问更加高效,最好对枚举变量进行封装。

perf-book 中描述了有关如何查找大类型的其他信息。

我还复制了针对小枚举变量的限制测试。

rust
#[cfg(all(target_arch = "x86_64", target_pointer_width = "64"))]
#[test]
fn no_bloat_enum_sizes() {
    use std::mem::size_of;
    use crate::ast::*;
    assert_eq!(size_of::<Statement>(), 16);
    assert_eq!(size_of::<Expression>(), 16);
    assert_eq!(size_of::<Declaration>(), 16);
}

对枚举变量进行封装带来了大约 10% 的速度提升。

跨度

有时,直到我们花更多时间检查数据结构时,才会意识到可以获得较小的内存占用。

在此示例中,所有 AST 节点的叶子都包含一个称为“跨度”的小数据结构,该数据结构用于存储来自源文本的字节偏移量,并包含两个 usize

rust
pub struct Node {
    pub start: usize,
    pub end: usize,
}

有人 向我指出,我可以安全地将 usize 更改为 u32 以节省峰值内存,因为大于 u32 的文件为 4 GB。

更改为 u32 可实现更好的性能,在较大的文件中性能提升高达 5%

字符串和标识符

在 AST 中,可以尝试使用对标识符名称和字符串文本的字符串引用。

rust
pub struct StringLiteral<'a> {
    pub value: &'a str,
}

pub struct Identifier<'a> {
    pub name: &'a str,
}

但遗憾的是,在 JavaScript 中,字符串和标识符可以包含 转义序列,即 '\251''\xA9''©' 对于版权符号是相同的。

这意味着我们必须计算转义值并分配一个新的 String

字符串内部化

当存在大量堆分配的字符串时,一种名为 字符串内部化 的技术可用于通过仅存储每个不同字符串值的一个副本来减少总内存。

string-cache 是服务团队发布的一个流行且广泛使用的库。最初,我将 `string-cache` 库用于 AST 中的标识符和字符串。解析器的性能在单线程中很快,但是当我开始实现使用 Rayon 并行运行多个解析器的提示程序时,CPU 利用率却只有所有内核的 50% 左右。

在进行分析时,一个名为 `parking_lot::raw_mutex::RawMutex::lock_slow` 的方法显示在执行时间的顶部。我对锁和多核编程了解不多,但全局锁一开始就显得有些奇怪,所以我决定移除 `string-cache` 库以实现完全的 CPU 利用率。

从 AST 中移除 `string-cache` 使并行解析的性能提高了大约 30%。

string-cache

半年后,在从事另一个以性能为重点的项目时,`string-cache` 库再次浮出水面。它在并行文本解析期间阻塞了所有线程。

我决定研究 `string-cache` 的功能,因为在阅读 Mara Bos 的著作《Rust Atmoics and Locks》后我这次有所准备。

以下是 与锁相关的相关代码。请注意,该代码是八年前(2015 年)编写的。

rust
pub(crate) static DYNAMIC_SET: Lazy<Mutex<Set>> = Lazy::new(|| {
    Mutex::new({

// ... in another place
let ptr: std::ptr::NonNull<Entry> =
    DYNAMIC_SET.lock().insert(string_to_add, hash.g);

所以这是直接的。它每次插入字符串时都会锁定数据结构 `Set`。由于解析器中频繁调用此例程,因此其性能会受到同步的负面影响。

现在我们来看看 Set 数据结构,看看它做了什么

rust
pub(crate) fn insert(&mut self, string: Cow<str>, hash: u32) -> NonNull<Entry> {
    let bucket_index = (hash & BUCKET_MASK) as usize;
    {
        let mut ptr: Option<&mut Box<Entry>> = self.buckets[bucket_index].as_mut();

        while let Some(entry) = ptr.take() {
            if entry.hash == hash && *entry.string == *string {
                if entry.ref_count.fetch_add(1, SeqCst) > 0 {
                    return NonNull::from(&mut **entry);
                }
                entry.ref_count.fetch_sub(1, SeqCst);
                break;
            }
            ptr = entry.next_in_bucket.as_mut();
        }
    }
    debug_assert!(mem::align_of::<Entry>() >= ENTRY_ALIGNMENT);
    let string = string.into_owned();
    let mut entry = Box::new(Entry {
        next_in_bucket: self.buckets[bucket_index].take(),
        hash,
        ref_count: AtomicIsize::new(1),
        string: string.into_boxed_str(),
    });
    let ptr = NonNull::from(&mut *entry);
    self.buckets[bucket_index] = Some(entry);

    ptr
}

看起来它正在查找一个存储字符串的存储桶,如果存储桶中没有该字符串,它将插入该字符串。

💡 这是线性探测吗?如果是线性探测,那么这个 `Set` 只是一个 `HashMap`,并未说明它是一个 `HashMap`。💡 如果这是一个 `HashMap`,那么 `Mutex<HashMap>` 就是一个并发 hashmap。

尽管在知道该查找什么时解决方案似乎很简单,但我花了一个月才弄清楚,因为我不知道这个问题。当很明显这只是一个并发 hashmap 时,将 Mutex 应用到存储桶而不是整个 hashmap 就是一个清晰且合乎逻辑的解决方案。在实施这一变更的一小时内,我提交了拉取请求,并对结果感到满意 😃。

https://github.com/servo/string-cache/pull/268

有必要提到在 Rust 社区里,字符串池化是备受争议的问题。对于这篇博文中的示例,有一些单线程代码库,比如string-internerlassolalrpop-internintagliostrena

由于我们正在并行解析文件,一种选择是使用多线程字符串池化代码库,例如 ustr。然而,经过对 ustr 和升级版 string-cache 的分析,发现在性能上显然低于我将在下面解释的方法。

对性能较差的一些初步猜测:

  • 哈希 - 池化需要对字符串进行哈希以消除重复数据
  • 间接引入 - 我们需要从“远端的”堆中读取字符串值,这是不利于缓存的

字符串内联

于是我们回到了需要分配大量字符串的最初问题。幸运的是,如果我们着眼于所处理的数据类型:JavaScript 中的短变量名和短字符串,就有这个难题的部分解决方案。有一种名为字符串内联的技术,我们把所有字符串的字节存储在堆栈上。

本质上,我们希望通过枚举来存储字符串。

rust
enum Str {
    Static(&'static str),
    Inline(InlineReprensation),
    Heap(String),
}

为了最大程度地减小枚举的大小,InlineRepresentation 应与 String 大小一致。

rust
#[cfg(all(target_arch = "x86_64", target_pointer_width = "64"))]
#[test]
fn test_size() {
    use std::mem::size_of;
    assert_eq!(size_of::<String>(), size_of::<InlineReprensation>());
}

Rust 社区里许多模块旨在优化内存使用率。该社区一直为此争论不休。最普遍的模块如下:

每个模块对如何实现内存优化有其独特的特性和方法,在选择时需要综合考虑各种折中方法和考量因素。例如,smol_strflexstr 的克隆是 O(1)。flexstr 可以存储 22 个字节,smol_strsmartstring 可以存储 23 个字节,在 64 位系统中,compact_str 可以存储 24 个字节。

https://fasterthanli.me 对该主题进行 了深入的研究

String 改为 compact_str::CompactStr 可以大幅减少内存分配。

词法分析器

词素

词法分析器(也称作标记发生器)的目的是将源文本转化为名为词素的结构化数据。

rust
pub struct Token {
    pub kind: Kind,
}

为了便于处理,词素类型通常被定义为 Rust 中的枚举。枚举的变量保存着每个词素的相应数据。

rust
pub enum Kind {
    // Keywords
    For,
    While,
    ...
    // Literals
    String(String),
    Num(f64),
    ...
}

目前此枚举使用 32 个字节,并且分析器通常需要构造数百万个此令牌 Kind。每次构造一个 Kind::ForKind::While 时,它都必须在堆上分配 32 个字节的内存。

改善这一点的巧妙方法是分离枚举变体,保持 Kind 为一个字节并把值移动到另一个枚举中,

rust
pub struct Token<'a> {
    pub kind: Kind,
    pub value: TokenValue
}

pub enum TokenValue {
    None,
    String(String),
    Num(f64),
}

由于我们控制所有的解析代码,所以始终声明与它的种类相对应的令牌值以保持此代码安全是我们的职责。

虽然 32 字节的 TokenValue 已经很小了,但由于它被频繁分配,因此仍然会对性能产生负面影响。

我们来看看 String 类型并查看我们可以找到什么,通过在我们的代码编辑器中使用“转到定义”,我们将浏览 String -> Vec -> RawVec

rust
pub struct String {
    vec: Vec<u8>,
}

pub struct Vec {
    buf: RawVec<T, A>,
    len: usize,
}

pub struct RawVec {
    ptr: Unique<T>,
    cap: usize,
    alloc: A,
}

如你所见,一个 String 只是一个 Vec,由 u8 组成,而一个 Vec 具有长度和容量域。由于我们永远不会改变此字符串,因此在内存使用方面的一个优化方法是删除上限域并改用字符串切片 (&str)。

rust
pub enum TokenValue<'a> {
    None,
    String(&'a str),
    Num(f64),
}

TokenValue 变成 24 个字节。

虽然在 TokenValue 中使用字符串切片而不是 String 会减少内存使用,但它带来的缺点是添加了生存期注释。这会导致借用检查器出现问题,而且生存期注释会传播到代码库的其余部分,从而使我们的代码难以管理。8 个月前我输了借用检查游戏,但当重拾此事时我 终于赢了

在有意义的情况下,我们始终可以使用不可变数据的受拥有版本,而不是使用引用。例如,使用 Box<str> 代替 String,使用 Box<[u8]> 代替 Vec<u8>

总而言之,我们始终可以想出一些窍门,让我们的数据结构保持较小,而且这有时会为我们带来性能改进。

Cow

第一次遇到术语 Cow 是在学习 jsparagus 的代码时,它有一个名为 AutoCow 的基础设施。

我隐约理解代码的用途。当 JavaScript 字符串被标记化时,当遇到转义序列时它会分配一个新字符串,否则它会返回原始字符串切片

rust
fn finish(&mut self, lexer: &Lexer<'alloc>) -> &'alloc str {
    match self.value.take() {
        Some(arena_string) => arena_string.into_bump_str(),
        None => &self.start[..self.start.len() - lexer.chars.as_str().len()],
    }
}

这是很巧妙的,因为在 99.9% 的情况下,它不会分配一个新字符串,因为转义字符串很少见。

但是,术语 Cow 或“克隆可写智能指针”对我来说一直没有意义。

Cow 类型是一种智能指针,提供克隆可写功能:它可以封装并提供借用数据的不可变访问,并在需要更改或所有权时惰性克隆数据。该类型旨在通过 Borrow 特性与通用借用数据一起使用。

如果您是 Rust 新手(像我一样),那么此描述根本没有帮助(我仍然不明白它在说什么)。

有人为我 指出来clone-on-write 只是此数据结构的一种用例。一个更好的名称应该是 RefOrOwned,因为它是一个包含自有数据或引用的类型。

SIMD

当浏览旧的 Rust 博客时,宣布便携式 SIMD 项目组引起了我的注意。我一直想使用 SIMD,但从未有机会。经过一些研究,我找到了可能适用于解析器的一个用例:您能多快从字符串中删除空格?(作者:Daniel Lemire)。因此,事实证明,这已经在名为 RapidJSON 的 JSON 解析器中执行过了,它 使用 SIMD 删除空格

因此,最终,在便携式 SIMD 和 RapidJSON 代码的帮助下,我不仅成功地 跳过了空格,我还成功 跳过了多行注释

这两项更改将性能提高了几个百分点。

关键字匹配

在性能分析的顶部,有一个热点代码路径,约占总体执行时间的 1 - 2%。

它尝试将一个字符串与一个 JavaScript 关键字匹配

rust
fn match_keyword(s: &str) -> Self {
    match s {
        "as" => As,
        "do" => Do,
        "if" => If,
        ...
        "constructor" => Constructor,
        _ => Ident,
    }
}

在添加了 TypeScript 后,有 84 个字符串需要我们进行匹配。经过一些研究,我找到了 V8 的一篇博客 极速解析,第 1 部分:优化扫描器,它详细地描述了其 关键字匹配代码

由于关键字列表是静态的,我们可以计算一个完美哈希函数,它对每个标识符最多给我们一个候选关键字。V8 使用 gperf 计算此函数。结果通过长度和前两个标识符字符计算出一个哈希,以找到单个候选关键字。只有当关键字的长度与输入标识符长度匹配时,我们才将标识符与关键字进行比较。

因此,快速哈希加上整数比较应该比 84 个字符串比较更快。但我们再次 尝试,但 又一次徒劳无功。

事实证明,LLVM 已优化了我们的代码。在 `rustc` 上使用 `--emit=llvm-ir`,我们找到了相关代码

  switch i64 %s.1, label %bb6 [
    i64 2, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit.i"
    i64 3, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit280.i"
    i64 4, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit325.i"
    i64 5, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit380.i"
    i64 6, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit450.i"
    i64 7, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit540.i"
    i64 8, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit590.i"
    i64 9, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit625.i"
    i64 10, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit655.i"
    i64 11, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit665.i"
  ], !dbg !191362

%s 是字符串,%s.1 是其长度 ... 它正在基于字符串长度进行分支!编译器比我们聪明 😃。

(是的,我们对此非常认真,以至于我们开始着眼于 LLVM IR 和汇编代码)。

稍后,@strager 在此主题上发布了一个极具教育意义的 YouTube 视频 比 Rust 和 C++ 更快:完美的哈希表。该视频教会我们一种系统化的解决方法来推理微调性能问题

最后,我们得出的结论是,简单的关键字匹配对我们来说就足够了,因为它只占性能的 1 - 2%,并且在上面花费数天后,就其价值而言,并不值得付出努力 - Rust 并不具备我们构建这个完美哈希表所需的所有组件。

Linter

Linter 是一种用于分析源代码中问题(例如拼写错误、语法错误、样式问题甚至更复杂的问题(如代码异味))的程序。

最简单的 linter 访问每个 AST 节点并检查规则。可以使用访问者模式

rust
pub trait Visit<'a>: Sized {
    // ... lots of visit functions

    fn visit_debugger_statement(&mut self, stmt: &'a DebuggerStatement) {
        // report error
    }
}

父指向树

使用访问者很容易向下遍历 AST,但是如果我们想要向上遍历树以收集某些信息,该怎么办?

由于无法向 AST 节点添加指针,因此在 Rust 中解决这个问题特别具有挑战性。

让我们暂时忘记 AST,而专注于具有节点指向其父节点的指针的属性的通用树。为了构建通用树,每个树节点都必须是相同的类型 `Node`,我们可以使用 `Rc` 引用它们的父节点

rust
struct Node {
    parent: Option<Rc<Node>>,
}

如果我们需要突变,使用这种模式会很乏味,而且它也不高效,因为必须在不同的时间丢弃节点。

更有效的解决方案是使用 `Vec` 作为其后备存储,并对指针使用索引。

rust
struct Tree {
    nodes: Vec<Node>
}

struct Node {
    parent: Option<usize> // index into `nodes`
}

indextree 是一个针对此任务而生的出色库。

回到我们的 AST,我们可以通过让节点指向包装每种 AST 节点的枚举来构建一个 `indextree`。我们将其称为非类型化的 AST。

rust
struct Node<'a> {
    kind: AstKind<'a>
}

enum AstKind<'a> {
    BlockStatement(&'a BlockStatement<'a>),
    // ...
    ArrayExpression(&'a ArrayExpression<'a>),
    // ...
    Class(&'a Class<'a>),
    // ...
}

最后一块缺失的部分是在访问者模式中具有用于构建此树的回调。

rust
pub trait Visit<'a> {
    fn enter_node(&mut self, _kind: AstKind<'a>) {}
    fn leave_node(&mut self, _kind: AstKind<'a>) {}

    fn visit_block_statement(&mut self, stmt: &'a BlockStatement<'a>) {
        let kind = AstKind::BlockStatement(stmt);
        self.enter_node(kind);
        self.visit_statements(&stmt.body);
        self.leave_node(kind);
    }
}

impl<'a> Visit<'a> for TreeBuilder<'a> {
    fn enter_node(&mut self, kind: AstKind<'a>) {
        self.push_ast_node(kind);
    }

    fn leave_node(&mut self, kind: AstKind<'a>) {
        self.pop_ast_node();
    }
}

最终的数据结构变为 `indextree::Arena<Node<'a>>`,其中每个 `Node` 都指向一个 `AstKind<'a>`。可以调用 `indextree::Node::parent` 来获取任何节点的父节点。

如果制作此父级指针树,则可以访问 AST 节点而无需实现任何访问器,这将带来良好的好处。对于 indextree 内的所有节点而言,语言规则检查器将变成一个简单的循环

rust
for node in nodes {
    match node.get().kind {
        AstKind::DebuggerStatement(stmt) => {
        // report error
        }
        _ => {}
    }
}

在此处提供了一个完整示例 here

乍看之下,此流程似乎缓慢且效率低下。然而,通过内存区域访问已键入 AST 并将指针推入 indextree 是高效的线性内存访问模式。当前基准测试表明,此方法比 ESLint 快 84 倍,因此对于我们的目的而言,这肯定足够快。

并行处理文件

语言规则检查器使用 ignore 箱用于目录遍历,它支持 .gitignore 并添加其他忽略文件,例如 .eslintignore

此箱的其中一个小问题是没有并行接口,对于 ignore::Walk::new(".") 没有 par_iter

而可以 使用基本元素

rust
let walk = Walk::new(&self.options);
rayon::spawn(move || {
    walk.iter().for_each(|path| {
        tx_path.send(path).unwrap();
    });
});

let linter = Arc::clone(&self.linter);
rayon::spawn(move || {
    while let Ok(path) = rx_path.recv() {
        let tx_error = tx_error.clone();
        let linter = Arc::clone(&linter);
        rayon::spawn(move || {
            if let Some(diagnostics) = Self::lint_path(&linter, &path) {
                tx_error.send(diagnostics).unwrap();
            }
            drop(tx_error);
        });
    }
});

这会解锁一个有用的特性,我们可以在一个线程中打印所有诊断,这将引导我们转到本文的最后一部分。

打印缓慢

打印诊断很快,但我已经在这个项目上工作了很长时间,好像每次我在大型单体仓库上运行语言规则检查器时,打印成千上万条诊断消息需要一个世纪。因此,我开始搜索 Rust GitHub 问题,最终发现了相关问题

总之,每次遇到换行符时,println! 调用都会锁定 stdout,这称为行缓冲。为了使打印速度更快,我们需要选择加入块缓冲,此处 记录了块缓冲。

rust
use std::io::{self, Write};

let stdout = io::stdout(); // get the global stdout entity
let mut handle = io::BufWriter::new(stdout); // optional: wrap that handle in a buffer
writeln!(handle, "foo: {}", 42); // add `?` if you care about errors here

或获取 stdout 上的锁。

rust
let stdout = io::stdout(); // get the global stdout entity
let mut handle = stdout.lock(); // acquire a lock on it
writeln!(handle, "foo: {}", 42); // add `?` if you care about errors here

根据 MIT 许可证发布。