把 Common Lisp 当作脚本语言(2015版)
把 Common Lisp 当作脚本语言(2015版)
我使用的第一台计算机有大约 2KB 的 RAM。 前几天,我将一个 2KB 的 Common Lisp 脚本编译成了一个 16MB 的可执行文件,以将它的启动(和总执行)时间从 2 秒缩短到主观感受上的一瞬间——这一点我并不担心,因为我现在的机器拥有 8GB 内存和超过 100GB 的持久存储。 但它确实让我感到一丝丝不安,因为 16MB 也是第一台让我摆脱 RAM 不足恐惧症的计算机:我可以在那台机器上同时运行 X 服务器、Emacs 编辑器和 shell 终端而无需占用虚拟内存! 现在,整个舒适的软件开发领域可能会被一个愚蠢的优化随意地浪费掉——我不得不关注它,因为软件系统仍然很糟糕。 想象一下,在有意识的人类到达其马尔萨斯式的未来之前,代码编程将再次成为一种流行的活动……
背景(如果您不关心硬件战争故事,请跳至下一段):我刚刚退回了我多年前的工作笔记本电脑(一台联想 Thinkpad X230),因为我开始遭遇到各种硬件问题:除了传统的过热和 wifi 卡经常连接失败,需要杀死 wpa_supplicant
守护进程外,电池连接不好有时会导致机器在最不吉利的时候关机。我非常喜欢 Thinkpad 外形,但我的老板不再提供 Thinkpad-s,所以我选择了纤薄的 HP EliteBook Folio 1040:它的外形显然是从 Macbook Air 中汲取灵感的,只不过它运行的是 Linux 系统,我是我的船的主人。现在,EliteBook 的触摸板特别糟糕,甚至比 Thinkpad 的触摸板还要更糟糕,因为我打字时一直被拇指触发;我决定立即禁用它,就像我最终对 Thinkpad 所做的那样;但是,与 Thinkpad 不同的是,EliteBook 没有“clit”界面来补充触摸板。因此,我不得不一次又一次打开和关闭触摸板,而不是永久禁用它。谷歌搜索很快找到了一个切换触摸板的 shell 脚本,以及如何将 Penguin-Space 映射到它的说明(Penguin 是 Super,比它所取代的 Windows 更重要)。但坦率地说,shell 脚本让我呕吐了,我决定用 Lisp 重写它,它产生了一个不到 2KB 长的非常好的程序……
事实上,这几年来,我一直在鼓吹将 Common Lisp 用作脚本语言:语法抽象、高阶函数和高级对象系统的组合、允许高效编译的相对简单的语义模型、强大的编译器具有良好的性能和对所有重要平台的可移植性,以及对交互式调试和结构化编辑的支持——这一切都使其比所有其他常用的动态类型脚本语言(shell、perl、python、ruby、javascript)领先数年,尽管 Lisp 是在这些晚辈语言之前几年或几十年就开发出来的。然而,直到最近,它还缺少一些可用作脚本语言的东西,我为自己敲上了棺材板上的最后几颗钉子而感到自豪:寻找源库时的零配置,存储编译输出时的零管理,以可移植的方式调用外部程序——如果你实现了这些,你也可以把你最喜欢的编程语言当作“脚本语言”来使。
好吧,我最近又在棺材板上敲了一颗额外的钉子,它解决了遗留的启动时间和内存占用之间的权衡:现在可以轻松地在所有脚本之间共享同一个内存堆映像,以实现程序即时启动而不会导致内存和硬盘爆炸。 诚然,您已经可以使用 Xach 的 buildapp
在 SBCL 和 CCL 上进行半可移植的方式进行操作; 但是现在您可以使用 cl-launch
实用程序在所有实现上以完全可移植的方式完成它,您可以使用该实用程序将程序作为脚本调用。
编写 Common Lisp 脚本的可移植方式是使用 cl-launch
,通常在使用 Unix 时通过 #!/usr/bin/cl
脚本规范行。然而,以这种方式启动脚本时,即使是相对简单的程序也可能需要一到几秒钟才能启动:Lisp 编译器将所有目标文件定位并加载到内存中,将代码链接到符号表、类表和方法表中;即使对文件进行了预编译,这也要花费不可忽略的时间,因为编译器从未经过优化以使其速度更快;事实上,典型的 Lisp 黑客在他的交互式 REPL 中一次只重新编译和重新加载一个文件,而不经常从头开始重新加载所有文件。通过使用提供的 install-asdf.lisp
脚本在 SBCL 提供的版本上安装 ASDF 3.1.4,并使用提供的 cl-source-registry-cache.lisp
脚本来避免搜索我相当大的 CL 源代码集合,我可以将启动时间缩短到 0.7 或 0.8 秒左右,但这仍然太多了。对于计算密集型或需要长时间运行的程序来说这点启动延迟不算不什么。但这对于执行延迟非常重要的交互式脚本来说,这个解决方案就不太实用了,就算是不如其它语言,但至少要做到主观的瞬间启动。
/bin/sh
或 perl
在大约 5 毫秒的时间内执行一个空命令,python
大约需要 18 毫秒(所有时间和大小粗略的平均值和估计是在我当前的 linux x86-64 笔记本电脑上得出的)。 如果没有我的可移植性基础架构,您也可以在 10 毫秒内使用 sbcl 或在 15 毫秒内使用 clisp 做到,但是代价是失去可移植性并且被限制为不使用任何软件库,或者回到不可移植的配置和编译地狱 除了具有相同的加载缓慢问题之外。 有了这样的启动延迟,Common Lisp 可能仍然在某种程度上适用于脚本,这与绝大多数需要编译的编程语言不同,后者需要显式的编译步骤以及对源文件和目标文件进行非凡的配置; still it finds itself unsuitable for producing scripts destined for use as instantaneous interactive commands outside its own autistic interactive development environment.
现在,所有重要的 Common Lisp 实现还允许您转储内存映像,所有代码都已加载和链接,并且此类映像启动速度非常快,在 sbcl 上完全加载图像大约需要 20 毫秒,在 clisp 上大约需要 35 毫秒; 您可以使用我的 cl-launch
实用程序通过添加 -output /path/to/executable -dump
来可移植地转储映像! 与启动脚本时使用的命令相同。因此,您可以通过可移植的方式将启动缓慢的脚本转换为预编译的可执行文件,这将使启动时间与其他脚本语言相比具有竞争力,而运行效率可以与其他编译型语言相竞争,而代价是一个额外但微不足道的构建步骤(一次只需几秒钟)。
问题是这样的内存映像在空间方面有很大的开销:一个空的 cl-launch
程序,对于 CLISP 映像有 13MB 大小,对于 CCL 有 28MB, 对于 SBCL 有 52MB(你可以从这个角度来看待这件事:内存映像中包含了整个的编译器和基本库,和 GGC 相比,这还不错——GCC 比这大太多了!);包含我所有要加载的代码的映像在 CLISP 上需要 27MB,在 CCL 上需要 50MB,在 SBCL 上需要 82MB。多十兆字节的图像文件没什么大不了的。这些映像中最大的也只是我笔记本电脑内存的 1%。因此,按照今天的标准,这是一个很小的附加开销。但是,如果每个脚本都需要一个映像,那么执行一个 2KB 脚本需要 80MB 内存,内存浪费的倍数是 40000 —— 如果你像我一样想用 Common Lisp 代码替换许多小的 shell 脚本,这是不可接受的。与每增加 1KB 的脚本代码所增加的空间开销相比,映像的额外大小通常在 1KB 到 10KB 之间,合理的因子为 1 到 10。这提出了一个明显的解决方案:在所有 CL 脚本之间共享映像转储开销,因此空间开销就回到了一个可忽略不计的加法开销和合理的乘法因子,而不是一个令人发指的乘法因子。
busybox
使多调用二进制的旧概念流行起来:一个相同的可执行二进制程序,在执行时根据程序的调用名称表现出不同的行为,因此通过对同一个程序使用多个符号链接(或硬链接),可以用一个二进制文件替换多个不同的二进制文件,得益于动态链接的共享效应和静态链接的优化。对于常见的 Lisp 代码也可以这样做。Xach 的 buildapp
已经允许您在 SBCL 上执行此操作,在 CCL 上也可以使用选项 -dispatched entry
执行此操作。我刚刚丰富了 cl-launch
4.1.2,以便在其所有12+受支持的实现上支持完全相同的接口(好吧,相同的接口,模块化了对极端情况的不同处理)。现在,我已经为7个个人使用的脚本共享了同一个可执行文件,并且只会在缓慢迁移所有旧脚本的同时,为新脚本使用 CL。[2015年11月更新:现在,95MB SBCL 映像中有 44 个个人脚本,16ms启动]
该功能总共有100行代码,包括注释、文档和新的 cl-launch-dispatch.asd
文件;仅当使用 --dispatched-entry
时,才可按需加载对该功能的 Lisp 支持,此时可以稍微自由地加载一个微小的额外 ASDF 系统。我喜欢 Common Lisp 让我以如此模块化的方式实现这一功能。以下是文档:
- 如果使用了选项
-DE --dispatch-entry
,那么下一个参数必须是 NAME/entry 格式,其中 NAME 是程序可以作为调用的名称(uiop:argv0 参数的basename
),entry
是一个函数,在这种情况下,可以作为-entry
调用。对-DE --dispatch-entry
选项的支持委托给dispatch
库,该库与cl-launch
一起分发,但不是cl-launch
本身的一部分
- 注册对
dispatch
库的依赖,就如同指定了-system cl-launch-dispatch
- 如果既没有指定
-restart
也没有指定-entry
,则注册一个默认的入口函数,就像通过-entry cl-launch-dispatch:dispatch-entry
一样。 - 注册一个 init-form 来注册调度条目,就好像
(cl-launch-dispatch:register-name/entry "NAME/ENTRY" :PACKAGE)
已被指定,其中 PACKAGE 是当前包。 有关更多详细信息,请参阅所述库的文档。
现在,这是一个很好的解决方法,但并不能完全解决最初的问题。为了彻底解决这个问题,一个明显的策略是让某些实现从根本上优化编译对象的加载(所谓的 FASL 文件,用于 FASt Loading,有些玩笑应该重命名为 SLOw Loading),因此它实际上变得很快。例如,编译器可以生成一个预链接对象,它乐观地假设它知道加载地址,符号表、类和方法定义等不会发生冲突,并且在运行时只修补通常的最小指针集案件。为 12 个以上的实现这样做是不可行的,但只有一个就足够了,比如 SBCL 或 CCL。或者,可以使用“增量映像”功能,从而可以将所有符号转储到某些包中,而不是其他包中,以及相关的函数、类等;不过,这需要对程序员的习惯进行微小的改变,因此不太可能发生。但任何这样的完整解决方案都需要深入了解 CL 实现的核心,而这并非易事。
假设我们不打算改进底层实现,一个更冗长的“解决方案”可能是扩展变通方法,直到它成为解决方案:在所有重要的程序之间实现可执行文件的自动共享。Debian 的旧的 Common-Lisp-Controller 可以重新使用,为系统的软件包管理器安装的软件创建共享映像和/或共享可执行文件;类似的机制可以声明性地管理给定用户的所有程序(如果可用,可能会在上面分层)。这可能需要对 ASDF 进行一些调整,以便它不会尝试使用系统管理的实现从系统管理的目录构建预构建的软件,而是在用户指定的升级、软件未构建或实现不是系统管理的情况下以通常的方式编译。重要的是,不能有不安全的可写系统范围的FASL缓存。(即,当需要任何写访问权限时,恢复到每用户缓存,或以某种方式与受信任的守护进程交谈,以使用受信任的编译器编译受信任的源)。不过,这种通过系统管理解决问题的方法有点难看。
请注意,这些问题不会影响从 Common Lisp REPL 运行这些脚本提供的功能的 Common Lisp 开发人员;他们已经可以做到了。它只影响从 shell 命令行或其他一些外部非 Lisp 程序从这些脚本运行功能的用户。对于需要这样一个用例的 Common Lisp 开发人员来说,由于这个新的 cl-launch
特性,这些问题的解决方案现在变得微不足道。但是这些问题确实使人们很难发布对最终用户“正常工作”的脚本——最终用户是不需要管理安装或配置步骤的人。这些最终用户将不得不在启动时遭受数秒的暂停,或者为他们使用的每个脚本或一组相关脚本背负多十兆字节的可执行文件。因此,暂时的结论是,虽然 Common Lisp 在许多方面都远远领先于作为一种低开销“脚本语言”的竞争,但它目前确实存在一个问题,使其在这场竞争中处于劣势。部署到最终用户的关键方式。
PS:我的 github repo 中提供了示例,在我的 repo 中有一些常用功能。
后记 你还在用 Common Lisp 编写脚本吗?
Aug. 31st, 2016 12:43 am (UTC)
是的。 由于这些天我在做任何个人脚本,我仍然使用 Common Lisp,因为它击败了所有的 blub 语言,而且我对它很熟悉。 也许我会学习足够多的 Racket 和/或 Haskell 来替换它,但现在,它仍然是 Common Lisp 用于超过一行的任何东西(我仍然在 zsh 中这样做)。
自从我写了那篇文章以来,我通过在 Linux、MacOS X 和 Windows 之间可移植地驱动 ASDF 测试来测试我的脚本支持(请参阅 asdf 存储库中的 Makefile-lisp-scripting),而无需在 Windows 上使用 cygwin、bash 或任何类似的东西。 同时,Elias Pipping 正在向 UIOP 添加异步子流程支持,因此可能会添加我的脚本中当前缺少的一项功能。 要真正做好事情,可能需要使用 IOlib 等,但它需要在 C 代码中链接,这有点问题,除非您使用 Bazel,它允许使用静态链接的 C/C++ 库进行单文件部署, 但目前只在 Linux 上运行良好。
因此,Common Lisp 中的脚本编写情况(目前)并不完美,但已经相当不错了。