编写长期可靠和可维护的软件,有很多障碍。其中之一就是软件的复杂性。在最近结束的2021年KVM论坛上,Paolo Bonzini以开源虚拟化QEMU仿真软件为例,探讨了这个话题。根据他作为几个QEMU子系统的维护者的经验,他就如何抵御不受欢迎的复杂性提出了一些具体建议。Bonzini在整个演讲中使用QEMU作为例子,希望让未来的贡献者更容易修改QEMU。然而,他所分享的经验也同样适用于其他许多项目。
为什么软件的复杂性会成为一个问题?首先,毫不奇怪,它导致了各种类型的错误,包括安全缺陷。对于复杂的软件来说,代码审查变得更加困难;它也使得对项目的贡献和维护更加痛苦。很明显,这些都是不可取的。
Bonzini旨在回答的问题是 “我们能在多大程度上消除复杂性?”;为了做到这一点,他首先区分了 “基本 “和 “意外 “复杂性。这两类复杂性的概念源于1987年Fred Brooks的经典论文《没有银弹》。可以看出,Brooks本人正在回顾亚里士多德的本质和意外的概念。
正如Bonzini所说,基本复杂性是 “一个软件程序试图解决的问题的属性”。而意外的复杂性,则是 “正在解决手头问题的程序的属性”(由于困难不是被解决的问题所固有的)。为了进一步解释这些概念,他指出了QEMU正在解决的问题,这些问题构成了QEMU的基本复杂性。
QEMU复杂性的本质和意外
QEMU在可移植性、可配置性、性能和安全性方面有大量的要求。除了模拟设备并提供保存和恢复其状态的方法外,它还有一个强大的存储层,并且还嵌入了一些网络服务器,如VNC服务器。QEMU还必须确保暴露给虚拟机的CPU和设备模型保持稳定,无论底层硬件或QEMU本身是否被更新。对于许多用户来说,使用QEMU的发行版内核而不是定制的内核很重要。对于许多QEMU用户来说,能够启动非Linux操作系统也是一个必要的功能;这些被认为是必要的复杂性。
QEMU提供了一个管理界面,通常称为监视器。事实上有两个,HMP和QMP,因为用户需要一个简单的方法来与监控器互动,不会被QMP提供的基于JSON的接口所服务,外部程序使用QEMU来管理。因此,QEMU包含了一个对象模型和一个代码生成器,它可以处理C结构的集合和解集。由于这个代码生成器,同样的代码可以很容易地对JSON字典或命令行选项进行操作。
开发人员也看到了复杂性的另一面,这是由作为构建过程一部分的工具带来的。工具使普通的任务变得更容易,但当它们发生故障时,也使调试变得更难。例如,QEMU曾经有一个手动配置机制,需要逐一列出它所模拟的主板上的所有设备。现在,只需要指定主板,构建系统将自动启用它所支持的设备。它还能确保不可能的配置不会被构建–这很有用,但是,当然,开发者必须学会如何处理这些故障。
复杂性的来源
Bonzini确定了两个主要的意外复杂性的来源。第一个是 “不完整的过渡”(受一篇关于GCC维护的论文启发),它发生在一个新的和更好的方法被引入,但它没有在整个代码库中被一致应用。这可能是由于多种原因造成的:开发人员可能没有时间或相关的专业知识;或者他们根本没有发现其余的情况。
作为一个例子,他列举了在QEMU中报告错误的两种截然不同的方式:基于传播的API,以及将错误写入标准输出的特设函数(如error_report()
)。基于传播的API是为了向QMP接口报告错误而引入的。它有两个优点:它将错误发生的地方与报告的地方分开,并允许优雅的错误恢复。另一个不完全过渡的例子是,尽管现在QEMU的构建系统大多使用Meson,但仍有一些预先存在的编译测试是用Bourne shell编写的,是QEMU的配置脚本的一部分。
然而,QEMU在完成转换方面也有一个不错的记录。其中有几个是使用Coccinelle完成的–这是一个模式匹配和源码转换工具,允许创建一个 “语义补丁”,它可以统一应用于整个代码库。例如,Coccinelle被用来取代过时的API,简化那些要经过不必要的圈套的代码,或者甚至引入全新的API(如设备的创建和 “实现”)。
偶然复杂性的第二个来源是重复的逻辑和缺失抽象。在编写临时性的代码与设计可重用的数据结构和API之间,有一个权衡。Bonzini提到了命令行解析,并将使用strtol()或scanf()等函数的临时代码与QEMU特定的API,如QemuOpts或keyval进行了对比。后者确保了命令行的一致性,有时还负责打印帮助信息。
另一个例子是最近努力将QEMU的更多部分组织成可以单独安装的共享对象。随着这类模块数量的增加,我们建立了一个新的机制,将模块提供的功能及其依赖关系与实现的功能列在同一个源文件中,而不是让它们散落在QEMU源代码中。Bonzini建议,一旦审查员注意到过多的重复,或者功能散落在许多文件中,他们就应该制定一个如何消除这些功能的计划。
QEMU 命令行的复杂性
接下来是对QEMU中意外复杂性的案例研究,即命令行处理代码。QEMU有117个选项,在大约3000行的代码中实现,有 “一些基本的复杂性,但意外的复杂性太多”。Bonzini概述了简化事情的方法,或者说在处理QEMU的命令行解析代码时如何不使其变得更糟。他首先问道:到底是什么导致了QEMU命令行选项的意外复杂性?众多的选项在实现上有很大的不同,所以将它们归为六类,并按照意外复杂性增加的顺序进行了讨论:灵活、命令、组合、快捷、一次性和遗留。
灵活选项是最复杂的,因为它们满足了广泛的需求。它们提供了对QEMU基本复杂性的很大一部分的访问,QEMU的新功能通常是通过这些选项来实现的。灵活选项的工作方式是将尽可能多的功能委托给通用的QEMU APIs,因此启用新功能不需要编写或修改任何命令行解析代码。这就是一个单一的选项,-object可以配置诸如加密密钥、TLS的证书、虚拟机与主机上的NUMA节点的关联等信息。三个选项,-cpu
、-device
和-machine
,配置了虚拟硬件的几乎所有方面。然而,这些选项也不能避免意外的复杂性:至少有四个解析器用于此类选项。QemuOpts、keyval、JSON解析器,以及一个被-cpu选项使用的定制解析器。四个解析器至少比应该有的多两个。
命令选项是在QEMU命令行上指定的,但它通常也对应于可以在运行时调用的QMP命令之一。一个例子是在客户启动时不启动vCPU的选项(qemu-kvm -S
命令行使用;stop
在运行中使用),但是在启动的时候(通过QMP cont
,表示 “继续”)。另一个例子是-loadvm
,从一个保存有虚拟机文件中启动QEMU;或者trace,启用跟踪点(假设QEMU是用可用的跟踪后端构建的)。这些选项给QEMU的维护者带来了相对较小的负担;但Bonzini建议在添加新的命令行选项时保持较高的标准–在运行时从QMP接口调用这些选项会更容易。
有了组合选项,”我们开始进入意外的复杂性地狱”:这些选项在一个命令行选项中同时创建设备的前端和后端。例如,QEMU的-drive
选项在一个选项中创建了一个设备,如virtio-blk
和一个虚拟机的磁盘镜像。更加粗略的选项变体对于普通用户来说已经很不方便了,所以组合选项确实有真正的用处,但它们的维护负担很重。解析代码很复杂,而且这些选项也往往会对代码的其他部分产生影响–包括后台代码和虚拟芯片组创建代码。这些选项使QEMU的代码不那么模块化,因此,如果不了解命令行的一些细节,就无法增加对主板的支持。
快捷选项语法糖复杂性排在前三。例如,-kernel path
是-machine pc,kernel=path
的缩写。它们很方便–许多用户可能甚至没有意识到较长形式的存在–而且它们的维护负担很小,因为它们的实现完全在命令行解析代码中。然而,考虑到已经存在的大量选项,最好不要再增加。
最后,对于传统的命令行选项,”我们跌到了谷底”。其中许多都是失败的实验(比如-readconfig
和-writeconfig
选项),或者根本就不应该出现在QEMU中的东西。例如,-daemonize
在初始化后对QEMU进程进行守护,而libvirt等工具则为用户提供了更好的服务。这些东西的未来就是废弃并最终删除它们。
改进的方法
QEMU命令行带来了什么教训,开发者可以从中得到什么指导?他说:”不要在空白处设计”–要利用现有的基本复杂性。在开始添加一个新的命令行标志之前,问问自己是否有必要。也许可以使用QEMU中的一个现有的集成,如QEMU API和QMP命令。这样一来,就可以充分利用QEMU的子系统之间现有的互动。
其次,Bonzini强调了补丁审查员的责任:理解复杂性的基本部分,不要把它误认为是意外–这是识别上升的意外复杂性的前提条件。而且不要让意外的复杂性占据项目。对于那些从事大型代码库重构的人,他鼓励学习Coccinelle。
不完整的过渡期并不总是可怕的:从一个旧的API过渡到一个新的、更好的API是软件改进的一个自然部分。就QEMU而言,有时一个新功能无论如何都需要一个过渡期,因为它影响到命令行或管理工具,因此需要一个废弃周期。在这种情况下,利用不完整的过渡期,分阶段工作。找出可以被认为是改进的最小的工作块,并为以后的工作做计划。
此外,确保执行一项开发任务或使用一项功能的新的、优雅的方法被记录下来 - “应该有一种明显的方法来完成一项任务。如果没有,也要有一个记录在案的方法。” 不完整的或零散的过渡不应该阻止人们对程序进行改进。评估重复代码和添加更多抽象概念之间的权衡。有些情况可能需要重复代码;但当事情变得更糟时,不要使情况恶化。
总结
构建本质上复杂且可维护的软件已经很困难了。如果这里讨论的意外复杂性的因素–不完整的过渡、过度的抽象、组件之间不明确的逻辑边界和工具的复杂性–不加以控制,问题就会随着时间的推移而变得复杂。从QEMU的经验中提炼出来的教训,为其他面临类似障碍的项目提供了充分的指导。