LWN:以QEMU为例解析软件复杂度!

共 6574字,需浏览 14分钟

 ·

2021-10-24 14:03

关注了就能看到更多这么棒的文章哦~

A QEMU case study in grappling with software complexity

October 12, 2021
This article was contributed by Kashyap Chamarthy
KVM Forum
DeepL assisted translation
https://lwn.net/Articles/872321/

要制作出具有长期可靠性以及可维护性的软件,有很多障碍。其中之一就是软件的复杂性。在最近结束的 2021 年 KVM 论坛上,Paolo Bonzini 以 open source 的 emulator 和 virtualizer (仿真器和虚拟化器) QEMU 为例,探讨了这个话题。根据他作为 QEMU 中几个子系统的维护者的经验,他针对如何抵御不该有的复杂性提出了一些具体建议。Bonzini 在整个演讲中使用 QEMU 作为实例,希望让未来的贡献者更容易对 QEMU 进行修改。然而,他所分享的经验也同样适用于其他许多项目。

为什么软件的复杂性会成为一个问题?首先,大家都知道它会导致各种错误,也会引入安全缺陷(security flaws)。对于复杂的软件来说,对代码进行 review 会更加困难;它也使得希望对项目进行贡献和维护的时候感到更加麻烦。显然,这些都是缺点。

Bonzini 希望能回答一个问题,那就是 "我们能在多大程度上消除复杂性?"。为此,他首先把复杂性分为了 "基本的(essential) "和 "偶然的(accidental)" 复杂性两种。这两类复杂性的概念源于 1987 年 Fred Brooks 的经典论文《No Silver Bullet》。Brooks 本人则是在借鉴了亚里士多德的 essence and accident 的定义。

正如 Bonzini 所说,essential complexity 是 "软件程序试图解决的问题本身固有的属性"。而 accidental complexity 则是 "正在解决手头问题的过程相关的一个特性"(即这不是由我们想要解决的问题本身所具有的困难)。为了进一步解释这些概念,他挑选了一些在 QEMU 中正在解决的问题,用来说明 QEMU 的 essential complexity。

Essence and accidents of QEMU

QEMU 在可移植性、可配置性、性能和安全性方面有许多要求。除了要模拟(emulate)guest device,以及提供保存和恢复 guest 状态的方法之外,它还有一个功能强大的 storage layer,并且还带有一些网络服务功能,如 VNC server。QEMU 还必须确保暴露给客户的 CPU 和 device 的模型保持稳定,哪怕曾经更新过所运行的硬件环境或 QEMU 本身。许多用户来说都希望在 QEMU 上直接使用发行版的内核,而不希望专门修改一个内核来使用。对于许多 QEMU 用户来说,能够启动非 Linux 的操作系统也是一个必备功能,这个也算是 essential complexity。

QEMU 提供了一个管理界面,通常称为 monitor。实际上是有两个,分别是 HMP(人类监控协议,human monitor protocol)和 QMP(QEMU monitor protocol),因为用户也需要一个简单的方法来与 monitor 互动,而不希望使用 QMP 提供出来的基于 JSON 的接口,外部程序则要 QMP 的接口来来管理 QEMU。因此,QEMU 包含了一个对象模型(object model)和一个代码生成器(code generator),它可以处理 C structure 的双向转换(marshaling and unmarshaling)。得益于这个代码生成器,可以简单地使用同样的代码来相应 JSON 或命令行参数的操作方式。

开发人员还看到了另一方面的复杂性,这是由于 build 过程中的相关工具所引入的。工具一般会使常见的任务变得更容易,但它们也使得调试工作在发生故障时变得更加困难。例如,QEMU 曾经有一个手动配置机制,需要用户逐一列举出它所要模拟的电路板上的所有设备。现在,只需要指定某个电路板,build 系统就会自动把它所支持的设备都启用起来。它还能确保不会去 build 那些不合理的配置,这个机制也非常有用。但是,开发者仍然必须要学会如何处理这类出错情况。

Sources of complexity

在演讲中,Bonzini 介绍了 accidental complexity 的两个主要来源。第一个是 "incomplete transitions"(受到一篇关于 GCC 维护的论文的启发),也就是当一种新的、更好的方法被引入的时候,这个新方法尚未在整个代码库中统一应用起来。这可能是由于多种原因造成的:开发人员可能没有时间或缺乏相关的专业知识,或者他们根本没有找到那些被遗漏的情况。

他列举了在 QEMU 中报错的两种截然不同的方式作为例子:一种是 propagation-based API,另一种是将 error 直接写入标准输出的具体函数(例如 error_report())。propagation-based API 是为了向 QMP 接口报告错误而引入的。它有两个优点:它将错误发生的位置与报告的位置分离开,并可以比较干净地进行 error recovery。另一个 incomplete transitions 的例子是,尽管现在 QEMU 的 build 系统主要使用 Meson,但仍有一些之前就存在的 build test 是用 Bourne shell 编写的,仍然是 QEMU 的配置脚本的一部分。

然而,QEMU 历史上也有一些彻底完成切换(transition)的例子。其中有几次是使用 Coccinelle 来完成的(这是一个模式匹配和源码转换工具,允许创建一个 "semantic patch,语义补丁",用来在整个代码库中修改所有相关点。例如,曾经使用 Coccinelle 来替换那些过时了的 API、 简化一些不必要的中间调用、甚至引入全新的 API(如 device 的创建和 "realization")。

accidental complexity 的第二个来源是重复逻辑以及缺乏抽象。在编写临时代码和设计可重复使用的数据结构和 API 之间,有一个平衡点需要权衡。Bonzini 举了命令行解析的例子,有些临时代码在使用 strtol() 或 scanf() 等函数,相应地正规代码使用的是 QEMU 专用 API(如 QemuOpts 或 keyval)。后者确保了命令行的一致性,有时还能协助打印一些 help 信息。

另一个例子是最近发生的,人们在试图将 QEMU 的更多部分组织成可以独立安装的 shared object。随着这类 module 的数量增加,我们就建立了一个新机制,可以将一个 module 所提供的功能和它的依赖关系列在实现相关功能的源文件代码中,而不是让它们散落在 QEMU 源代码各处。Bonzini 建议,一旦 reviewer 看到有过多的重复内容,或者某个功能散乱分布在许多文件中,他们就应该计划一下如何消除这种情况。

Complexity on the QEMU command line

讲座接着介绍了一个关于 QEMU accidental complexity 的案例研究,即命令行的处理代码。QEMU 有 117 个选项,用了大约 3000 行的代码来实现,这里有 "一些 essential complexity,但有太多 accidental complexity"。Bonzini 简要介绍了一些可以对这里进行简化处理的方法,也就是说在处理 QEMU 命令行解析代码时如何使其至少不会变得更糟。他首先问道:到底是什么导致了 QEMU 命令行选项的这些 accidental complexity?这么多的选项参数,具体的实现都有很大的差异,所以讲座将它们归为了六类,并按照 accidental complexity 的递增顺序进行了逐项介绍:flexible (灵活性)、command (命令)、combo (组合)、shortcut (快捷方式)、one-off (一次性),以及 legacy (遗留内容)。

flexible option 是最复杂的,因为它们用来满足广泛的需求。它引入了 QEMU 中大多数的 essential complexity,QEMU 的新功能通常是通过这些 option 来启用的。flexible option 的工作机制是将尽可能多的功能转化给通用的 QEMU APIs 来执行,因此启用新功能通常不需要对命令行解析代码进行任何新增或者修改。这就是为什么可以用一个单个选项 -object 来完成配置诸如加密密钥、TLS 证书、虚拟机与 host 上的 NUMA 节点的关联等的原因。而使用了三个选项,分别是 -cpu、-device 和 -machine 来配置了虚拟硬件的几乎所有特性。然而,这些 option 也不可避免地会有 accidental complexity:至少使用了四个 parser 来解析这些 option。这四个分别是 QemuOpts、keyval、一个 JSON 解析器,以及一个被 -cpu 选项所使用的专门定制的解析器。"四个解析器至少有两个是可以不需要的。"

command 选项是在 QEMU 命令行上指定的,但它通常也对应于在 runtime 调用的某个 QMP 命令。举例来说,guest 启动时可以不让 vCPU 运行(qemu-kvm -S 命令行命令,或者也可以在 runtime 停止),而后续需要时再启动 CPU(通过 QMP cont,表示 "继续")。另外的例子有 -loadvm,用来从一个保存有 guest 状态的文件中启动 QEMU。还有 trace,是用来启用 trace point 的(这要求 QEMU 在 build 的时候带上了相应的某个 tracing 后端支持)。这些 option 给 QEMU 的维护者带来的负担相对比较小,但 Bonzini 建议在添加新的命令行选项时保持一个较高的标准,这样才能今后从 QMP 接口设置这些 option 是更加容易。

加上了 combo 选项之后,"我们开始进入 accidental complexity 的地狱":这些选项在一个命令行选项中同时创建了 device 的前端和后端。例如,QEMU 的 -drive 选项会创建一个类似 virtio-blk 这样的 device,以及一个 guest 的磁盘镜像。这个选项还有一些更加细化的变体,它们对于普通用户来说已经很不方便了,所以 combo 选项确实是真正有用的,但维护这些选项给大家带来的负担也很重。相关的解析代码是很复杂的,而且这些选项也往往会对代码的其他部分产生影响,包括 backend 代码以及 virtual-chipset 的 creation 相关代码。这些选项使得 QEMU 的代码变得不那么模块化,也因为这个原因,如果不了解命令行的细节的话,就无法增加对新 board 的支持。

shortcut 是前三组选项的语法糖(syntactic sugar)。例如,-kernel path 是 -machine pc,kernel=path 的缩写。它们很方便(许多用户可能甚至没有意识到较长形式的存在)而且相应的维护负担很小,因为它们的实现完全可以依赖现有的命令行解析代码来完成。然而,考虑到已经存在这么多的选项了,所以最好也不要再增加。

然后是 one-off 选项。这些选项是必不可少的,但它们的实现往往是不够优化的。它们经常会向全局变量写入一个数值,或者调用一个无法在 runtime 通过 QEMU monitor 调用的函数。Bonzini 恳请开发人员务必避免创建再新的命令了,而是对现有的命令进行重构,改为 shortcut 或 command 选项,他在过去一年中一直在断断续续地做这件事。

最后,来到了 legacy 命令行选项,"我们跌到了谷底"。其中许多都是来自失败的实验(例如 -readconfig 和-writeconfig 选项),或者根本就不应该出现在 QEMU 中的东西。例如,-daemonize 在初始化后让 QEMU 进程变成守护进程,而 libvirt 等工具给用户提供的方案更加好。这些工具的未来,就是等待被废弃(deprecate)并最终删除掉。

Ways to fight back

QEMU 命令行带来了哪些教训,以及开发者可以从中学到哪些原则?他说:"不要从无到有地进行设计",要利用现有的 essential complexity。在着手添加一个新的命令行 flag 之前,问问自己是否有必要。也许可以跟 QEMU 中的 QEMU API 和 QMP 命令等集成起来。这样一来,就可以充分利用 QEMU 的子系统之间现有的互相配合的机制。

其次,Bonzini 强调了 patch reviewer 的责任:需要理解复杂性中的 essential 的这部分,不要把它误认为是 accidental。只有这样做了,才能真正发现 accidental complexity 是否在上升。而且不要让 accidental complexity 完全占据我们的项目。对于那些从事大型代码库重构的人,他鼓励先学习一下 Coccinelle。

incomplete transition 并不总是可怕的事情:从一个旧 API 过渡到一个新的、更好的 API 是软件改进中自然会发生的事情。就 QEMU 而言,有时一个新功能无论如何都需要一个 transition 阶段,因为它会影响到命令行或管理工具,因此就需要一个 deprecate 周期。在这种情况下,利用 incomplete transition,分阶段来完成工作。辨别出所有进行的工作中最小的各个任务,并为先做哪些后做哪些做好计划。

此外,需要确保这些新的、大家推荐的完成开发任务的方法、或使用某个功能的最佳方法要被得到相应的文档记录。"希望在做任何任务的时候都能有一个很明显正确的方法完成。如果没有这么明显的方法的话,那就要有一个记录下来的方法。" incomplete transition 或者分段过渡(piece-wise transition)都不应该阻碍人们对程序进行改进。需要对重复代码和添加更多抽象层之间进行权衡。有些情况可能需要重复代码,但当情况变差的时候,不要再继续让它继续恶化了。

Conclusion

要搭建 essentially-complex 并且可维护的(maintainable)的软件已经很困难了。如果这里讨论的 accidental complexity 的因素(incomplete transition、过度抽象、组件之间不明确的逻辑边界和工具的复杂性)没有得到控制,那么随着时间的推移就会出现越来越复杂的情况。从 QEMU 的经验中提炼出来的教训为其他面临类似麻烦的项目提供了不少借鉴。

[我想感谢 Paolo Bonzini 对本文早期草稿的深入 review。]

全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~



浏览 41
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报