↓推荐关注↓
因为我们被 C++ 折磨得不轻,包括:
工具与编译器 / 平台差异;
人体工程学与线程安全问题;
社区活力不足。
坦率地讲,C++ 的语言生态并不好。比如说,因为 C++ 没有“rustup”、也没有在旧有操作系统上安装最新 C++ 编译器的标准方法,所以我们在为 LTS Linux 和其他早期版本 macOS 发布最新 Fish 软件包时就遇到了麻烦。
Fish 还被迫使用线程来实现广受好评的自动建议与语法高亮显示,添加并发性的计划也因为 C++ 的自身局限而长期缺乏进展。
这里跟大家说个秘密:虽然外部命令能够并行运行,但 FISH 的内部命令(内置命令与函数)目前仍只能串行执行,而无法在后台运行。解除这一限制将实现异步提示和非阻塞补全等功能,同时提高性能表现。
POSIX shell 选择用子 shell 来解决这个问题,但子 shell 是一种不完善的抽象,可能会在种种意想不到的情况下造成麻烦。我们希望尽量避免这些不可控的因素。
我们还尝试使用 C++ 开发真正的多线程执行原型,但没能成功。比如说,其很容易意外跨线程共享对象,还得配合 Thread Sanitizer 等辅助工具才能防止此类问题。
C++ 的人体工程学也很糟糕——头文件很烦人、模板很复杂、经常触发编译错误,导致标准库中出现大量重载。许多函数使用起来不够安全,C++ 字符串处理非常冗长,许多方法的重载都容易引起混淆,因此很容易变成 C 样式的字符指针并引发安全风险。总而言之,C++ 是一种优先考虑性能、而非人体工程学的语言,这对开发者显然很不友好。
Curses 这个 C 库则是 C++ 开发实践中相当典型的案例。这是个用于访问终端功能的古老库,我们用它来访问 terminfo 数据库,后者负责描述终端功能与行为中的差异。
整个过程不仅相当麻烦、用起来也不够安全,而且感受上也很别扭——cur_term 指针(有时是宏)可以为 NULL,经常在意想不到的地方被取消引用,而且在源代码构建时也会引发大量问题。
最后得承认,C++ 对开发者的吸引力不强,贡献者们其实对它有点“嫌弃”。在 FISH 使用 C++ 这 11 年里,只有 17 位提交量超过 10 次的贡献者。
所以值此离别之际,我们也想给 C++ 社区提点感想:希望 C++ 语言和工具的人体工程学及安全性能有所改进,这些改进其实比性能更重要。我们也希望 C++ 编译器在实际系统上的升级过程能简单一点。
因为 Rust 很酷,也很有趣。
首先,FISH 只是个业余项目,所以我们都是用爱发电来参与的。没人因为开发 FISH 而拿过一分钱报酬,因此趣味性就成了留住贡献者的关键。
Rust 的工具生态也很出色,注重实用性且编译器错误机制出色。这可不是跟 C++ 相比,而是 Rust 拥有绝对意义上优秀甚至卓越的错误消息机制,非常棒。
其安装体验也很好——Rustup 堪称神奇,能够让大家快速上手、将 root 权限使用频度降到最低。相较于 C++ 编译器那复杂到爆炸的升级流程,Rust 这边只须使用 rustup。
Rust 具有出色的人体工程学——即使对于刚刚接触的新手来说,C++ 指针也是被碾压的一方。
Rust 还有明确的使用系统,能够帮助开发人员确切了解哪个函数来自哪个模块,要比 C++ 的 #include 好用很多。
Rust 的依赖项添加体验也更好。现在我们可以轻松使用各种工具能够读取的特定格式,而 Rust 则顺畅支持 YAML/JSON/KDL 等主流选项。
从 FISH shell 的角度来看,Rust 真正的王炸其实是 Send 和 Sync,即静态执行线程规则。“无所顾虑地并发”太强大了,我们可以通过 Send 和 Sync 实现完全的多线程执行,并对其正确性充满信心。
当然,肯定也有其他同样适合的编程语言,只是我们没有那么多时间一一了解。我们相信 Rust 能够胜任这项任务,并果断开始行动。
网上有不少关于 Rust 平台支持力偏弱的讨论,但在我们这边没什么大问题——我们的 macOS、Linux 和 BSD 几大平台都受支持,Opensolaris/Illumos 和 Haiku 也不在话下。反正我们是没听说过有人想在 NonStop 上运行 FISH。
从 Debian 系统的流行度来估算,99.9995% 的 Debian 设备都安装有 Rust 包。再结合 Fish 在 Debian 系统中 1.92% 的安装比例来看,预计每 25 万台设备中只有两、三台不受支持,完全可以接受。
跟很多网友的猜测不同,我们并不是为了支持原生 Windows 端口才转向 Rust 的。其实 Fish 本质上是一款 UNIX shell,它不仅依赖于 UNIX API,还依赖于其语义,并且在脚本语言中直接暴露。总之我们是搞 UNIX 的,开发的也是 UNIX shell,跟 Windows shell 没啥关系。
我们唯一关注但缺乏支持的平台是 Cygwin,很遗憾,但一点点妥协也完全可以接受。
我们决定以“忒修斯之船”的方式完成移植——即逐个组件迁移,直到 C++ 代码被彻底替换掉。而且在过程中的每个阶段,项目都仍能正常运行。
这非常必要,因为如果不这样做,我们在几个月时间里就没有可以正常工作的版本。这不仅令人沮丧,还会影响大部分测试套件(即运行脚本或者伪终端交互的端到端测试)的正常运行。
闭门造车的问题就在这里——不光迁移可能根本没法完成,而且在测试阶段没准还要打回重来。此外,这种方式也让我们保持了 C++ 代码结构的基本完整,这样我们可以比较迁移前后的情况,找出转译错误出现在哪里。
因此,我们使用 autocxx 生成 C++ 与 Rust 代码之间的绑定,确保每次只移植一个组件。
移植的第一步从内置函数开始。这些函数本质上就是小型独立程序,拥有自己的参数、流、退出代码等。也就是说,只要有办法从 C++ 这边调用 Rust 内置函数,就能轻松将它们与 shell 的其余部分拆解开来分别移植。
对于如何将函数接入主 shell,我们使用了以下三种方法:
添加 FFI 胶水代码,使得 Rust 可以调用 C++ 函数,这样就能先移植调用方、后移植被调用函数。
将被调用函数迁移至 Rust,如有必要则保证其可从 C++ 处调用。
编写被调用函数的 Rust 版本,并从移植后的调用方处调用,且继续保留 C++ 版本。
例如,几乎每个内置函数都需要解析其选项。我们有自己的 getopt 实现,并在初始 PR 中用 Rust 进行了重新实现,但同时也将 C++ 版本保留到了最后。若非如此,我们就得编写一个 C++ 到 Rust 的桥接器,再调整 C++ 调用方来使用,这显然就太麻烦了。
迁移工作大概就是这样有序推进,但最终我们还是遇到了更复杂的系统,这时必须选择更大的移植块,从而减少需要临时编写并终将被丢弃的 FFI 胶水代码的数量。比如说输入 / 输出“读取器”,作为 FISH 中最大的部分,其最终转换成了约 1.3 万行 Rust 代码。
在移植过程中,我们在 autocxx 上遇到了不少问题。有时候它理解不了特定 C++ construct,我们只能花时间尝试解决。比如说,我们在 C++ 端引入了一个打包 C++ 向量的 construct,但出于某种原因,autocxx 总是弹出 vector
另外,因为 autocxx 生成了大量代码,所以某些辅助工具的表现也不如预期。比如 rust-analyzer 的运行速度就特别慢。
总之,虽然我们的代码库已经算是相当适合迁移至 Rust(因为没怎么使用异常或者模板),但 autocxx 的使用体验确实一般。它能正常起效的确令人眼前一亮,也确实帮助我们完成了移植,但距离完美还差得很远。
初始 PR 发布于 2023 年 1 月 28 日,并于 2023 年 2 月 19 日合并。
Fish 3.7.0 是 C++ 分支下的另一个版本,用于整理某些增量改进,于 2024 年 1 月发布。
最后一点 C++ 代码于 2024 年 1 月被彻底删除(残留的额外测试代码于 2024 年 6 月 12 日被移植至 C)。
首个移植后的 beta 测试版于 2024 年 12 月 17 日发布。
当初的移植计划本来打算在半年之内完成,最终显然没有达成,但大家对此并不失望。坦率地讲,14 个月也是份相当不错的成绩了,毕竟我们在期间还发布了一个 C++ 版本,就是说迁移工作甚至没有打乱我们的常规发布节奏。
大部分工作是由 7 位贡献者完成的(即至少提交过 10 次.rs 文件的贡献者),同时也要感谢很多社区成员的热心参与。
造成延迟的原因主要有以下几点:
“最后 10% 需要翻倍的时间才能完成”——我们进行了全面测试,清除了大量 bug。如果急于发布,那绝对是个极其糟糕的版本。
不光是迁移,还要有新东西——用新代码做同样的事情没啥意义,一定要有所差别。所以我们推迟了发布,直到做出让人眼前一亮的成果。
有时候,部分成员需要休息一段时间,这也是人之常情。
所以大家在评判之前,请先了解一个基本事实:我们只是一群志愿者,大家完全是在自发参与,能做成这样已经很不容易了。
必须承认,Rust 并不完美,我们对它的某些状况也颇有微词。
最主要的一点就是 Rust 对于可移植性的处理方式。虽然它提供大量系统抽象,允许使用相同的代码指向多种系统,但在较低层级的系统上进行代码适配时,仍然完全依赖于手动枚举,即使用 #[cfg(any(target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))] 之类的检查。
这个办法明显有很多问题,可能会遗漏某些系统并忽略版本间的差异。据我们所知,libc 能够能够为我们要使用的函数添加 FreeBSD 12,但如果不经精心检查,在 FreeBSD 11 上直接调用则会触发失败。
但直接在代码中列出目标,实际上是在重复 libc crate(在本项目中)已经完成的工作。所以要想调用 libc::X(仅在系统 A、B 和 C 上定义完成),则需要单独为 A、B 和 C 添加该检查;如果 libc 添加了系统 D,则需要额外添加。好在我们使用自己的 rsconf crate 在 build.rs 中实现了编译时功能检查。
假如 Rust 能有某种形式的“如果该函数存在,则将其编译”的功能——#[cfg(has_fn = "fstatat")],那情况就会好得多。这样 libc create 就能进行任何检查,而 FISH 则遵循其结果,帮助我们大大精简现有 rsconf。现在的方案无法支持缺少某些功能的陈旧发行版,只能通过 min_target_API_version cfg 来解决。
我们还遇到了本地化问题——Rust 往往依赖于在编译时检查的格式字符串,可遗憾的是这些内容无法转译。我们只能将 musl 移植为 printf,这是我们内置 printf 函数运行所需,确保在运行时内复用预先存在的格式字符串。
迁移期间我们也遇到了不少错误。例如,我们最初使用一个复杂的宏以允许将字符串写为“foo”L,但其最终未能起效,所以我们将其删除并转而使用更常规的 L!(“foo”) 宏调用。
libc crate 中的弃用警告也经常让人摸不着头脑。它解释说“time_t”将在 musl 上转换为 64 位。我们曾尝试解决这个问题,添加了很多打包器,但最终发现其实没有实际影响,毕竟我们不会把从一个 C 库处获取的 time_t 传递到另一个 C 库。
有时对原始代码中细微差别的忽略也会引发 bug,进而导致崩溃。比如我们使用了断言或者断言的现代实现“.unwrap()”。这通常就是转译 C++ 的直接方法,但事实证明其准确性不足,有时需要替换成其他错误处理机制。
但总的来说,这些问题并不太难发现。而且出现之后,往往经常几次尝试和调整就能解决,所以就还好。
我们还因为开启了 link-time-optimization 并在 CMake 默认使用发布 bulds(目前需要运行完整的测试套件)而引发了一些问题,导致构建时间意外超过预期。
虽然抱怨不少,但迁移至 Rust 的好处也随着时间推移而开始显现。
还记得我们之前提到的(n)curses 问题吗?现在问题没了,因为我们压根就不使用 curses。相反,我们转为使用一个 Rust 包,它唯一的功能就是访问 terminfo 并扩展其序列。这消除了尴尬的全局状态,用不着费心保证在系统上“正确”安装 curses——cargo 下载相应包并构建就行。
我们仍然会读取 terminfo,就是说用户还是需要安装。但这个过程可以在运行时内完成,其已经预安装在所有主流系统之上;如果找不到,也只需使用 xterm-256color 定义的随附副本。
我们还高潮创建了“自安装”FISH 包,其中包括 FISH 二进制文件在运行时写出的所有函数、补全及其他资产文件。如此一来,我们就能创建静态链接版本的 FISH(在 Linux 上则使用 musl,因为 glibc 总会崩溃且无法解决),这样我们就第一次拥有了能够在任意 Linux 上下载并顺利运行的单独文件(唯一需要注意的就是架构匹配)!
对于想要使用 FISH,但有时候又需要以 SSH 接入服务器的朋友来说,这无疑是个巨大的福音。他们可能没有 root 访问权限来安装软件包,而现在一个 scp 就能解决所有问题。
我们当然也可以使用 C23 的 #embed 实现类似的效果,但 Rust 的办法更简单也同样有效。
我们没能成功完成的一项目标,就是在移植之后删除 CMake。
这是因为 Cargo 虽然在构建方面表现出色,但在安装方面却过于简单粗暴。Cargo 希望把所有内容都塞进几个简洁的二进制文件之内,但这对我们的项目并不适用。FISH 拥有约 1200 个.fish 脚本(961 条补全,217 条相关函数),外加约 130 页的说明文档(html 及手册页面),外加 web-config 工具与手册页面生成器(均由 Python 编写)。
项目中还有一个测试套件,其在单元测试方面比重不大,主要关注端到端脚本和交互式测试方面。脚本测试通过我们自己的 littlecheck 工具运行,该工具会运行脚本并将输出结果与嵌入的注释进行比较。交互式测试由 pexpect 驱动,其会模拟终端交互并检查按下按钮时是否触发了正确的行为。
于是我们保留了精简版的 CMake 来完成上述任务,但将构建工作移交给了 Cargo。
当然,也可以把这些都交给更简单的任务运行器,比如 Just 或者更常见的 makefiles。但因为之前的设计运行良好,所以我们决定暂时保留,这样对于程序包来说构建过程并不会发生实质性改变。
我们暂时未将 Cygwin 列为受支持平台,因为 Cygwin 无法针对 Rust 构建二进制文件。我们希望这种情况未来会有所改变,但目前在 Windows 上运行 FISH 的唯一方法只有使用 WSL。
我们这个巨大的迁移项目取得了成功,下面列举几个数字让大家直观感受一下:
变更文件达 1155 个,涉及 110247 次插入(添加)、88941 次删除(削减),其中不包括转译。
200 多位贡献者共提交 2604 次。
提交 498 个问题。
近 2 年的工作周期。
将 57000 行 C++ 迁移为 75000 行 Rust(外加 400 行 C)。
彻底清退 C++ 代码。
目前的 beta 版本运行良好,性能整体上比之前的版本略好一些,内存使用量的下限比之前高、但上限比之前低——闲置时为 8M,高于之前的 7M;但即使是在运行高负载任务时也不会增加太多。当然,这些还有改进的空间,但对于初步迁移成果来说已经令人相当满意了。
必须承认,如今的 FISH 还是有点怪……作为一款 Rust 程序,它仍然会直接使用 C API,也在沿用 UTF-32 字符串。希望接下来能找到更好的解决方案,但在迁移后的首个版本中,就不要求那么多啦。
移植过程的确充满挑战,很多工作也没能按计划进行。但总体来看,进展还算相当顺利。现在我们拥有了让人更加心情舒畅的新代码库,增添了不少在 C++ 时代难以处理的功能,另有更多功能正在开发当中。
总之,Rust 干得不错,我们很开心。
原文链接:
https://fishshell.com/blog/rustport/
最近极客时间出了一个《面试后优雅谈薪》的专栏,目前还在内测阶段,主要是看市场反馈来定价,所以现在还是免费阶段,等上线了估计就可能收费了。
干我们这样的基本都求一个落袋为安,如果能多拿点薪资自然是很美的事,可很多童鞋压根不知道如何谈薪?
这里分享一下极客时间的这份内测专栏,只申请到30个内测名额,扫描下方二维码自取,如果还能加得了客服就说明还有名额,如果无法添加就说明名额已经完了。。。
扫描上方二维码自取
—— EOF —— 你好,我是飞宇。日常分享C/C++、计算机学习经验、工作体会,欢迎点击此处查看我以前的学习笔记&经验&分享的资源。
我组建了一些社群一起交流,群里有大牛也有小白,如果你有意可以一起进群交流。
欢迎你添加我的微信,我拉你进技术交流群。此外,我也会经常在微信上分享一些计算机学习经验以及工作体验,还有一些内推机会。
加个微信,打开另一扇窗