凌云的博客

行胜于言

Suture - Supervisor Trees for Go[译]

分类:golang| 发布时间:2022-03-14 15:40:00


前言

译自 Suture - Supervisor Trees for Go

Supervisor trees 是 Erlang 可靠性的核心要素之一,它有自己的一套崩溃哲学。 一个结构良好的 Erlang 程序被分解成多个独立的部分,它们通过消息进行通信,当一个部分崩溃时,该部分的 supervisor 会自动重新启动它。

如果您从未使用过它,这听起来可能并不令人印象深刻。 但我亲眼目睹了我编写的系统每分钟经历数十次崩溃,但对 99% 的用户来说,它们都能正常运行。 即使我在写 suture 的时候,我有时也会惊讶地将屏幕切换到使用 suture 的 Go 程序的控制台中,并惊讶地发现它实际上在我的手动测试期间愉快地崩溃了,但由于它运行地这么好,我甚至都没发现。

(当然了,我紧接着就改进了我的日志记录,这样我在将来就是知道什么时候发生崩溃了。 防止崩溃固然好,但不应该轻率地 “花费” 宝贵的资源!)

由于各种其他原因,我一直在将系统从 Erlang 移植到 Go 中,而 Go 并没有 supervisor trees。 我决定在 Go 中创建 supervisor trees。 但是我们不需要将 Erlang 代码翻译成 Go 语言。 一方面,这根本不可能,因为两者在某些基本方面是互不相容的。 我们希望对功能进行惯用移植,尽可能多地保留原始功能,同时保持将任何新的功能引入其中的可能性。

要正确地做到这一点,第一步是不仅要深入研究 Erlang 的 supervision trees 是什么,还要深入研究原因,然后弄清楚如何移植。

Erlang Supervisor Trees 到底是什么?

Erlang 进程

为什么我们仍然必须涵盖 Erlang 进程是什么? 让我们从底层开始,沿着抽象栈向上工作。

Erlang 和 Go 一样,可以支持许多 “同时” 运行的上下文(对某些定义来说)。 在 Erlang 中,它们被称为“进程”。 回想一下 Erlang 是在 1980-1990 年代后期开发 的,当时并发的主要竞争对手是“线程”。 线程是在同一内存空间中运行的独立执行上下文,能够自由读写共享堆。 Erlang 将其执行上下文称为“进程”,以类比 OS 进程。 这个想法是,像 OS 进程一样,Erlang 进程不能自由读写任何其他进程的 RAM。 一个普通的 Erlang 进程完全由它的本地内存内容来描述,它可能只有几百字节。 我相信这个想法并不是起源于 Erlang,但它是除了 LaTeX 论文之外的早期实现之一。 (并不是说这有什么问题!)

题外话:根据这个定义,goroutines 更像线程。 最好的做法是尽可能地将你的 goroutines 彼此隔离,但 Golang 中没有任何东西强制这点,甚至没有任何特别的帮助来实现这一点。 但有约定总比没有好,并且可以创建一个相对隔离良好的核心库。

Erlang 系统建立在这些进程之上。 像 Go 一样,Erlang 可以在单个系统上启动数百万个这样的进程。 每个进程都有一个 ID,即 “PID”,它是 Erlang 中的一等值。 与 Go 相比,Go 没有代表 goroutine 的一等值。

接下来,我们必须了解进程是如何相互关联的。 可能有如下两个进程:

  • 通过目标进程的 PID,将任意 Erlang 值作为“消息”发送到另一个进程。 这个发送是双方异步的;消息的发送者只是简单地将它抛出并继续运行,而接收者可以自由选择何时接收它,包括可以选择不同于收到消息的顺序。 Erlang 运行时保存消息,直到进程通过 Erlang 的模式匹配选择接收给定的消息。

    这是 Erlang 中主要的通信方式。 API 会在后台发送消息,即使看起来消息没有被发送。 在 Erlang 中,直接使用消息发送操作似乎是一种代码异味。 一般来说,你几乎总是应该使用某种 gen_*,并使用受支持的机制与 gen_* 进程对话。
  • 一个进程可以监视链接到另一个进程;出于我的目的,我将略过差异。 你得到的是一个进程能够说 “让我知道那边的另一个进程是否死了”,无论是通过发送消息还是通过终止侦听进程。 这听起来很暴力,但有其用处;例如,如果进程 A 正在为进程 B 管理资源,那么如果其中一个死掉,你可能只想杀死这两个进程,例如在套接字关闭或其他情况下。 这使得资源管理变得容易。 当我发现需要自己手动将两个 goroutine 之间的这种关系连接在一起的时候,我意识到在 Go 中错过了这一点。
  • 一个进程可能会杀死另一个进程。 我相信这在编程语言理论世界中被称为“异步异常”。 Go 没有这种能力。 在这篇文章的后记中,我添加了为什么 Go 几乎可以肯定永远不支持这种能力的原因。
  • 上述所有工作都可以在节点之间工作,这些节点可能存在于不同的物理系统上。 PID 可以引用另一个连接的 Erlang 节点上的进程,并且所有这些功能都有效,包括链接和监控。

理解 Erlang 设计的真正关键是理解它对可靠性的普遍关注,而不是困扰于它实现的方法。 对于 Erlang 来说,同时处理多个独立的硬件块不仅仅是一个小把戏。 Erlang 哲学包含这样一种想法,即如果软件仅驻留在一个硬件上,就不可能使软件可靠。 Erlang 世界中的跨节点通信更多的是为了可靠性而不是共享工作。

Supervision Trees

从这些片段中,很容易看出构建 supervisor 进程的基本结构。 它是一个进程,它告诉运行时它对目标进程是否死亡感兴趣,当运行时告诉它它已经死亡时,它会采取所需的操作,通常是重新启动它(尽管还有一些其他奇特的选项)。 它应该尽可能少做其他事情,因为我们真的不希望主管本身崩溃,但我们仍然必须为它崩溃的可能性做准备,如果没有其他原因那就是内存损坏了。 (同样,Erlang 对可靠性的关注意味着这种可能性不会被忽视。 达到一定规模以上,内存损坏是真实存在的。)

由于 Erlang 的不变性,“重新启动”进程的形式是使用给定函数以及参数生成一个新的“进程”。 例如,我有一个在多个端口上运行的服务,每个端口都提供相同的服务。 我有一个 supervisor 用于监控所有的监听进程,从概念上讲,它知道如果端口 80 进程崩溃,它需要使用 provide_service_on(80) 生成一个新进程,而如果端口 81 服务崩溃,它需要使用 provide_service_on(81) 生成一个新进程。 Erlang 的 OTP 库用一些很好的声明性功能、各种默认行为和一些默认功能(如基本的“服务器”或基本的“有限状态机”)来包装这一切。

如果我们觉得 supervisor “超过”了它所监督的进程,我们可以通过创建一个进程 “树”(tree)来监督 supervisors。 在实践中,拥有一棵非常深的树不一定有很多价值,所以我想大多数 Erlang supervisor trees 都非常茂密,而它们确实会如此。 在 Erlang 中,您应该定义一个“应用程序”,它是一些具体的功能,封装在一个顶级 supervisor 中,然后会触发其他一些实际实现该功能的 supervisors。 然后,您可以单独启动和停止这些功能。 应用程序可以访问其他功能,例如特定于应用程序的配置、启动和停止它们的特殊命令以及依赖关系图。 因此,即使是最简单的应用程序,您也至少有两个层次的深度。 作为树的顶层,如果它们死亡,它们也会得到特殊处理,默认情况下会关闭整个操作系统进程。 (因此可以重新启动它。)

我记得当我意识到我需要在一个运行 Erlang“应用程序” 的进程运行另一个“应用程序”,仅需要 application:start(new_app) 时我震惊了。 没错,就是这么简单。 Erlang 还开发了更安全的重启方法;如果某些东西只是在启动时无休止地崩溃,supervisor 将停止重新启动它。 (这是通过设置在一定时间内允许的最大崩溃次数来完成的,如果超过,则使 supervisor 崩溃。) 有一些日志记录和崩溃记录等集成功能。 这是一个非常好的经过调谐的插入功能; 如果你用 Erlang 写代码而不用 supervisor trees,那你就错了。

移植 Supervisor Trees 到 Erlang 以外

要将 supervisor trees 最大程度移植到 Go 中,我们应该仔细检查它们是由什么组成的,仔细检查我们使用 Go 的部分,并找出如何尽可能地用惯用语来翻译它们,看看是否有任何有用的东西我们可以从 Go 中挑选出来而这是 Erlang 所没有的。

Erlang 可以说是结构性地从上到下安全的支持 supervisor trees。 Supervisors 有如下组成部分:

  • 隔离的进程
  • 不变性
  • 通过全局共享(包括节点之间)PID 识别进程
  • 多进程并发
  • 信令和异步异常
  • 安全重启(不会只是无休止地重试,防弹等)

严格来说,其中许多并不是 supervisor trees 工作所必需的,但它们改进了它们的工作方式,并且在某些情况下会影响功能。 例如,围绕“链接”的一组特性,以及这使得一个崩溃的服务能够取出其 supervisor,进而取出并重新启动所有子进程的方式,不一定适用于缺乏这些原语的其他语言(并且无论出于何种原因,它们都无法实现)。

比较 Go 和 Erlang

让我们将 Go 与上面的 Erlang 进行比较:

  • 隔离的进程:没有强制执行,但语言的约定和社区至少认为这是一个理想。 Go 不强制分离,所以 Suture 当然不能,但我可以简单地告诉你,Suture-monitored 进程应该尽可能地隔离。
  • 不变性:是的,完全不支持。这会影响 suture 的设计。
  • 进程识别:没有 Go 等价物。Go 不会给你任何类型的 goroutine ID。 我敢肯定,这完全是故意的,而且不太可能改变。 然而事实证明,我们可以使用 defer 将我们需要的所有信息放入堆栈状态,这不是问题。 Supervisor 与其受监管者的关系是如此刻板,以至于我们不需要通用的消息传递系统。
  • 多进程并发:大多数情况下是的,再次警告,它们仅通过约定而不是语言强制来隔离。
  • 信令和异步异常:Erlang 为您提供准备好的解决方案,而 Go 为您提供构建解决方案的工具。 如果 goroutine 碰巧进入无限循环,您无法停止它,但如果您使用它,则有足够的异步通信能够关闭协作进程。 通过在服务的核心“选择”循环中接收到的特殊“停止”通道上发送消息来停止 goroutine 的简单实现在大约 90% 的时间里就足够了。
  • 安全重启:只需编写所需的策略。 在这里,不同于 Erlang 我们确实进行了一些调整…… 因为 Go 不是“早崩溃,经常崩溃”的语言,如果它的 clients 开始行动,我们实际上不会终止 supervisor。 相反,Suture 使用了一种退避方法,以便在服务从未正确启动的情况下,它至少不会吞噬 CPU 无休止地尝试重新启动。

但是让我们不要忘记 Go 有 Erlang 没有的东西,我们可以使用:

  • 用户定义的类型,带有方法和接口——我想我的同事可能厌倦了我把“我的王国属于静态类型系统!” 进入团队聊天室。 我已经准备好摆脱 Erlang 的动态类型。 我想我理解为什么 Erlang 类型系统是这样的(尽管那将是另一篇文章),但我仍然厌倦了它。
  • 可变性……这确实有它自己的问题,但如果我们要使用它,我们也应该利用它的优势。

SUTURE 库

因此,让我们看看我们可以用 Go 做些什么,尝试尽可能多地利用 Go 的优势,同时保持惯用语并利用它的能力。

我们至少有足够的原语来获得我们正在寻找的基本功能。 我们有轻量级的“进程”,尽管是共享状态的。 我们可以拼凑一些足够像“链接”这样的东西,我们可以获得 supervisor trees 所需的东西。 (其余的将不得不等待另一个库。)

Erlang 在其 supervisor trees 中实现的一些东西本身就是对 Erlang 设计的反应,我们不需要把这些也带过来。 例如,关于如何通过指定模块、函数和初始参数集来设置服务的描述是因为 Erlang 必须从头开始创建一个 supervised process 的全新实例, 它对函数进行“初始化”和“执行”之间的区别是割裂的,最初常常让新用户感到困惑。 在 Go 中,不需要我们将 Service 包装在某种形式化的创建方法中,我们可以简单地让用户创建一个实现 Service 接口的值的新实例。 初始化由用户处理,就像值的任何其他初始化一样。 因此,我们可以简化。

我们不需要“行为”。 我们有接口,它们当然更简单,可能更有用。 如果您尝试使用“Service”,编译器将静态验证它是否实现了正确的功能。

内存不是隔离的这一事实不是我们可以在 SUTURE 库中“修复”的。 如果你的服务崩溃了,你应该尽可能多地从你的服务中清除状态,以避免损坏状态导致无限崩溃的情况。 可以尝试指定一个笨拙的框架来初始化新服务,实际上我最初是这样写的,但后来我注意到程序员绕过它无论如何都是微不足道的,只需将 Init() 留空而只是 将所有内容写入 Start()。 事实上,这是我作为 SUTURE 库的用户所做的第一件事,这是库作者不应该忽视的那种线索。 所以我选择简单而不是束缚,现在只是建议你在服务重启时尽可能清理你的状态。

我们没有“链接”或“pids”,但可以考虑通过服务捕获崩溃并通过日志记录重新启动它。 可以在集中位置实现更智能的重启逻辑。 您只需将对服务的“Start”函数的调用包装在 catches panics 中、记录它们并重新启动服务的东西中。 (重启逻辑可能需要一些调整,但当前的逻辑至少是一个开始。)

这允许我们创建:

  • 一个 Service 接口,内容如下:
    type Service interface {
        Serve()
        Stop()
    }
    

    我不得不承认接口是如此简单。 信不信由你,在我明白这一点之前,我经历了几次迭代。 到目前为止,它似乎已经足够了。 令人惊喜的是,最小的 Suture 服务实现比最小的 Erlang gen_* 小得多,并且也更容易理解,因为没有“链接”与“初始化”的混淆。 使用 Go 的那种结构类型,这也意味着一个包可以很容易地提供一个 Suture 服务而不依赖于 Suture,如果需要的话,可以很容易地提供一个非 Suture 依赖的启动功能。

  • 一个 Supervisor,这是一段接受 Services 并管理它们的代码。 当然,Supervisor 本身就是一个 Service,因此创建树只是将 Supervisor 相互连接起来的问题。

虽然我没有为 “应用程序” 创建任何特殊支持,但我发现将我的服务打包到顶层 Supervisors 中是有利的,就像我在 Erlang 中所做的那样。 即使在我这里相对较小的 Go 团队中,我们也已经很开心地将服务组合成各种可执行文件。

关于组合的主题,如果服务本身实际上是某种组合,那么在它们自己的 Supervisor 实例中组合服务也是非常强大的。 它为可能复杂的服务提供了一个简单的启动 API。 更多的例子即将到来。

Supervisor 还捆绑了 Supervisor 工作方式的日志记录,记录来自服务的故障(包括堆栈跟踪),并且如果您向其提供回调,则很容易适应调用您的本地日志记录代码。

为什么?

最后,它是否和 Erlang 一样丝滑顺畅? 坦率地说,没有。 Erlang 在某种意义上是围绕 supervisor trees 构建的,或者至少是提供构建它们的能力的一组特性,因此很难与之竞争。

然而,即使在我有限的经验中,将这种风格应用到 Go 中仍然有足够值得的好处。 我觉得我仅是编写和使用这个库已经获得了不少获得了净收益。 我所做的一切可能都是 Suture 服务,我已经目睹了它占用了大约 99% 的功能代码,并使其成为我可以部署一段时间而不会完全失败的东西。 这是好东西。

另外,我将继续开发维护这个库。 敬请关注。

附录:为什么 Go 永远不会有异步异常

如上所述,Erlang 允许您远程终止目标进程。 这是通过 exit 函数实现的,该函数将异步异常抛出到目标 PID 中,包括在强制终止时抛出无法捕获的异常的可能性,就像 UNIX 中的 kill -9 一样。

要理解为什么这些被称为“异步”,就得从接收异常的进程的角度来看待异常; 这不是程序员习惯的术语的通常含义。 大多数异常是同步的,因为它们要么发生在程序的特定点,要么不发生; 它们与生成它们的代码是“同步的”(其原始含义是同时)。 例如,如果您的语言要为“找不到文件”抛出异常,它会在您尝试打开它时发生,而不是随机发生。 相比之下,从线程的角度来看,“异步异常”随时都可能发生,而从线程的时间计算来看,它与它当前正在做的任何事情都完全无关。

这是 Erlang 范式中的一个微妙之处。 由于一个进程与任何其他进程不共享状态,甚至大多数资源都通过端口保持一定距离,因此可以合理安全地异步终止 Erlang 进程。 被杀死的进程将自动删除其所有值。 它打开的任何资源都将通过一个“端口”,作为 Erlang 进程,该端口将链接到正在使用的进程,因此,当正在使用的进程死亡时,该进程或端口也将“死亡”,因此被杀死的进程 即使在异步终止时,它也有一种明确定义的方式来清理其资源。 它仍然不是完全安全的; 某些资源可能会泄漏,具体取决于它与其他线程的交互方式等,但它是相当安全的。 在 Erlang 中,可以说编写不能安全杀死的代码将是一个错误。

Erlang 通过严格划分进程来解决这个问题。 也就是说,我认为正是严格的分割实现了这一点而不是不变性。 具有不可变值的语言确实更容易提供异步异常,尽管我观察到 Haskell 需要多次迭代才能使其正确。 在这种情况下,可以说是 laziness 使它变得更难,但仍然不清楚具有共享值的严格不可变语言是否也会有微不足道的时间。(In this case, arguably it was the laziness making it harder, but it still is not clear that a strict immutable language with shared values would have a trivial time either.) 然而,在共享状态可变语言中存在异步异常是一个非常糟糕的主意,尽管 Go 使用约定来尝试避免共享状态,但它是一种共享状态可变语言。

当“异步异常”随时可能发生时,不可能用可变状态语言正确编程。 特别是,您不知道线程中间的什么操作是不应该被观察到的;例如,如果 goroutine 处于受互斥锁保护的关键部分的中间,则可以在 goroutine 死亡时清理互斥锁,但无法回滚 goroutine 一半所做的任何事情。 还会出现许多其他更微妙的问题。 例如,尝试使用顶级 defer 保护 goroutine 并不能防止异步异常有破坏您的程序的一天…… 如果您在 deferred 函数本身的中间遇到异步异常怎么办? 在没有异步异常的世界中安全的代码最终可能会由于某些正在运行的 goroutine 无法控制的东西而冒泡到 goroutine 堆栈的顶部... 在当前语义中,这与段错误无法区分,并且您的程序终止。 任何试图绕过它都会带来更多的问题。 我在这里继续写了几段,由于多余而删除了它们。 在这里分形失败! 如果您想自己探索,请记住将其视为敌对环境,就像任何其他线程案例一样。 想象一个敌对的对手试图找到你的线程遇到异步异常的最坏时间是有帮助的,记住:你可以接收任意数量的异常,异常本身很重要,它可能实现了一些其他的保证。..以任何理由忽略它本身就是失败。

如果简单的答案立即浮现在您的脑海中,请记住它们都已尝试过,但没有奏效。 这是所有 CLispScript 语言都面临的一个古老问题,而且它已经相当成熟,没有实际的解决方案。 甚至 Java 最终也不得不将它们踢出去,我提到 Java 不一定是软件工程的典范,而是作为一个明显投入大量精力的项目,世界上所有使该功能工作以实现反向兼容性的动机。 如果他们做不到,并且考虑到可变状态语言中问题的基本性质,可能其他人也做不到。

因此,在编写 supervision tree 库之前等待此功能存在是没有意义的。