Skip to content

构建 JavaScript 编译器过程中的性能追求

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

性能方面

经过两年的 Rust 编程,性能已深深植根于我的编程习惯中——归根结底就是
减少内存分配减少 CPU 周期使用

然而,如果没有对问题领域的了解或对潜在解决方案的认知,实现最佳性能会非常困难。

接下来的部分将带你踏上我探索性能与优化之旅。
我最偏好的学习方式是结合研究、尝试与错误,因此以下内容将按此结构组织。

解析

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

抽象语法树(AST)

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

所有 JavaScript 工具都在 AST 层面上工作,例如:

  • 一个检查器(如 ESLint)在 AST 上查找错误
  • 一个格式化工具(如 prettier)将 AST 重新打印为 JavaScript 文本
  • 一个压缩工具(如 terser)转换 AST
  • 一个打包工具将来自不同文件的多个 AST 的导入和导出语句连接起来

如果 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 节点通过 BoxVec 在堆上分配,它们是单独分配的,因此按顺序被释放。

有没有办法缓解这个问题?

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

这两个解析器在声明其 AST 时使用了生命周期标注,

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

并且都有一个名为 arena.rs 的配套文件。

起初我不理解它的作用,于是忽略了它们,直到我开始阅读它们关于内存区域(memory arenas)的使用: bumpalotoolshed

简而言之,内存区域预先以块或页的形式分配内存,并在区域被释放时一次性释放。
由于 AST 被分配在该区域中,因此释放整个 AST 成为一次快速操作。

另一个附带的好处是, 由于 AST 按特定顺序构造,遍历也遵循相同的顺序,从而在访问过程中实现线性内存访问。 这种访问模式将非常高效,因为附近的所有内存都将被加载到 CPU 缓存页中,从而获得更快的访问速度。

不幸的是,对 Rust 初学者来说使用内存区域可能具有挑战性,因为所有数据结构及相关函数都需要用生命周期标注进行参数化。 我花了五次尝试才成功将 AST 放入 bumpalo

将 AST 改为使用内存区域后,性能提升了约 20%。

枚举大小

由于 AST 的递归性质,我们必须以某种方式定义类型,以避免“无间接递归”的错误:

错误[E0072]:递归类型 `Enum` 与 `Variant` 具有无限大小
 --> crates/oxc_linter/src/lib.rs:1:1
  |
1 | enum Enum {
  | ^^^^^^^^^
2 |     Variant(Variant),
  |             ------- 无间接递归导致的递归
3 | }
4 | struct Variant {
  | ^^^^^^^^^^^^^^
5 |     field: Enum,
  |            ---- 无间接递归导致的递归
  |
帮助:插入一些间接引用(如 `Box`、`Rc` 或 `&`)以打破循环
  |
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 字节载荷
    B(String), // 24 字节载荷
    C { first: String, last: String }, // 48 字节载荷
}

在典型的 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% 的提速。

位置信息(Span)

有时,我们直到花额外时间审视数据结构,才会意识到更小的内存占用是可能的。

在此情况下,所有 AST 节点的叶节点包含一个称为“位置信息”(span)的小型数据结构,用于存储源文本中的字节偏移量,由两个 usize 组成。

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

有人曾指出我,我可以安全地将 usize 改为 u32 以减少峰值内存,因为大于 u32 的值对应于 4GB 文件。

改为 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 Interning)

当存在大量堆分配的字符串时,一种称为 字符串驻留 的技术可用于通过仅存储每个唯一字符串值的一份拷贝来减少总内存。

string-cache 是由 servo 团队发布的一个流行且广泛使用的库。 起初,我使用 string-cache 库来处理 AST 中的标识符和字符串。
解析器在单线程下表现很快,
但当我开始实现多线程并行运行多个解析器的检查器(linter)时,
发现所有核心的平均利用率只有约 50%。

性能剖析后,parking_lot::raw_mutex::RawMutex::lock_slow 方法出现在执行时间顶部。 我对锁和多核编程知之甚少,
但全局锁本身就显得很奇怪,
因此我决定移除 string-cache 库以实现全部核心的充分利用。

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

string-cache

半年后,在处理另一个性能关键项目时,
string-cache 库再次浮现。它在并行文本解析期间阻塞了所有线程。

我决定研究一下 string-cache 的原理,因为我这次已经读过 Mara Bos 所著《Rust Atomics and Locks》 一书,准备充分。

以下是围绕锁的相关代码片段,请注意这段代码是 2015 年八年前写的。

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

// ... 在另一处
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 就是一个没有声明自己是 HashMapHashMap
💡 如果这是 HashMap,那么 Mutex<HashMap> 就是一个并发哈希表。

尽管当我们知道要寻找什么时,解决方案看似简单,但我花了整整一个月才弄明白,因为当时我并未意识到这个问题。
一旦意识到这其实只是一个并发哈希表,将 Mutex 应用于桶而不是整个哈希表就成为了一个清晰且合乎逻辑的解决方案。
在实施这一更改后一小时内,我提交了拉取请求,对结果感到满意 😃。

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

值得一提的是,字符串驻留是 Rust 社区中的一个争议领域。 例如,在 这篇博客文章 中展示的示例,
存在单线程的库如 string-internerlassolalrpop-internintaglio 以及 strena

由于我们正在并行解析文件,一个选择是使用多线程字符串驻留库,例如 ustr
然而,在对 ustr 以及增强版的 string-cache 进行剖析后,
我们发现性能仍低于预期,而我即将解释的方案将更好。

对性能不佳的初步猜测包括:

  • 哈希 —— 驻留库需要对字符串进行哈希以去重
  • 间接引用 —— 我们需要从“远处”的堆中读取字符串值,这不利于缓存

字符串内联(String Inlining)

因此我们又回到了需要分配大量字符串的问题。 幸运的是,如果我们观察所处理的数据类型,即短的 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 字节,而 compact_str 在 64 位系统上可存储 24 字节。

https://fasterthanli.me 有一篇深入探讨此话题的文章 Rust 中的小字符串

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

词法分析器

令牌(Token)

词法分析器(也称标记器)的任务是将源文本转换为称为“令牌”的结构化数据。

rust
pub struct Token {
    pub kind: Kind,
}

为了便于使用,令牌类型通常在 Rust 中定义为枚举。枚举的变体持有每个令牌对应的相应数据。

rust
pub enum Kind {
    // 关键字
    For,
    While,
    ...
    // 字面量
    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),
}

由于我们控制所有解析代码,因此我们的责任是始终为每个令牌类型声明相应的令牌值。

虽然 TokenValue 占用 32 字节已相当小,但它仍可能因频繁分配而对性能产生负面影响。

让我们来看看 String 类型,看能否找到一些线索,通过代码编辑器中的“跳转定义”功能, 我们将查看 StringVecRawVec

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 只是 u8Vec,而 Vec 包含长度和容量字段。 由于我们从不修改这个字符串,因此在内存使用上的一个优化是去掉容量字段,并改用字符串切片(&str)。

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

TokenValue 变为 24 字节。

虽然在 TokenValue 中使用字符串切片而非 String 可以减少内存使用,但它也带来了增加生命周期标注的缺点。 这可能导致借用检查器问题,并且生命周期标注会传播到整个代码库,使我们的代码管理变得有些困难。 我八个月前输掉了借用检查游戏,但当我重新审视这个问题时,终于赢了 这里

当合适时,我们总是可以选择不可变数据的拥有版本,而非使用引用。 例如 String 使用 Box<str>Vec<u8> 使用 Box<[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(像我当初一样),这个描述完全帮不上忙(我仍然不明白它在说什么)。

有人曾指出我,“写时复制”只是这种数据结构的一个用例。
更好的名字应该是 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 的一篇博客 闪电般快速的解析,第一部分:优化扫描器, 其中详细描述了其 关键字匹配代码

由于关键字列表是静态的,我们可以计算一个完美的哈希函数,使得每个标识符最多只给出一个候选关键字。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)

检查器是一个分析源代码以发现问题的程序。

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

rust
pub trait Visit<'a>: Sized {
    // ... 大量访问函数

    fn visit_debugger_statement(&mut self, stmt: &'a DebuggerStatement) {
        // 报告错误
    }
}

父指针树

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

这个问题在 Rust 中尤其具有挑战性,因为无法在 AST 节点中添加指针。

先暂时忘掉 AST,专注于具有“节点指向其父节点”属性的通用树。 为了构建通用树,每个树节点必须是相同类型 Node,我们可以使用 Rc 引用其父节点:

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

如果需要变异,使用这种模式会很繁琐,
而且性能不高,因为节点必须在不同时刻被释放。

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

rust
struct Tree {
    nodes: Vec<Node>
}

struct Node {
    parent: Option<usize> // 指向 `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) => {
        // 报告错误
        }
        _ => {}
    }
}

完整示例见 此处

乍一看,这个过程似乎缓慢且低效。 然而,通过内存区域遍历有类型的 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(); // 获取全局 stdout 实体
let mut handle = io::BufWriter::new(stdout); // 可选:将该句柄包装进缓冲区
writeln!(handle, "foo: {}", 42); // 如果关心错误,添加 `?`

或者显式获取 stdout 的锁。

rust
let stdout = io::stdout(); // 获取全局 stdout 实体
let mut handle = stdout.lock(); // 获取其锁
writeln!(handle, "foo: {}", 42); // 如果关心错误,添加 `?`