前不久,微软在 Linux 基金会董事会的代表 Sarah Novotny 认为,由纯文本电邮讨论推动的 Linux 内核开发需要被更好的或替代协作工具取代,以降低门槛引入新的贡献者,维护和维持未来的 Linux。她认为替代工具可以是基于文本的、基于电邮的补丁系统,某种程度上是过去五到十年成长起来的开发者所熟悉的工具。此前 Linus 曾在接受采访时表示很难找到新的 Linux 内核维护者。

Linux 内核的工作方式为什么不能与 GitHub 相匹配?本文作者深入分析了背后的原因。以下为正文。(需要说明的是,本文虽为一篇旧文,但今天看来仍非常有价值。)


前不久,我跟几位出色的项目维护者进行了交流,探讨如何对大型开源项目进行规模扩展,以及 Github 如何强制要求项目采用特定的扩展方式。这里要多提一句,很多习惯于在 GitHub 上托管项目的开发者可能并不了解,其实 Linux 内核的维护模式完全不同。在本文中,咱们就具体看看 Linux 内核的工作方式、与 GitHub 的区别以及这种区别的产生原因。

而讨论这些问题的另一个重要动机,源自我在《维护者不扩展》演讲中发起的讨论,其中认同度最高的问题就是,“……这些老顽固为什么不愿意用现代开发工具?”虽然一部分顶级内核维护者仍然在大力支持邮件列表与 github pull request 等传统方法,但项目中负责图形子系统的贡献者确实更喜欢现代工具,毕竟脚本编写难度更低。问题在于,GitHub 并不支持 Linux 内核所采取的贡献者扩展方式,所以我们没办法简单迁移——甚至连迁移少部分子系统都做不到。当然,托管是 git 数据是没问题的,最大的困难在于 GitHub 上 pull request、issue 以及 fork 的实现方式。

Github 的扩展之道

Git 很棒,因为每个人都能够轻松在上面分叉、创建分支以及修改代码。其中的优势也显而易见,为主 repo 创建一项 pull request,然后进行审查、测试与合并。GitHub 同样非常强大,它提供一套 UI,能够让这些复杂的操作变得易于学习、易于上手,并借此大大降低了项目贡献的技术门槛。

但最终总会有一些项目取得巨大的成功,但标记、标签、排序、bot-herding 以及自动化机制的缺失,导致现有托管平台无法满足 repo 在处理大量 pull request 及 issue 方面的需要。为此,必须将项目拆分为更易于管理的形式。更重要的是,根据项目规模与诞生时间的不同,各个部分还需要配合不同的规则与流程:新的实验性 repo 与主体代码之间往往具有不同的稳定性与 CI 规则。另外,我们还可能面对一大堆废弃的插件,早已不受支持但又不能贸然删除。具体来讲,我们需要将庞大的项目拆分成多个子项目,保证每个子项目都拥有自己的流程与运作风格,同时分别纳入独立的标准、pull request 以及 issue 实现风格。光是这项重组工作本身,可能就需要几十甚至上百位全职贡献者的不懈努力……而这,真的有必要吗?

几乎所有托管在 GitHub 上的项目,都需要将其 monorepo 源代码树拆分成多个不同的项目,借此维持正常运转。而各个项目都拥有其独特的功能集。分散化的结果,就是同一个项目中包含多个核心,外加成堆的插件、库以及扩展。所有这些都依靠着某种插件或者软件包管理器被捆绑在一起,在必要时直接从 GitHub repo 中提取内容。

目前几乎所有大型项目遵循的都是这样的治理结构,所以咱们就没必要再赘述这种方式的好处了。相反,我觉得应该强调一下这么做引发的问题:

  • 本该统一的社区陷入碎片化。大多数贡献者只拥有自己直接贡献的代码与 repo,而接触不到其他项目内容。这虽然有助于贡献者集中注意力,但同时也降低了他们在不同插件及库之间共享工作成果、以及同其他子项目团队组织并行开发协作的可能性。而项目的总体负责人,则需要通过一系列脚本、git 子模块甚至是大量 repo 协同才能完成自己的本职工作。另外,由于总体负责人需要关注所有内容,因此极易被汹涌而来的 pull request 及 issue 所吞没。任何与当前项目及 repo 拆分方式不一致的协作需求(例如共享构建工具、说明文档等),都会给相应的维护者带来巨大的压力。

  • 即使充分认识到了结构重组的必要性,结构调整与代码共享也仍会面临一系列官僚式障碍:首先,大家需要发布新版本的核心库,而后浏览所有插件并进行更新,接着根据实际情况删除共享库中的部分旧代码。而高度碎片化,往往导致旧代码删除执行得不够彻底。

当然,相当一部分工作其实不需要那么麻烦,也有很多项目都可以轻松完成变更。但无论如何,具体操作都要比对单一 monorepo 直接执行 pull request 要麻烦得多。因此,项目贡献者将倾向于不频繁执行非常简单的重构(例如仅共享一项新功能),这会在一段时间内快速积累起大量提交库存。当然,我们可以使用面向单一函数的 node.js 重构方式,但这相当于是把源代码控制系统由 git 替换成了 npm——npm 也不怎么样,实话实说。

  • 理论上 ,受支持的版本组合在数量上终将爆炸,彻底破坏掉我们的支持体系。作为用户,大家最终必须自行完成集成测试。换言之,对于单一项目,最终将只有其中某些元素的特定组合才能正常运作,或者至少在事实上具有可靠的匹配稳定性。同样的,这实际意味着我们的 monorepo 已经无法在 git 当中进行管理。或者说……除非我们选择使用子模,但这还能算是 git 吗?

  • 对于完整项目,重组其子项目拆分方式同样非常麻烦。这意味着我们需要重新组织各 git repo 及其拆分思路。在 monorepo 当中,只需要更新 OWNER 或者 MAINTAINERS 文件即可转移维护权,只要各 bot 运作良好,新的维护者就得被自动标记。但如果您的扩展方案将各不长 repo 拆分成彼此不相交的集合,那么任何重组操作在难度上都将等同于从零开始将 monorepo 拆分成一组 repo。换句话说,您的项目将始终摆脱不了糟糕的组织结构。

插曲:为什么存在 Pull Request 这种东西

Linux 内核项目,是我所了解的少数几个没有进行过此类拆分的大型项目。在深入探讨 Linux 内核项目的维护方式之前,我们首先需要明确一点——内核开发是一项规模极大的工作,不可能在缺少子项目结构的情况下运行。这也让我不禁想到,git 为什么要采用 pull request 这种结构设计:在 GitHub 上,pull request 可以说是贡献者提交开发成果乃至合并更改的唯一认证途径。但在内核项目这边,即使已经广泛引入了 git,大家仍然习惯将变更以补丁的形式通过邮件列表进行发送。

但事实上,git 从第一个版本开始就在支持 pull request。初始版本确实相当粗糙,而其受众则主要是内核维护者,那时候 git 的诞生,完全是为了解决 Linus Torvalds 维护者团队所面临的实际问题。虽然 git 很好也很实用,但并不适用于单一贡献者:即使在今天,甚至可预见的未来,pull request 仍然主要用于转发面向整个子系统的变更,或是在不同代码之间同步代码重构、乃至以类似的跨领域方式更改子项目。例如,由 Linus 提交的 Dave S. Miller 的 4.12 网络 pull request 就包含来自 600 位贡献者的超过 2000 项提交,外加一大堆来自下游维护者的合并请求。但是,几乎所有补丁程序都由维护者们从邮件列表中获取,而非由补丁作者自行提交。也正是这种将补丁程序提交至内核共享 repo 的实际操作、往往并非由补丁作者本人执行的实际情况,才让 git 在设计层面特别强调分别跟踪提交者与提交作者。

而 GitHub 最大的创新与改进,就是对所有内容都使用 pull request,包括各项个人贡献。但这很明显已经与 git 的最初诞生诉求有所区别。

Linux 内核的扩展之道

乍看之下,Linux 内核很像是那种 monorepo,所有东西都被收纳在 Linux 的主 repo 当中。但实际情况真这么简单吗?当然不是:

  • 几乎没有人会使用 Linux 运行 Linus Torvalds 的主 repo。虽然 Linus 对于上游应用来说可以算是最稳定的内核选项之一,但大多数用户实际上是在自己的发行版中运行内核,因此所需要的内核通常还包含其他补丁程序与反向移植代码,甚至并未被托管在 kernel.org 网站上。这就形成了一种完全不同的组织结构。或者,不少用户也会使用由硬件供应商提供的内核(适用于 SoC 以及几乎所有 Android 平台),而且与“主”repo 相比,这些内核往往包含规模可观的增量元素。

  • 除了 Linus 本人之外,已经没有人在 Linus 的 repo 上开发项目。时至今日,每一种子系统,包括各类大型驱动程序,都拥有自己的 git repo、自己的邮件列表、以及用于跟踪提交并处理问题的独立体系。

  • 跨子系统工作在 linux-next 集成树之上实现,其中包含来自众多不同不长 repo 的数百个 git 分支。

  • 所有这些多到疯狂的元素,都需要通过 MAINTAINERS 文件与 get_maintainers.pl 脚本进行管理。以此为基础,我们才能面对任意给定的代码段,通过脚本了解谁是对应的维护者、谁需要对此进行审查、正确的 git repo 在哪里、要使用哪份邮件列表以及如何与在哪里上报 bug。其中不仅包含准确的文件位置,同时还提供代码模式以保证跨子系统的具体主题(例如设备树的处理或 kobject 层级结构)都由合适的专家负责处理。

从概念层面看,这种方法似乎太过复杂,导致每个人的磁盘里都塞上不少跟自己根本没什么关系的管理系统。但总体来说,这种治理结构确实具有一定优势:

  • 对项目内容进行重新整理与子项目拆分的过程非常简单,只需要更新 MAINTAINERS 文件即可完成。当然,大家可能需要创建一个新的 repo、新的邮件列表以及新的 bugzilla,所以实际操作过程往往并没这么轻松。但正如 GitHub 直接提供简单的 fork 按钮,这方面问题只靠 UI 中的一点设计即可解决,不是什么大事。

  • 我们可以在各子项目之间非常轻松地重新分配关于 pull request 及问题的讨论内容。大家只需在回复当中调整 Cc: list 即可。同样的,跨子系统的各项工作也更易于协调,因为您可以将同一请求提交至多个子项目;而且面向存储在不同邮件列表归档中的邮件地址,您只需要一项整体讨论(可以使用 Msg-Ids: tags 在邮件列表线程处理内添加所有人的标签),就能够向成千上万个不同的收件箱发送消息。这一切不仅降低了子项目主题与代码层面的讨论难度、避免了碎片化倾向,同时也让代码共享与重构的收益更易被项目的全体参与者所理解与承认。

  • 跨子系统工作不需要任何形式的发布操作。大家只需要更改代码即可,所有代码都将存储在同一 repo 当中。另外,这也是一种远比拆分 repo 更为强大的治理模式:对于破坏性重构,大家仍可将其强制分发至多个发行版当中。例如在存在大量用户的情况下,您可以立即对其 repo 做出变更,而不必有协调统筹方面浪费太多时间。

这种对重构及代码共享的简化,极大减轻了陈旧技术带来的债务负担。内核中不再需要保留毫无意义的非稳定版 api 说明文档。

  • 这种方式虽然看似强硬,但并不会阻止大家创建自己的实验性添加项,而这也是多 repo 设置的核心优势之一。您可以将代码直接添加到自己的分支当中,之后再也不必费心打理——不会有人强迫您将代码撤回,或者将其推送至特定 repo 或者是主组织,因为这套体系中根本就不存在中央 repo。这样的设计思路听起来颇有道理,实际表现也着实不错,毕竟 Android 硬件供应商 repo 中那数以百万计的代码行已经充分证明了这一点。

简而言之,我认为这是一套更严格也更强大的模式,至少其中保留了后撤至采用多个互不相干 repo 的灵活空间。甚至某些英伟达驱动程序都有自己的专用 repo,与主内核树完全不相交。这类 repo 只涉及一丁点源代码,而且出于法律原因,其中不能包含内核中的任何内容,可以算是个完美的极端案例了。

这听起来就像一场超大型 monorepo 的噩梦!

对,也不对。

乍看之下,Linux 内核确实很像是个 monorepo,因为一切尽皆囊括于其中。相信很多朋友都知道 monorepo 的问题,其规模增长到一定程度后,将再无进一步扩展的可能。但认真分析,我们会发现 Linux 内核项目跟单一 git repo 有着诸多本质区别。其不仅拥有数百个上游子系统与驱动程序 repo,着眼于整个生态系统,来自硬件供应商、各发行版乃至其他基于 Linux 的操作系统与独立产品的主要 repo 更是成千上万。这还不包括各类供个人使用的私有 git repo。

二者之间的关键区别,在于 Linux 内核虽然为所有内容提供一套作为共享命名空间的单一文件层级结构,但出于不同应用需求与关注重点,各 repo 又保持着相互独立。换言之,Linux 内核项目更像是个由众多 repo 构成的 monotree,而非 monorepo。

请举例说明!

在深入解释 GitHub 目前为什么无法支持其工作流之前,我们首先需要挑选几个典型案例,解释其在实践运作中的具体特性。先给出结论:所有工作都需要通过维护者之间的 git pull request 完成,这就是最大的痛点。

最简单的情况就是对维护者的层级结构进行渗透式变更,直到各项变更落实在树结构当中。整个过程非常简单,因为其中的 pull request 只需要从一个 repo 转向另一个 repo,所以仅使用现有 GitHub UI 即可完成。

但跨多个子系统的变更则要复杂得多,因为后续出现的 pull request 不再以非循环图刑期睁大眼睛,而是变成了网格结构。第一步就是由所有相关子系统及其维护者对变更进行审查与测试。在 GitHub 流中,相当于同时面向多个 repo 提交 pull request,并在各请求之间共享同一条讨论流。在 Linux 内核中,这一步骤将通过一系列不同的邮件列表,将补丁提交给各维护者手中。

审核的方式也往往无法与合并方式相统一,而需要选择某一子系统作为主子系统并负责接收 pull request,且只能在其他所有维护者都表示同意后才执行路径合并。这里选定的往往是受变更影响最大的子系统,但有时候也可以是负责执行其他工作、但与当前 pull request 相冲突的子系统。有时候,如果变更会影响到整个树结构、而非明确影响少部分文件及目标,项目可能还需要建立一套全新 repo 并指定专项维护者。最近的相关实例就是 DMA 映射树,其目标在于合并以往一直分散在各驱动程序、平台维护者以及架构支持组当中的工作成果。

但有时候,可能同时存在多个既与当前变更存在冲突,又无法通过常规合并方式处理的子系统。一旦出现这种情况,我们无法直接应用补丁程序(即 GitHub 上的 rebasing pull),而需要通过单一提交将仅包含必要补丁的 pull request 推送至全部子系统,从而将其合并至所有子系统树当中。在这种情况下,我们必须建立通行基准,保证各子系统树不会因此出现不相关变更、或者说遭受污染。由于该 pull 只面向特定主题,因此这些分支通常被称为主题分支。

结合实际经历,我曾经参与一个项目,旨在添加代码以实现经由 HDMI 的音频支持功能。这部分代码需要跨越图形与声音驱动程序子系统。来自同一项 pull request 的同一批提交需要同时被合并至英特尔图形驱动程序以及音频驱动程序当中。

作为世界上规模最大的通用型操作系统项目之一,Linux 选择这种方式当然也是经过了充分考虑。Linux 内核同样采用 monotree 单一树状结构,只是此结构极度庞大,甚至需要专门的全新 GVFS 虚拟文件系统为其提供支持。

给 Github 提点意见

遗憾的是,GitHub 并不支持这样的工作流,至于在 GitHub UI 中不提供原生支持。虽然只需要简单的 git 工具即可完成此类操作,但之后我们还是得回到邮件列表上的补丁程序,并以手动方式通过邮件执行 pull request。在我看来,这也是 Linux 内核社区决定不向 GitHub 迁移的核心原因。总体来说,虽然也有不少顶级维护者对 GitHub 还有这样或者那样的抱怨,但这些都不是真正的关键技术问题。而且不仅仅是 Linux 内核,一般来说任何规模足够大的项目都 GitHub 上都很难顺利扩展,因为 GitHub 在设计上就限制了项目通过 monotree 进行多 repo 扩展的空间。

说到这里,我想给 GitHub 提一项简单的功能要求:

请通过单一 monotree 对多个 repo 上的 pull request 与 issue 跟踪提供支持。

思路很简单,影响却将极为深远。

Repo 与组织

首先,我们可能希望在同一组织之内为同一 repo 保留多个分叉版本。看看 git.kernel.org 就能看到,其中的大部分 repo 并不属于个人项目。即使不同的组织可能各自拥有不同的子系统,但硬性要求每个 repo 对应一个组织的作法都相当愚蠢、过度僵化,只会给用户的访问与管理带来不必要的阻碍。例如,在图形领域,我们在 GitHub 上只能为用户空间测试套件、共享用户空间库以及工具与脚本常规集分别提供一套 repo;却无法建立一套整体性的子系统 repo,外加一套专门容纳核心子系统工作 repo 以及分别面向各大型驱动程序的对应 repo。这些完全可以作为多个独立分叉存在,但 GitHub 却不支持。很明显,我们设法的方法更科学,其中每个 repo 都拥有大量分支,其中一个分支负责实现应用功能,而其他分支则可用于支持发布周期内的 bug 修正。

将所有分支整合至同一 repo 中也不可行,因为 GitHub 上 repo 拆分的目的正是将 pull request 与 issues 各自区分开来。

同样的,GitHub 应当允许用户根据实际情况建立起分叉关系。虽然现有设计对从零开始诞生在 GitHub 上的新项目来说还算友好,但 Linux 显然不能这么干——这意味着我们每次只能移动 Linux 中的一个子系统,更不用说目前 GitHub 上早已包含数量庞大且彼此冲突的 Linux repo。

Pull Request

我们有必要将 pull request 同时附加至多个 repo,同时保持一条统一的共享讨论流。现在,GitHub 虽然允许用户将同一 pull request 重新分配至目标 repo 的另一不同分支,但却无法实现同一 pull request 对多个不同 repo 的同时分配。事实上,这种对 pull request 进行重新分配的功能非常重要,因为新的贡献者们只会为他们认定的主 repo 创建 pull request。以此为基础,各 bot 将分别将 pull request 发送至 MAINTAINERS 文件中列出的全部 repo 中的特定文件及变更组处。在与 GitHub 项目人员的交流中,我一直建议他们直接提供这项实现。但从现状来看,只要这一功能仍然能够在各独立项目上通过脚本实现,GitHub 就不会推出真正的标准。

这方面还存在与 UI 相关的问题:对于指向不同分支的 pull request,其对应的补丁列表也可能有所区别。但这不一定就是用户的错,同一套 repo 中往往也已经合并了多项补丁。同样,不同 repo 对于 pull request 的状态也有不同的要求。一位维护者可能倾向于将当前 pull request 交由另一子系统进行处理,因此直接拒绝合并请求;而另一位维护者则决定直接合并。而另一树状结构甚至可能出于旧版本兼容性或供应商分叉版本的考虑,而直接将当前 pull request 无效化。更有趣的是,每个子系统都可能通过多项不同的合并提交而对同一 pull request 进行多次合并。

Issues

与 pull request 类似,issues 也可能同时涉及多个 repo,甚至需要进行往来转移。这里我们以 bug 为例,假定从某一发行版的内核 repo 中初次发现并上报了一项 bug。在查验之后,我们证明该 bug 归属于某驱动程序,目前处于最新的开发分支当中,且同时影响到当前 repo、上游主分支以及其他多个分支。

这里我们需要对状态进行再次拆分,因为一次向一套 repo 推送补丁的作法无法快速覆盖到全部 repo。我们甚至需要组织额外的修复方案,借此处理较为陈旧的内核或发行版,包括将一部分已经没有修复必要的 repo 以 WONTFIX 的形式关闭,并在相应子系统 repo 中将其标记为“已成功解决”。

总结:要 Monotree,不要 Monorepo

Linux 内核不会登陆 GitHub。但真正重要的是,GitHub 应该学习 Linux 内核项目采取的 monotree 架构思路,此举也将给目前 GitHub 之上的各类大型项目带来显著收益。在我看来,这种架构层面的转换,将为整个 GitHub 带来一种强大且独特的扩展能力与灵活空间。

英文原文

Why Github can’t host the Linux Kernel Community