Rust 1.80发布之后,我们团队把几个项目的Rust版本升级到了最新的稳定版。
升级的过程中,以及升级之后用新版本开发的过程中,我们踩了不少坑。有些是Rust语言本身的特性导致的,有些是新版本的变化带来的,还有些是我们自己对Rust理解不够深入造成的。
这篇文章我想把这些踩坑经历和实战经验分享出来。从所有权、生命周期、借用检查,到异步编程、 trait 系统、编译优化,聊聊用Rust 1.80+开发过程中遇到的问题和解决方法。如果你也在用Rust开发,或者准备学习Rust,希望这篇文章能帮你少走一些弯路。
先说明一下,Rust的版本更新很快,本文提到的一些特性和问题,可能在后续版本中会有变化。但Rust的核心设计理念,比如所有权、借用、安全等,是比较稳定的,相关的经验应该还是有参考价值的。
为什么要用Rust
先说说我们为什么选择Rust。
我们团队主要做后端服务和系统工具开发。之前主要用Go和Python,Go做高性能服务,Python做脚本和数据处理。但在一些对性能和安全性要求很高的场景下,Go和Python都有一些不足。
Go的性能不错,但GC有时候会造成延迟抖动,在对延迟要求极高的场景下不太理想。Python的性能就更不用说了,只适合做轻量级的脚本。
Rust正好填补了这个空白。它没有GC,性能和C/C++相当,同时又有内存安全和线程安全的保证。用Rust写的程序,运行效率高,内存占用小,而且不容易出现内存安全问题。
我们用Rust做了几个项目,包括一个高性能的代理服务器、一个命令行工具、一个数据库引擎。用下来的感受是:Rust的学习曲线确实比较陡,但一旦掌握了,开发效率和运行效率都很高。
Rust 1.80版本带来了一些新特性,比如更智能的借用检查器、更好的异步支持、更稳定的特性等。升级到新版本之后,整体的开发体验有提升,但也遇到了一些新的问题。
坑一:所有权和借用的纠缠
第一个坑,也是Rust最经典的坑,就是所有权和借用。
刚开始用Rust的时候,我经常被借用检查器搞得头大。写一段代码,编译报错,说"cannot borrow x as mutable because it is also borrowed as immutable",或者"borrowed value does not live long enough"。改来改去,就是编译不过。
比如下面这段代码,看起来很简单,但就是编译不过:
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);编译器会报错,因为first是v的不可变借用,而v.push(4)需要v的可变借用。在Rust中,不可变借用和可变借用不能同时存在,所以编译不过。
刚开始我觉得这个限制很不合理,不就是先取了第一个元素的引用,然后加一个元素吗,有什么大不了的。但后来我理解了,Rust的这个限制是为了内存安全。如果v.push(4)导致了vector的扩容,原来的内存被释放了,那first就成了悬垂指针,访问它就会造成内存安全问题。
理解了这一点之后,我就不再和借用检查器对抗了,而是学会了顺着它的规则来写代码。
我的经验是:
第一,尽量缩短借用的生命周期。不要让借用的范围太大,用完就释放。比如上面的代码,可以把first的使用放在push之前,或者直接不用引用,用拷贝的值。
第二,必要的时候用克隆。如果借用实在太麻烦,而数据量不大,可以直接克隆一份。虽然克隆有性能开销,但在很多场景下,这点开销可以忽略不计,而代码的可读性和可维护性会提升很多。
第三,用作用域来限制借用。可以用花括号创建一个新的作用域,让借用在作用域结束时自动释放。这样可以在同一个函数中,先有不可变借用,释放之后再有可变借用。
第四,理解非词法生命周期。Rust 1.80的借用检查器已经支持非词法生命周期,也就是说,借用的生命周期不再严格按照词法作用域,而是按照实际的使用范围。这让很多以前编译不过的代码现在能编译过了。理解了这一点,可以少写很多多余的花括号。
坑二:生命周期标注的烦恼
第二个坑是生命周期标注。
Rust的生命周期标注,是很多初学者的噩梦。比如写一个函数,返回两个参数中较长的那个字符串切片,就需要标注生命周期:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}刚开始写的时候,我总是搞不清楚什么时候需要标注生命周期,该怎么标注。经常是编译器报错了,我就照着错误信息加标注,加来加去还是不对。
后来我总结了几个规律:
第一,函数的参数中如果有引用,返回值也是引用,就需要标注生命周期。因为编译器需要知道返回值的生命周期和哪个参数的生命周期相关。
第二,如果函数只有一个引用参数,返回值也是引用,那编译器可以自动推断生命周期,不需要手动标注。这叫生命周期省略规则。
第三,如果函数有多个引用参数,返回值也是引用,就需要手动标注生命周期,告诉编译器返回值和哪个参数的生命周期一致。
第四,结构体中如果有引用字段,就需要给结构体标注生命周期,告诉编译器这些引用的生命周期和结构体的生命周期是什么关系。
理解了这些规律之后,生命周期标注就没有那么难了。大部分情况下,按照编译器的提示来标注,都能搞定。
Rust 1.80在生命周期方面也有一些改进,比如更智能的生命周期推断,减少了需要手动标注的情况。但在一些复杂的场景下,还是需要手动标注。
我的建议是:不要害怕生命周期标注,多写多练,慢慢就熟悉了。刚开始可能觉得很繁琐,但写多了之后,你会发现生命周期标注其实是在帮助你思考代码中的数据流动和所有权关系。
坑三:异步编程的复杂性
第三个坑是异步编程。
Rust的异步编程用async/await语法,看起来和其他语言差不多,但实际上复杂得多。因为Rust的异步是基于Future trait的,需要运行时来执行,而且涉及到Pin、Waker等概念,理解起来比较困难。
我们在做异步网络服务的时候,踩了不少坑。
第一个坑是异步运行时的选择。Rust本身不提供异步运行时,需要用第三方库,最常用的是tokio。但tokio的版本更新比较快,不同版本之间有一些不兼容的地方。我们升级的时候,就遇到过一些API变化导致的编译错误。
我的建议是:选一个稳定的tokio版本,不要频繁升级。升级的时候,仔细看changelog,了解有哪些破坏性变化。
第二个坑是async函数中的借用问题。在async函数中,借用检查器会变得更严格,因为async函数的状态机会跨越多点保存,借用的生命周期会更长。
比如,在async函数中持有一个引用,然后await一个异步操作,这个引用的生命周期就会跨越await点,可能会和其他借用冲突。
我的经验是:在async函数中,尽量少持有引用,特别是跨await点的引用。如果需要持有数据,尽量用拥有所有权的类型,或者用Arc等智能指针。
第三个坑是异步trait的问题。在Rust 1.80之前,trait中的async函数需要用async-trait这个第三方库来实现,而且会有一些性能开销。Rust 1.75之后,原生支持了async fn in trait,但还有一些限制。
我们在升级的时候,把一些用async-trait的代码改成了原生的async fn in trait,减少了依赖,性能也有一些提升。但需要注意,原生的async fn in trait目前还不支持在trait object中使用,如果需要动态分发,还是需要用async-trait或者手动实现Future。
第四个坑是阻塞操作的问题。在异步运行时中,不能执行阻塞操作,否则会阻塞整个运行时的线程,导致其他任务无法执行。比如,在async函数中调用std::thread::sleep,或者执行耗时的CPU密集型计算,都会阻塞运行时。
正确的做法是:用异步版本的API,比如tokio::time::sleep;CPU密集型的计算,用tokio::task::spawn_blocking放到专门的阻塞线程池中执行。
坑四:trait系统的限制
第四个坑是trait系统的限制。
Rust的trait系统很强大,但也有一些限制,在复杂的场景下会让人觉得不够灵活。
第一个限制是孤儿规则。孤儿规则规定,如果你要为一个类型实现一个trait,那么要么类型是你自己定义的,要么trait是你自己定义的。你不能为外部类型实现外部trait。
这个规则保证了代码的一致性,但有时候会让人觉得不方便。比如,你想为标准库的Vec类型实现你自己的一个trait,这是可以的,因为trait是你自己的。但如果你想为标准库的Vec类型实现标准库的Display trait,这是不行的,因为类型和trait都是外部的。
解决方法是:用newtype模式,创建一个新的类型包装外部类型,然后为新类型实现外部trait。虽然多了一层包装,但能绕过孤儿规则的限制。
第二个限制是trait对象的限制。trait对象(dyn Trait)有一些限制,比如trait必须是对象安全的,不能有泛型方法,不能返回Self类型等。在需要动态分发的场景下,这些限制会带来一些麻烦。
Rust 1.80在这方面有一些改进,比如支持了更多的trait对象场景,但基本的限制还是存在的。
我的经验是:如果不需要动态分发,尽量用泛型和静态分发,性能更好,也更灵活。只有在确实需要运行时多态的时候,才用trait对象。
第三个限制是trait的连贯性。有时候你想为一个类型实现多个trait,或者为多个类型实现一个trait,可能会遇到连贯性的问题,编译器会说有冲突的实现。
这时候需要仔细设计trait的层级和实现,避免冲突。必要的时候,可以用marker trait或者类型参数来区分不同的实现。
坑五:编译时间长
第五个坑是编译时间长。
Rust的编译速度,一直是被吐槽的点。特别是项目大了之后,编译一次可能要几分钟甚至几十分钟。我们的项目,完整编译一次大概要十分钟左右,开发的时候改一点代码重新编译,也要等几十秒。
编译时间长,会影响开发效率。特别是在调试的时候,改一行代码就要等半分钟才能看到结果,很影响心情。
我们总结了一些加快编译速度的方法:
第一,用cargo check代替cargo build。cargo check只检查代码能不能编译通过,不生成可执行文件,速度比cargo build快很多。开发的时候,大部分情况下只需要检查语法和类型错误,用cargo check就够了。
第二,用增量编译。Rust默认开启了增量编译,改了部分代码之后,只重新编译受影响的部分。增量编译能显著减少重新编译的时间。确保你的Cargo.toml中没有关闭增量编译。
第三,优化依赖。项目的依赖越多,编译时间越长。定期检查依赖,去掉不需要的依赖,或者用更轻量的替代库。比如,serde可以只开启需要的feature,减少编译时间。
第四,用sccache。sccache是一个编译缓存工具,能把编译结果缓存起来,下次编译相同的代码时直接从缓存取,速度快很多。特别是在CI环境中,sccache能大幅减少编译时间。
第五,合理设置优化级别。开发环境用debug模式,优化级别低,编译快。发布环境用release模式,优化级别高,编译慢但运行快。不要在开发的时候用release模式,否则每次编译都要等很久。
第六,拆分crate。把大的项目拆分成多个小的crate,每个crate独立编译。改了一个crate的代码,只需要重新编译这个crate和依赖它的crate,不需要重新编译整个项目。合理的crate拆分,能显著减少增量编译的时间。
Rust 1.80在编译速度方面也有一些改进,比如更智能的增量编译、更快的链接等。升级之后,我们的项目编译速度有一些提升,但总体还是偏慢。
坑六:错误处理的繁琐
第六个坑是错误处理。
Rust的错误处理用Result类型和?运算符,设计得很优雅,但在实际开发中,有时候会觉得比较繁琐。
比如,一个函数中调用了多个可能出错的函数,每个都要用?传播错误。这本身没问题,但如果不同的函数返回不同的错误类型,就需要统一错误类型,或者用Box<dyn Error>来包装。
我们在项目中,一开始用的是anyhow和thiserror两个库。anyhow用于应用层的错误处理,thiserror用于库层的错误类型定义。用下来感觉还不错,减少了很多错误处理的样板代码。
Rust 1.80在错误处理方面也有一些改进,比如更稳定的Error trait、更好的错误信息等。但基本的错误处理模式没有变化。
我的经验是:
第一,库层面用thiserror定义明确的错误类型。这样调用者可以精确地匹配和处理不同的错误。
第二,应用层面用anyhow简化错误处理。应用代码不需要太精确的错误类型,用anyhow::Result可以方便地传播和包装各种错误。
第三,善用?运算符,不要到处unwrap。unwrap会在错误时panic,在生产代码中应该尽量避免。除非你确定这个错误不可能发生,或者发生了也无所谓,否则不要用unwrap。
第四,给错误加上下文。用anyhow的with_context方法,给错误加上上下文信息,方便调试。比如,读取文件失败的时候,加上文件名作为上下文,就能知道是哪个文件出了问题。
实战经验总结
踩了这么多坑,我们也总结了一些实战经验。
第一,先编译通过,再优化性能。Rust的编译检查很严格,先让代码编译通过,功能正确,再考虑性能优化。不要一开始就纠结于性能,先把功能做出来。
第二,善用clippy。clippy是Rust的代码检查工具,能发现很多常见的错误和不规范的写法,还能给出性能优化的建议。每次写完代码,跑一下clippy,按照建议修改,能显著提升代码质量。Rust 1.80的clippy也新增了很多检查规则。
第三,写测试。Rust的测试支持很好,单元测试、集成测试、文档测试都很方便。写测试不仅能保证代码的正确性,还能在重构的时候提供安全网。Rust的所有权系统虽然能保证内存安全,但不能保证逻辑正确,测试还是很有必要的。
第四,合理使用unsafe。Rust的unsafe块可以绕过编译器的安全检查,在需要极致性能或者和C语言交互的时候很有用。但unsafe块要尽量少用,而且要仔细审查,确保里面的代码确实是安全的。我们的项目中,unsafe块的比例控制在1%以下,大部分代码都是安全的Rust。
第五,关注版本更新。Rust的版本更新很快,每六周一个新版本。每个新版本都会带来新特性、性能改进和bug修复。关注版本更新,及时升级,能享受到最新的改进。但升级的时候要注意测试,确保新版本不会引入新的问题。
写在最后
Rust是一门优秀的系统编程语言,它的性能、安全性和表达能力都很出色。但它的学习曲线确实比较陡,特别是所有权、生命周期、借用这些概念,需要花时间去理解和掌握。
Rust 1.80及以上版本,在之前版本的基础上,有了很多改进,开发体验越来越好。但还是有一些坑需要注意,比如异步编程的复杂性、编译时间长、错误处理繁琐等。
这篇文章分享的只是我们团队的一些踩坑经历和实战经验,可能不够全面,也可能随着Rust版本的更新而过时。但希望能给正在用Rust或者准备学习Rust的你,带来一些参考和启发。
最重要的是,不要被Rust的学习曲线吓到。虽然刚开始的时候会觉得很难,经常和编译器对抗,但一旦掌握了它的核心概念,你会发现Rust是一门非常优雅和强大的语言。它能让你写出高性能、高安全性的代码,而且越写越顺手。
最后用一句话来结束这篇文章:"Rust很难,但值得。"
愿你在Rust的学习和开发中,少踩坑,多成长,写出优秀的Rust代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录