周刊(第7期):一个C系程序员的Rust初体验

2022-02-27
7分钟阅读时长

引言:在工作里使用Rust已经有两个多月的时间了,谈谈我做为一名多年的C系(C、C++)程序员,对Rust的初体验。


一个C系程序员的Rust初体验

最近由于工作的原因,使用上了Rust语言,在此之前我有多年的C、C++编码经验(以下将C、C++简称C系语言)。

使用C系语言编码时,最经常面对的问题就是内存问题,诸如:

  • 野指针(Wild Pointer):使用了不可知的指针变量,如已经被释放、未初始化、随机,等等。
  • 内存地址由于访问越界等原因被覆盖(overflow),这不但是可能出错的问题,还有可能成为程序的内存漏洞被利用。
  • 内存分配后未回收。

连Chrome的报告都指出,Chrome中大约70%的安全漏洞都是内存问题,见:Memory safety。(不仅如此,微软的文章也显示在微软的产品中70%的安全漏洞也是内存问题,见:Microsoft: 70 percent of all security bugs are memory safety issues | ZDNet

C系语言发展到今天,已经有不少可以用于内存问题检测的利器了,其中最好用的莫过于AddressSanitizer,它的原理是在编译时给程序加上一些信息,一旦发生内存越界访问、野指针等错误都会自动检测出来。

但是即便有这些工具,内存问题也不好解决,其核心的原因在于:这些问题绝大部分都是运行时(Runtime)问题,即要在程序跑到特定场景的时候才会暴露出来,诸如上面提到的AddressSanitizer就是这样。

都知道解决问题的第一步是能复现问题,而如果一个问题是运行时问题,这就意味着:复现问题可能会是一件很麻烦的事情,有时候还可能到生产环境去复现。

以我之前经历的一个Bug来看这类工作的复杂度,见线上存储服务崩溃问题分析记录 - codedump的网络日志,这是一个很典型的发生在生产环境上由于内存错误导致的崩溃问题:

  • 不好复现,因为跟特定的请求相关,还跟线程的调度有关;
  • 本质是由于使用了被释放的内存导致的错误。

这个线上问题,记得当时花了一周时间来复现问题解决。

换言之,如果一个问题要等到运行时才能发现,那么可以预见的是:一旦出现问题,要复现问题可能要花费大量的精力,以及需要很多经验才行。如果一个问题还是在特定场景,或者用户现场才出现的,那就更麻烦了,C系程序员以往一般都是这样来保存“现场”:

  • 出现崩溃的时候保存core文件来查看调用堆栈、变量等信息。
  • 发明了各种复制流量重放的工具,比如tcpcopy等。

总而言之,运行时问题一旦出现是很麻烦的,而解决这类问题的时间是难以预期的。

Rust给这类内存问题的解决提供了另一个解决思路:

  • 一个内存地址同时只能被一个变量使用。
  • 不能使用未初始化的变量。

简而言之,凡是可能出现内存错误的地方,都在语言的语法层面给予禁止,换来的就是更多的编译时间,因为要做这么多检查嘛,而需要更多的编译时间反过来就需要更好的硬件。我想这也是Rust到了最近几年才开始慢慢流行开来的原因之一,毕竟即便是现在,一些大型的Rust项目普通的机器编译起来也还是很耗时。

“编译时间(compile time)”是一个可以预期的固定时间,能通过增加硬件性能(比如买更好的机器来写Rust)来解决;而“运行时问题”一旦出现,查找起来的时间、精力、场景(比如出现在用户现场、几百万次才能重现一次等)不确定性可就很高了。

两者权衡,我选择解决“编译时间”问题。而且,在我意识到有这样的工具能够在编译期解决大部分内存问题时,反过来再看使用C系语言的项目,几乎可以预期的是:只要代码和复杂度上了一定规模,那么这类项目都要花上相当的一段时间才能稳定下来。原因在于:类似内存问题这样的运行时问题,是需要场景去积累,才能暴露出来的,而场景的积累,就需要很多的小白鼠和运行时间了。

总结一下我的观点:

  • C系语言最多的问题就是各类内存问题,而这些问题大多是运行时问题。即便现在已经有了各种工具,解决其运行时问题也很困难。
  • Rust解决这类问题的思路,是在语法层面禁止一切可能出现内存问题的操作,换来的代价就是更多的编译时间。
  • 解决可预期的“编译时间”和难预期的“运行时问题”,我选择前者。人生苦短,浪费时间在解决运行时的各种内存问题太不值当了。

番外篇

rr

rr: lightweight recording & deterministic debugging也是出自Mozilla的另一款调试C系程序的利器,rrRecord and Replay的简称,目的还是为了解决各种运行时问题,由于运行时问题中存在着各种不确定的因素,包括:

  • 变量值。
  • 进程、线程环境,比如不同的线程调度顺序可能导致了不同的结果。
  • 输入不同的数据,能得到不同的结果。

于是,rr要解决的核心问题,就是让一个程序在运行时有一个固定的环境,它可以抓取程序运行的环境保存下来。这样在出现问题之后,就能使用它可以记录下来程序运行时的环境,不停的重放来调试解决问题。

但是,即便是这样,rr可能更适合于明确知道问题的情况下去抓取环境,不可能在线上直接打开这个工具。所以又回到前面的结论了:调试运行时问题可能面对的困难,包括场景、时间、用户现场等等不确定因素。

rrRust一样,都出自Mozilla,我想不是偶然的。Mozilla和chrome等一样,都是使用C++编码的超大型项目,而这里一定遇到了各种运行时问题,不止于内存问题,所以才要使用各种工具来辅助解决这类问题。

吃上硬件升级的红利了吗?

前面提到过,Rust目前较大的问题是编译时间过长,这可能是导致它最近几年才开始逐渐流行开来的原因。其实反过来说,在硬件升级之后,应该能尽量利用上硬件,在编译期尽量多检查出错误来,减少运行时发现问题的数量。这样,才能吃上硬件升级的红利,利用硬件来减少自己的犯错。

一方面硬件升级给了编程语言能施展更大、更快的的“舞台”,随着舞台的更新,就会有更新、更好的工具出现;另一方面做为从业者,也应该与时俱进,多学习跟进这些工具的演进。

我看到有一些人,强调自己多早就已经用C语言写代码了,但是查内存问题还在用慢的不行的Valgrind,没听过更不知道怎么用Address Sanitizer

想说如果技能点都已经不更新了,强调多早学的有什么意义?好比1950年就会打算盘,有意义吗?强调多早就用C语言类似的言论,在我看来就是“倚老卖老”,但是技术日新月异的领域,卖老的意义不大。

推荐

《Rust for Rustaceans》

推荐Rust for Rustaceans作者Jon Gjengset的油管频道:https://www.youtube.com/c/JonGjengset/playlists

有很多很有深度的Rust分享,比如:

介绍Rust缘起的文章

Infoq上的《想要改变世界的Rust语言》,是一篇介绍了Rust语言的缘起和设计目标好文章,对于了解Rust的历史、设计哲学等都有帮助。其中谈到的Rust三大设计哲学中:

  • 内存安全
  • 零成本抽象
  • 实用性

就把“内存安全”放在了第一位,可见尽量解决运行时的内存问题都是大家很关心的问题。

查询Rust文档的浏览器插件

Rust Search Extension - The ultimate search extension for Rust,是一个方便在浏览器中快速查询Rust文档的插件,提供了各种浏览器的支持。

其他推荐

《神鞭》

神鞭是一部上世纪80年代的老电影,印象里小时候在露天电影院看过,故事的梗概大概是这样的:

故事发生在清朝末年,主角是一个会使辫子神功的人,耍起辫子来能像鞭子一样抽打对手。后来八国联军入侵,加入了义和团,结果可想而知。再后来重新出现在江湖上时,不再是当年那个会耍辫子的高手,而是变成了一个神枪手了。

里面主角的有一句台词“辫子没了,神还在”,至今印象深刻,我对这句话的解读是:使用的工具,也应该与时俱进的进化,这个观点放在今天这篇对比C系和Rust的文章里,我认为是合适的。