如果你是一名码农,或许曾经历过这样的困境:项目初期进展神速,代码写得飞起,可随着功能越来越多、代码量越来越大,你突然发现自己再也不敢轻易改动了——生怕一动就崩。更可怕的是,有些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的生产力优势?或者遇到过类似的语言对比经历?欢迎在评论区分享你的故事!