无论是作为最终用户还是作为程序员,最令人沮丧的经历之一就是试图运行一个不久前还可以完美运行的软件,结果却发现它现在坏了,软件不再运行了,原因也不清楚。软件没有改变,但有些东西坏了,似乎是无缘无故的。这通常是由于被称为“代码腐烂”或“位腐烂”的现象。
我曾经在一个深度学习研究实验室工作,我见过一些案例,研究人员在六个月前刚刚发布了代码,但他们所依赖的一个或多个依赖项后来发生了突破性的变化。这让我们陷入了不幸的境地,不得不对其他人的代码进行故障排除。有时候你是幸运的,问题只是他们的代码所需的Python包中有一个做了破坏性的更改,而这个问题可以通过简单地编辑项目清单来解决。有时人们会导入一些他们并不真正使用的软件包,我们可以完全消除依赖性。其他时候,我们必须解决可以安装在给定系统(pip2、pip3、Conda和Ubuntu的apt)上的多个Python包管理器之间的冲突。
编辑与包管理器相关的依赖列表或争吵并不太坏,但是PyTrink是一个令人恼火的特性,它是一个令人恼火的特性,每个版本需要英伟达GPU驱动程序的一个特定版本来运行。安装较旧的GPU驱动程序需要根访问权限,这是我们在远程计算集群上工作时通常没有的。即使你是在一台本地机器上做这件事,在那里你确实有根访问权限,安装一个新的GPU驱动程序的过程是非常缓慢和乏味的。多亏了Nvidia不友好的驱动程序安装程序,这个过程无法自动化,一旦完成,你可能会成功地获得Pytork的特定版本,但你的其他项目无法再运行,因为它们需要最新版本。
为了可复制性,研究人员被鼓励发布他们的代码,但如果没有其他人能够在几个月后运行这些代码,那就没有多大意义了。因此,我们开始鼓励那些希望发布代码的人使用Docker或Singularity进行集装箱化。这修复了诸如Python软件包损坏、PyTorch或TensorFlow版本不兼容以及主机系统上缺少库等问题。然而,还有另一个问题,那就是很多深度学习代码不能在CPU上运行得足够快,无法使用。我们看到的大多数代码都需要GPU加速。实现这一点的解决方案是使用nvidia docker,这是docker的一个特殊版本,允许代码访问主机的nvidia GPU驱动程序。然而,这再次引发了一个问题,即容器中运行的代码需要在主机上安装一个特殊版本的GPU驱动程序才能正确运行。Nvidia让人们能够访问Docker容器内的GPU的解决方案是破坏容器,并在过程中暴露主机系统的细节。
我们每年集体浪费多少时间来修复因依赖关系中断而导致的错误?每天损失了多少百万小时的生产力?我们花了多少时间重写那些在软件崩溃前运行良好的软件?在我看来,代码腐败是一个我们应该努力解决或至少缓解的问题。由于我将在本文后面讨论的基本原因,代码腐败可能永远无法完全消除,但我认为,通过更具原则性和前瞻性的软件工程,情况可能会变得更好。至少,如果我们承认代码腐烂是一个问题,并且首先理解是什么导致了它的发生,情况就会有所改善。
我们如何避免代码破坏?Linus Torvalds似乎认为,一般来说,在编译软件时,如果可以的话,最好是静态链接库,因为很少有共享库实际上是版本安全的,而且使用动态链接,你总是在增加复杂性,让自己面临这样的风险:你的软件将要安装到的系统没有与你需要的库兼容的版本。
在我看来,对于依赖软件包管理器的软件,如果可能的话,最好修复软件包版本号。也就是说,直接在包的清单中指定要使用的每个依赖项的版本。原因是,不幸的是,您不能信任更新版本的依赖项不进行破坏性更改,而且通常,一个破坏的依赖项就是导致软件破坏的全部条件。在某些情况下,程序员可能会避免指定固定的版本号,因为Python的pip等软件包管理器不支持同时安装给定软件包的多个版本,这可能意味着您请求的软件包版本可能会与已安装在给定系统上的其他软件冲突。如果我们想要构建可靠的软件,包管理器需要解决这个缺点。
不过还有一个问题。Python的部分吸引力在于,它可以通过其外部函数接口(FFI)轻松地与C代码链接。这是Python如此受欢迎的原因之一,因为它使任何人都可以轻松编写一个包,与常用的C库进行接口,并从C生态系统的优势中获益。然而,这种便利是有代价的。FFI本质上是一扇陷阱门,通过它,软件可以访问包管理器无法控制的外部依赖项,这大大增加了代码破坏的风险。第三方软件包管理器(如Conda)试图通过管理外部库和Python代码的安装来解决这个问题,但这可能会与通过其他方式安装的Python软件包产生冲突。
在我看来,在现实世界中处理这些问题的最实际的解决方案是对软件设计采取保守和最低限度的方法。如果可能,有目的地最小化依赖关系。不要添加新的依赖项,除非增加的价值真的值得潜在代码破坏的额外成本。尽可能避免外部依赖,如果选择依赖外部软件包和库,请选择稳定、维护良好、向后兼容且易于安装的库。记住,你的软件可能只需要一个坏掉的依赖项就无法在用户的系统上运行,如果你的软件坏了,他们甚至可能不会告诉你它坏了。
良好的软件工程实践可以在很大程度上降低代码腐烂的风险,但我认为,首先问问自己代码腐烂的原因是什么也是很有价值的。我们能不能开发出不会腐烂的软件?一个有趣的观察结果是,在某种意义上,这样的软件确实存在。人们仍在为Super NES和Atari 2600等复古游戏平台编写软件。这些平台基本上是时间冻结的,设备固定,I/O能力有限。平台的固定性、简单性以及不可能依赖外部软件包意味着为其编写的任何软件都不太可能被平台本身的变化破坏。
代码腐烂的根本原因是变化。世界本身在变化,软件也在变化。因此,要完全防止代码腐烂,唯一的方法就是针对一个永不改变的平台。不仅是平台本身,还有平台与外部的每个接口、每个设备、文件格式和网络协议。我们无法阻止世界的变化,但我们可以尝试在更稳定的基础上构建软件。就像旧金山的千禧大厦一样,现代软件是建立在我们脚下不断移动的软土上的,但它不一定是这样的。
尽管世界确实在变化,但计算世界的许多元素仍然相当稳定。电脑键盘从20世纪50年代就开始出现了。自20世纪80年代以来,彩色显示器就已经出现了。十多年来,触摸设备已经司空见惯。IPv4于1981年推出,IPv6于1995年推出。如果我写的程序只需要读取键盘和指针设备的输入,并在屏幕上显示像素,那么这个程序就没有真正的理由需要中断。用于从键盘获取输入和将帧渲染到显示器的API可以非常简单。世界将不断变化,新的I/O设备将被发明,但即使在200年后,键盘和彩色显示器的概念也应该易于理解和使用。
最近,我一直在考虑虚拟机的设计。如果我们想创造出不会崩溃的软件,也许我们需要的是某种可执行代码存档格式。这是一个极简的虚拟机,带有一小组I/O设备,这些设备通过一个小的API界面、一个小的RISC指令集连接,总体设计尽可能简单和稳定。有点像现代版的Commodore 64,具有高分辨率彩色显示屏和现代机器的性能。我不认为这样的东西适用于每一个用例,但我猜测,我们使用的许多软件实际上只需要以相当简单的方式与外部世界进行交互。例如,它需要从用户那里获取鼠标点击或触摸设备方面的输入,需要绘制像素来显示用户界面,可能还需要读取和写入文件,可能还需要访问网络。该软件基本上不需要链接到任何外部库,所有东西都可以静态链接,它只需要简单、稳定的界面就可以访问外部世界。
虚拟机(VM)的概念并不新鲜。Java虚拟机试图实现这一点,但基本上失败了。Sun Microsystems创造了著名的“写一次,到处运行”口号,人们开始开玩笑地嘲笑它为“写一次,到处调试”。在我看来,大多数虚拟机设计人员出错的地方是,他们倾向于暴露太多的API,而且每个人都有一个太大的API表面。当一个API有一个大的表面时,很容易出现细微的bug和小问题。您几乎不可避免地会以不同的方式使用不同的API实现。Web Audio和Canvas HTML API就是很好的例子。创建具有大表面积的API是因为VM设计者认为这对程序员来说更方便,并且会产生更好的性能。然而,这种便利是有代价的,因为它使代码更容易被破坏。基本上,要输出音频,应该能够输出一个简单的浮点采样列表,要绘制像素,应该能够输出一个像素网格。它不必比这复杂得多,如果API保持简单,它们就不太可能崩溃。
为了将代码破坏的风险降到最低,API边界并不是唯一需要考虑的问题。如果你的软件与外界连接,你还需要考虑文件格式和网络协议。在这方面,选择成熟、稳定、开放的标准通常是可取的。我不知道我对代码归档格式的想法,或是为最大限度地提高API稳定性而设计的虚拟机,是否会大行其道。目前,它仍然是一个思想实验,但与此同时,我鼓励每个开发人员考虑有目的地最小化依赖性,设计更小、更稳定的API,以及以一种能够最大限度地延长其有效保质期的方式包装他们的软件。设计更健壮、更耐用的软件是一种方法,通过这种方法,你可以以较小的方式改善大量人的生活,并帮助减少电子垃圾。