Rust高可靠:互斥锁须在同一线程内锁住和释放


如果你是一名码农,或许曾经历过这样的困境:项目初期进展神速,代码写得飞起,可随着功能越来越多、代码量越来越大,你突然发现自己再也不敢轻易改动了——生怕一动就崩。更可怕的是,有些bug只在生产环境出现,本地永远复现不了。

最近,一位名叫Lubeno的开发者分享了他的亲身经历。他的后端项目100%采用Rust编写,当代码库膨胀到无法完全记住所有细节时,他意外发现:自己的生产力不但没有下降,反而因为Rust的独特特性获得了提升。这完全颠覆了“动态类型语言在Web开发中更高效”的传统认知。

今天,我们就来深入聊聊Rust是如何通过其强大的类型系统和编译器保障,让开发者在复杂项目中依然能保持高效和自信的。

一个让人后怕的并发bug

Lubeno在代码中遇到了一个看似简单的需求:需要将一个结构体用互斥锁包装起来,以便在多线程环境中安全使用。他写了这样一段代码:

rust
let lock = mutex.lock();
// … 使用锁定的数据生成提交 …
db.insert_commit(commit).await;

看起来完全正确,rust-analyzer也没有报任何错误。但奇怪的是,项目中的另一个文件(路由定义)突然出现了编译错误。这让他十分困惑:一个锁操作怎么会影响到路由处理程序呢?

错误信息显示:“future无法在线程间安全发送”,原因是“MutexGuard未实现Send trait”。这背后隐藏着一个深层的并发问题。

在现代Web框架中,每个HTTP请求都会在一个异步任务中处理,这些任务由工作窃取调度器执行——当某个线程空闲时,它会从其他线程“偷”任务来执行。这种调度只能发生在Rust的.await点。

但这里有个关键规则:如果互斥锁在某个线程上被锁定,它必须在同一线程上释放,否则会导致未定义行为。

Rust的生命周期系统发现这个锁存活时间足够长,甚至跨越了.await点。这意味着锁的释放可能发生在不同线程上,这是不允许的。

解决方案其实很简单:在.await之前显式释放锁。但这种问题如果不被编译器捕获,几乎不可能在开发阶段发现——因为通常系统负载不够高,不足以触发调度器将任务移到其他线程。最终你会得到一个“无法复现、偶尔发生、但从不为你发生”的幽灵bug。

令人惊叹的是,Rust编译器能够检测到这种微妙的问题。看似无关的语言特性——互斥锁、生命周期和异步操作——竟然形成了如此一致的系统。

TypeScript的可怕对比

相比之下,Lubeno团队在TypeScript代码库中遇到的一个异步bug,在发布到生产环境后很长时间都没有被发现。以下是问题代码:

typescript
// 用户登录成功!
if (redirect) {
    window.location.href = redirect;
}

let content = await response.json();
if (content.onboardingDone) {
    window.location.href = "/dashboard";
} else {
    window.location.href = "/onboarding";
}

逻辑很简单:登录时检查是否有重定向参数,如果有就重定向到指定页面;否则根据用户状态跳转到仪表板或 onboarding 页面。

开发者原本认为这样写没问题,但实际上存在一个调度竞态条件:给window.location.href赋值并不会立即重定向,而是设置值并在可能的时候调度重定向。代码会继续执行!这意味着后续的赋值可能会在浏览器开始重定向之前执行,导致用户被重定向到错误的位置。

这个问题的解决方案是添加return语句:

typescript
if (redirect) {
    window.location.href = redirect;
    return;
}

这两种问题(Rust和TypeScript)其实很相似,都与异步调度有关,都表现出一些非显而易见的未定义行为。但Rust类型检查器更有用,它阻止了bug进入编译阶段;而TypeScript编译器不跟踪生命周期,也没有借用规则,根本无法捕获这类问题。

无畏重构:Rust的超级力量

Rust通常被推荐为系统编程的绝佳语言,但很少被认为是Web应用的首选。Python、Ruby和JavaScript/Node.js总是被认为在Web开发中更“高效”。如果你刚刚开始项目,这确实是对的——这些语言开箱即用,初始进展非常快。

但当项目达到一定规模后,一切都会陷入停滞。代码库各部分之间存在大量松散耦合,使得更改任何内容都变得非常困难。

我们都经历过:你更改了某些内容,一切看起来都很正常,但两天后收到通知,说你的更改破坏了另一个(完全无关的)页面。在这种情况发生三次之后,你接触代码库的意愿会急剧下降。

使用Rust,我的担心少了很多,这让我更愿意尝试新事物。我感觉随着代码库的增长,我的生产力甚至提高了。有更多代码我可以构建、重用和更改,而不必担心意外破坏现有功能。

Rust非常擅长告诉你:“是的,你正在做的更改影响了项目的另一个部分,你可能根本没有想到这一点,因为你已经深入六个函数调用而且截止日期临近,但这里确切解释了为什么这可能引起问题。”

测试的作用与局限

测试当然很棒!如果你正在进行大型重构并需要帮助捕捉回归问题,测试是非常强大的工具。但编译器并不要求测试才能运行代码,这意味着你可以轻易决定不添加测试。

有些日子压力很大,时间紧迫,事情必须完成。但测试会带来额外的心理开销:我需要决定正确的抽象级别是什么?我是在测试行为还是实现细节?这个测试真的能防止未来的错误吗?做所有这些决定非常累人且容易出错。

Rust的学习和编写有时可能具有挑战性,但好处是它免去了我做决定的负担。这些决定是由比我聪明得多的人做出的,他们在巨大的代码库上工作过,并将所有常见错误编码到编译器中。

当然,应用程序的某些属性不能成为类型系统的一部分。在这种情况下,测试就显得尤为重要!

附赠:Zig也很可怕!

Zig经常与Rust相提并论,两者都旨在成为系统编程语言。我认为Zig非常酷,这门语言能激发我内心的极客热情。但然后我又记得它也很可怕。让我们看一个简单的错误处理示例:

zig
const std = @import("std");

const FileError = error{
    AccessDenied,
};

fn doSomethingThatFails() FileError!void {
    return FileError.AccessDenied;
}

pub fn main() !void {
    doSomethingThatFails() catch |err| {
        if (err == error.AccessDenid) {
            std.debug.print("Access was denied!\n", .{});
        } else {
            std.debug.print("Unexpected error!\n", .{});
        }
    };
}

这段代码有个拼写错误:AccessDenid vs AccessDenied。代码会正常编译,因为Zig编译器为每个独特的error.*生成一个新数字,并不关心你比较的是什么类型——它只是数字。

然而,如果你使用switch语句而不是if,Zig编译器突然会说:“这显然是错的!返回的错误永远不能是这个值,因为它不在FileError中”,并拒绝编译代码。它能够检测到bug,只是选择不关心。如果它看起来像数字,if语句不妨将其作为数字比较。

语言的这些小型设计决策与Rust形成鲜明对比。对于经常拼错名称的人来说,这可能会很可怕。

结语

Rust的生产力曲线与传统语言截然不同。它可能起步较慢,但随着项目复杂度的增加,它的优势越来越明显。强大的类型系统、所有权模型和borrow checker共同构成了一个安全网,让开发者能够自信地重构和扩展代码库。

如果你正在开始一个中长期项目,特别是那些需要高可靠性和并发性能的项目,Rust值得认真考虑。它可能会在开始时让你感到有些挫折,但随着代码库的增长,你会感谢它为你避免的那些潜在灾难。

你是否也在项目中体验过Rust的生产力优势?或者遇到过类似的语言对比经历?欢迎在评论区分享你的故事!