Skip to content

引言

软件文明在退步吗?

我时常会觉得现代软件相比于曾经的软件存在着某种退步。在过去,我们用 C 这样的语言编写程序时,我知道 open() 会返回一个 fd,我知道 malloc() 会返回一个 ptr,我知道 fork() 会返回一个 pid。这些东西通常被称为 资源 (Resource)。我也知道,为了编写可靠的程序,我应当在使用完这些资源后,调用对应的函数 (比如 close, free, kill) 来回收它们。

而在如今,当我使用 Koa 时,我可以使用 app.use() 来注册一个中间件;当我使用 Vue 时,我可以使用 app.component() 来注册一个组件;当我使用 Node.js 时,我可以直接导入 .node 文件来加载使用 C++ 编写的模块。但很遗憾的是,Koa 不会告诉你如何取消这个中间件,Vue 不会告诉你如何卸载这个组件,Node.js 甚至会永久占用这个 .node 文件。

你当然可以说这是软件发展的结果:底层 API 被妥善地封装了,开发者不再需要关心这些细节。但被封装后的资源仍然是资源,它们仍然有着被回收的需求。或许对于每一个具体的场景,我们都可以找到一个解决方案,或者给出我们不需要回收资源的理由,但面对一个复杂的、未知的应用,如果你想要回收资源而它又没有提供相应的 API,最好的办法就只有重启了。

事实上,封装也根本不是导致这种现象的原因。面对不当使用指针引发的内存安全问题,无论是 C++ 的智能指针、Java 的垃圾回收机制,还是 Rust 的所有权系统,都提供了对指针的封装。这些封装不仅不会导致内存泄露,反而通过提高易用性减少了开发者的心智负担。

有了正面的例子,我们就可以知道,这种退步实际上只是特定领域中呈现出的趋势 (在 JavaScript 和 Python 这种高级语言中尤为明显)。或许是人们认为重启过于方便了,因此一些框架的开发者们已经完全不考虑回收资源的需求了。但现代软件就如同摩天大楼,一旦某一层缺失了支撑,在其上的一切都会变得摇摇欲坠。好在我们或许有办法改善这一切。

可逆的插件系统

即使你不是专业的开发者,你也一定见过许多的插件系统。小到一个游戏的 MOD,大到操作系统的应用,本质上都可以视为一种插件系统。插件化是模块化的一种延伸,它使得一个系统能够在保持本体相对轻量的同时,允许用户扩展更多的功能。

按理说,JavaScript 语言的动态特性使得插件系统的实现变得相对容易。但实际上,大多数框架的插件系统都存在着一些问题:

  1. 不完全的插件化。这些框架往往仅仅将部分外围功能下放到了插件中,而核心功能仍然是硬编码的。插件的能力受到了限制,常常无法解决一些复杂需求。
  2. 不可逆的插件化。这些框架往往不支持插件在运行时卸载 (回收插件占用的资源)。「插件」这个词原本来自现实世界中的概念,而现实中的插头当然是可以拔出的。

也有一些框架试图解决这些问题。对于第一个问题,Webpack 通过 Tapable 实现了一个高度插件化的构建系统,这极大地扩展了插件的能力边界,但与之相对地,过度强调插件化也阻碍了整个系统的优化。一方面,众多插件本身的启动和通信时间成为了性能瓶颈;另一方面,一些插件仅仅是单纯地将代码移动 (而非解耦) 到了新的文件中,这使得各个插件之间的依赖关系变得复杂。

对于第二个问题,VSCode 的扩展支持导出一个卸载 (deactivate) 函数。但在实际使用中,即便是一些 VSCode 官方维护的扩展也需要在卸载后重新加载整个编辑器。在扩展的开发过程中,开发者需要存储每一个 Disposablecontext.subscriptions 中,以便在卸载时手动释放资源。实际上,开发者很可能忘记 (或者根本不关心) 这一步。

Cordis 通过自身的设计同时解决了这两个问题:

  1. 任何产生副作用的代码都处于某个插件中,不同插件的依赖关系通过服务声明。插件的生命周期仅由依赖关系决定,大幅提高了启动速度;任何服务都可以被替换实现,确保了可扩展性。
  2. 所有的 API 产生的副作用都是自动回收的。这意味着,只要框架实现了所在领域内的全部 API,框架的使用者就可以在对 Cordis 无感的情况下编写插件,而不需要担心资源泄露。

作用系统