【Kevin三连弹之二】Rust适合用来写linux内核模块吗?

本文转载自知乎:https://zhuanlan.zhihu.com/p/137907908

作者:Kevin Wang

前几天,我发了一篇文章记录了我用Rust重写一个Linux内核模块的一些重点体验,没想到引起不少人关注。由于第一次写文章,一些背景没有详细介绍,只专注于写我想的东西了,导致一些不了解Rust的人表示有些担心,因此,我在这里我补充介绍一点儿背景。

TOC:

  1. 没有找到原软件BUG的根因,就贸然用Rust重写,合适吗?

  2. 用Rust重写的代价大吗?相比用C如何?

  3. Rust这种【高级语言】会不会运行性能差,附加开销大?

没有找到原软件BUG的根因,就贸然用Rust重写,合适吗?

由于我们的产品绝大部分采用C语言开发,因此,我们一直被C写出的内存安全问题所困扰。一些影响较大的故障,会导致各方面的人员高度紧张。我们开发人员要面对就是一旦有紧急情况发生,老道的“专家”们就被召集过来,不管你是在岗还是休息,都要爬到屏幕面前分析解决。基本每年都会有一些这种情况,我就这样陆续被折磨了十年。

印象最深的一次是我在另一个项目开发攻关的最后一天,通宵到第二天9点,刚上床躺下,就接到领导电话,说我们一个重要客户的设备紧急故障,“专家组”其他人已经分析了一阵了,我不得不强行打起精神回到办公室。经过大家将近一天的努力,初步找到了bug原因,就是我本次重写的这个内核模块中的某处内存安全问题导致,与此同时我们另一路人马正在奔赴他乡故障现场的路上......。这个已经算是众多安全问题相对较好分析定位的了。

复杂系统中,C语言的内存安全问题远没有许多人想象的那么简单,通过开发人员的素质培训、良好的测试、费尽心思发明的各种调试方法的确能解决的大部分的安全问题。但是始终会剩下那一小部分,如梦魇般伴随我们的每一天。每当到达一个新的战场,她们少则十天半月、多则几个月冒出头来给我们沉重一击,她们当中有些很聪明,从不光顾我们的测试环境和实验环境。

难查的故障这次再一次光临,我和另一个同事分析了几天,没有准确定位原因,看到代码里一些内存问题,重构起来改动也非常大,怕改出其它问题。根据故障现象,我们只能得出一个结论: 这是内存安全问题导致的。而事情又紧急,以至于我们不得不启用了plan B为客户解决了问题,公司付出了代价、对于开发人员来讲是比较丢脸的事。

问题现场得到缓解之后,按理说我们有足够的时间来分析解决这个故障,但是,我们真的要这样年复一年的继续下去吗?即使我们再花一两周时间解决了这个疑难,梦魇仍然不会结束,或许下个月、明年下一个再次光顾。我想,我们需要解决掉这个"根本原因" ---- C。

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第1张图片

我关注Rust不是一两天了,2015年偶然发现Rust这个语言,当时没太在意。我从2016年开始考虑能不能用Rust解决我们的一些问题,也陆续的用Rust写过一些非产品的小工具。这几年下来,感觉到了Rust社区的活跃友好、配套工具的便利、语言特性的丰富,最关键的是它安全而不妥协性能。安全和性能是我最关注的,编译器保证我们不能在safe rust中写出有内存问题的代码,少数地方难免需要用到unsafe块,尽量把这少量的unsafe逻辑设计得简单些,安全审计起来也就容易。也算对Rust能否胜任工作有了比较充分的了解。

而Rust相比C唯一的缺点是上手门槛相对较高,这一点同时也是优点。C语言新人往往对语言、内存管理、程序整体工作原理知之甚少就能开始在产品上打码,往往需要留下许多血的教训才能换来一身的本领。看似练就了桐皮铁骨金刚不坏之身之后却仍然顶不住菜刀一击。而Rust一开始编译器就会逼迫我们理解这些东西,并且持续为内存安全把关,无论新手还是老手只要不碰unsafe,他就写不出内存问题来(如果有,那一定是其它unsafe的锅或编译器bug)。那种更好呢?

那这次这个内核模块代码量合适、相对其它组件独立,是一个我们在生产环境尝试Rust很好的一个契机,我们希望Rust能切实发挥作用解决问题。

用Rust重写的代价大吗?相比用C如何?

此次重写这个模块,其实很大一部分精力是花在理解原版的业务逻辑细节和开荒带来的填坑工作上。开荒的填坑工作是一次性的,后面在这沃土上耕耘将会比较顺利。真正的开发工作整个感受是会比用C写工作量要低,包括造轮子部分。

可能造轮子给人的感觉是比较耗费精力,是不必要的开销,但实际上这部分工作了很小,我造的那些轮子大部分在用户态都有对应功能成熟crate,大部分拿过来稍微改几刀就能用了。而造这些轮子一方面为了安全封装,另一方面有些轮子单纯是为了给后面的业务开发提供便利,有了轮子业务开发会好写很多。

相反,如果用C,有些个轮子是不会去造的。因为很多情况如果像Rust那样去造轮子,要么通用性不好(没有泛型),要么根本没有提供多少方便,缺少轮子该有的意义。所以,用C,业务代码里面常常会不经意的忍受一些不方便的写法。

虽然内核态不能使用标准库,但即便是no_std领域,我们有core、alloc,以及仍然不少的crate生态,这些现存的轮子使用起来都非常方便。而C呢,不到迫不得已,C程序员是不会去使用第三方库的,因为C的依赖管理太麻烦了,一些通用功能基本上宁愿自己代码里面多写几个函数也不愿去引入一个开源库。所以,我们看到的C项目往往都是无依赖或屈指可数的几个依赖。而Rust这种情况就是Cargo.toml里面加一行字的事儿,方便了许多。

这就导致用C会比用Rust成本更高一些。

Rust这种【高级语言】会不会运行性能差,附加开销大?

由于文章里面有"linux内核"关键字,看文章的很多朋友可能没有了解过Rust,担心用Rust这样的高级语言会有附加的运行是开销,降低性能。

Rust的亮点之一是"零开销抽象",提供高级抽象能力的同时,不在性能上做妥协。让我们简单写个例子分析一下。

首先,我们用C写个函数,然后在godbolt.org上看看它的汇编代码:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第2张图片

然后,我们用Rust写一个一样的:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第3张图片

可以看到生成的代码一模一样。

然后,我们开始添加一些Rust的抽象,看看是不是会影响。首先,用标准库的sum函数来代替循环:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第4张图片

汇编代码仍然一样,没有影响。

我们再加一些自己的抽象,引入结构体来表示中间计算过程:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第5张图片

这里引入了结构体来中间表示,以及多调用了new,map,fold等函数,但是并没有增加任何开销,结果依然不变。

再来看看引入trait:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第6张图片

可以看到引入trait抽象后仍然丝毫不影响编译结果。这就是Rust所谓的"零开销抽象"。

有时候有人会写一些微小的benchmark来比较C和Rust的性能,比如通过一些高密集的循环计算一些东西来跑分。但是做这种微bench需要高度注意控制变量,不然很容易被误导

以下面这边bench为例

怎么看Fuchsia官网编程语言政策?Go没有通过,Rust不予提供,建议使用Dart、C/C++? www.zhihu.com【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第7张图片

答主分别用C和Rust实现了八皇后问题的算法,看谁跑得快。运行结果是Rust比C慢10%-20%, 并给出原因是Rust每次从数组取值都进行边界检查, 导致了整体性能下降。但是我看到评论区有人跑出了相反的结果Rust比C快,为什么?

我克隆该仓库直接运行结果是这样:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第8张图片

可以看出,在我电脑上非递归版C更快, 递归版Rust更快,我们继续探索。

我将queen.rs的被测函数queen稍作修改,插入一些NOP汇编指令:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第9张图片

运行便得到这样的结果:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第10张图片

仅仅是插入了一些NOP指令就提高了程序的性能,神奇吧!

再来改一改queue.c试试,我们只改它的main函数。原始代码是这样:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第11张图片

给它复制几份, 变成这样:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第12张图片

其它什么都不动,原则上应该要打印5次几乎相同的结果。但实际结果是这样:

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第13张图片

可以看到那五次成绩差异巨大,什么原因?

我原本以为是和代码的cache line对齐有关系(不知道cache相关知识的话可以看这篇文章,讲得很清楚),因为手动插入NOP或复制queue.c的测试代码均会改变相关代码在内存中的位置,从而影响执行过程中指令cache miss的次数。但进一步研究发现和CPU cache没啥关系,用linux perf命令可以给程序统计性能,我用它打出加和不加NOP的两个程序的区别,发现cache miss都不高,不足以影响测评。

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第14张图片 上边加了NOP, 下边原版

两个程序运行的指令数差别不大,但加了NOP的版本平均2.2指令/周期,原版只有2.05,可以看出加了NOP版本许多指令的周期被缩短了。用perf annotate标记出来发现两者的确有些指令的开销不一样。

【Kevin三连弹之二】Rust适合用来写linux内核模块吗?_第15张图片 perf 得到的逐指令cycle开销百分比,左边原版,右边加了NOP

为什么会这样?目前对我来说仍然是个谜,麻烦懂行的朋友指点一二哈。(进一步分析见续集)

分析到这里,至少可以得出一个结论:这种微bench会受到编译环境诸多因素影响,从而导致编译出的机器指令在内存中的位置有差异,这样得出的测评结果完全是这种噪声造成的差异,不足以说明两个语言谁快谁慢。该bench中Rust版本的越界检查的确会产生影响,但理论上影响是微不足道的,不足以造成明显差异。不要轻易相信类似微测试,容易被误导。

总的来说,Rust的性能是不用担心的,目前它和C几乎一样快,由于类型系统有更多的信息,理论上它比C有更多的优化空间,将来可能会比C更快。

小结

对于我们的场景来讲,Rust用来写内核模块是非常合适的,最主要的就是能以较小的代价换来产品的安全稳定。如果这次仍然用C来重构,我不能保证写出完全没有内存问题,即便我写的没问题,数月后接棒者开始往上加功能、打补丁,数年后悲剧再次轮回。这不是我们期望的。假若此次效果良好,我们将用Rust氧化更多其它组件。

你可能感兴趣的:(【Kevin三连弹之二】Rust适合用来写linux内核模块吗?)