通过冷门 Linux 进程 Flag 将延迟降低 83%

James Matsuzaki February 28, 2025

目录

通过冷门 Linux 进程 Flag 将延迟降低 83%

那是一个美好的周日晚上。我刚抵达洛杉矶参加公司的线下活动,第一次见到了整个团队。 不久之后,我的手机收到了一条通知,“Antonio 在 Linear 上给你分配了一个 issue。” Antonio 是 Recall.ai 的工程团队负责人,他给我的任务是优化 Output Media 的启动延迟,以便我们客户的 AI agent 能够更快地启动。 我原以为这将是这周我做的最快、最直接的事情,但结果并非如此,它花费的时间比预期的要长得多。 这是一个看似简单的任务如何演变成对 Linux 内核、Bubblewrap 沙箱和 Rust Tokio 线程模型中未记录的复杂性的深入研究的故事。

计划

Output Media 是 Recall.ai 的一项功能,可以从 bot 输出超低延迟的音频和视频。 我们的客户使用 Output Media 构建会议中的 AI agent、交互式应用程序等等。

在底层,Output Media 的工作原理是将客户提供的网页渲染成音频和视频,然后通过 bot 发出。

因为渲染网页涉及到运行任意不受信任的代码,所以我们采取了很多措施来确保这个功能的安全性。 我们使用的最突出的技术是沙箱。 为了性能和简单性,我们使用 Bubblewrap 对 Output Media 代码进行沙箱处理,赋予它有限的权限——仅足以使其正常运行。

该功能当前的状态相当不错——它很可靠,并且您可以使用它构建的东西是无穷无尽的。 只有一个小问题。 每当客户激活 Output Media 时,视频需要 12 秒才能开始流式传输。 糟糕!

之所以花费这么长时间,是因为当 Output Media 被激活时,我们会启动一个 Chromium 实例来渲染网页。 众所周知,Chromium 复杂、庞大且资源密集,而 bot 在资源非常受限的环境中运行。

那么,可以做些什么来解决这个问题呢?

该计划似乎很简单:在 bot 启动时预加载 Chromium,而不是仅在 Output Media 激活时才启动它。 这样,当开发人员请求 bot 加入他们的会议时,Chromium 已经准备就绪,他们的页面可以立即加载。

本世纪的障碍

我正在研究代码,重构它,以便我们可以在会议加入逻辑之前启动 Chromium。 进展顺利,所以我开始在本地测试它。

好的…… Bot 正在运行,它正在加载先决条件,它正在启动 Chromium,完成了……现在它宣布了它的状态。 很好! 让我们把它推送上去。

在那个时候,我非常确信我已经完成了这项任务。 我将其推送到我们的 staging platform,并等待我们的数十个集成测试成功。 但是,他们没有。

检查日志显示,Chromium 启动了,然后终止了——莫名其妙地。 奇怪的是,我们的代码都没有执行进程终止,所以这里发生了什么?

一点背景

Bubblewrap 是 Linux 中包含的一个轻量级程序,允许创建与程序执行内联的隔离沙箱环境。 Bubblewrap 可以利用 Linux 命名空间、seccomp 过滤器、bind mount 和内核级 API 来生成包含的进程。

与完整的虚拟机或像 Docker 这样的容器化(可能资源密集)不同,Bubblewrap 以最小的开销运行,同时仍然提供强大的隔离,这在我们的计算受限的 bot 环境中至关重要。

我们还利用 Tokio 来确保我们 bot 同时处理的所有不同类型的任务的快速性能和并行处理。 这包括诸如将音频实时转发到转录提供商之类的操作,同时处理传入的结果并将它们保存到我们的数据库中以供以后检索,同时将这些结果转发到我们客户指定的 ingress endpoint。

Output Media 也没有什么不同——它一次使用许多线程并将它们全部处理以确保流畅和优化的体验,而不会影响我们客户依赖于我们的 bot 的其他基本任务。 这在以后变得非常重要……

我找到了解决方案! 但是那不应该解决它……

在接下来的一个星期里,我每天都在调试这个进程终止,而且我感到筋疲力尽。

我已经探索了我关于进程终止原因的每一个理论,最终演变为只是将想法扔到墙上,希望有一个会奏效。

我决定使用我们给 Bubblewrap 的参数来运行我们隔离的 Output Media 代码。

糟糕,删除那个完全破坏了一切。 好的,这个什么也没做……那个显然没有解决问题,我为什么要尝试改变它呢? 好的,这个绝对不相关,对吗? 保存,编译。 为什么要这么长时间? 请工作,请工作,请工作,请工作-

“老兄,我解决了。”

在我们的 Bubblewrap 命令中,有一个 flag --die-with-parent,不知何故导致我们的 Chromium 随机终止。 删除此参数似乎解决了该问题。

但是这没有任何意义。 父进程是 bot 的主循环,它肯定还在运行。 那么删除 --die-with-parent flag 是如何解决这个问题的呢?

让我们深入研究一下

Enter image alt description

还记得我几段前提供的关于 Bubblewrap 的背景信息吗? 好吧,--die-with-parent 使用了前面提到的“内核级 API”。 从 flag 的名称来看,我认为它的目的是确保如果父 executor 退出或崩溃,它的退出会向下传播到在 Bubblewrap 中执行的子程序。

从理论上讲,这可能非常有用,以确保您不会最终得到一堆滞留的进程,从而减少了手动清理过程的需要。 但显然它比那更细微一些,因为当设置了 --die-with-parent 时,我们的子进程正在随机终止,即使它的父进程继续存在。

所以我的下一步是实际查看 Bubblewrap 的源代码

/* If --die-with-parent was specified, use PDEATHSIG to ensure SIGKILL
 * is sent to the current process when our parent dies.
 */
static void
handle_die_with_parent (void)
{
 if (opt_die_with_parent && prctl (PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) != 0)
  die_with_error ("prctl");
}

当我第一次看到这段代码时,我非常无动于衷。 它太简单了! 它所做的只是调用 native 函数来设置 PR_SET_PDEATHSIG。 此功能完全由 Linux 内核处理。 当进行此调用时,内核会将此首选项记录在进程的内部数据结构中。

我们遇到的关于 PR_SET_PDEATHSIG 的所有文档都有些误导。 注释块中的短语“When our parent dies”,听起来像是指父 进程。 但实际上,它指的是父 线程。 这个微妙的细节至关重要,而且我们很久都错过了这个细节。

实际原因

在挖掘 Bubblewrap 的源代码和 Linux 内核文档之后,我们最终发现了真正的罪魁祸首。 问题不在于进程死亡,而在于 Linux 内核在使用 PR_SET_PDEATHSIG 时如何解释线程生命周期。

当设置 --die-with-parent flag 时,Bubblewrap 使用 Linux 内核的 prctl(PR_SET_PDEATHSIG, SIGKILL) 调用。 此内核功能旨在在子进程的父线程死亡时向其发送 SIGKILL 信号——不一定是在整个父进程退出时。

以下是该问题在我们的代码库中如何体现的:

  1. Tokio 的线程模型和线程 parking

我们的 bot 代码使用 Tokio,这是一个异步运行时,它管理着一个 worker thread 池。 Tokio 有效地利用线程 parking 和 unparking 来节省资源。 当任务暂时不活动时,它们的线程可以被“parked”(与进入睡眠状态同义),如果它们长时间保持空闲,它们可能会完全停止。

  1. 内核中基于线程的父进程跟踪

当 Tokio 运行时中的线程使用 PR_SET_PDEATHSIG 启动 Bubblewrap 时,Linux 内核会将该特定线程与 Bubblewrap 进程的“父进程”关联起来。 这意味着当该线程退出或被 Tokio 的内部调度程序回收时,内核会认为“父进程”已经死亡,即使整个 bot 进程仍在运行。

  1. 误解导致过早终止

在某些情况下,Tokio 的调度程序 parked 了 spawning Bubblewrap 的父线程。 如果线程保持空闲足够长的时间,Tokio 最终会清理它,完全终止它。 然后,Linux 内核错误地将其解释为父进程的死亡,触发了 PR_SET_PDEATHSIG 并向 Bubblewrap 发送了 SIGKILL。 由于 Bubblewrap 本身在它的沙箱内运行 Chromium,Chromium 也被杀死了,导致了我们观察到的看似随机的崩溃。

这解释了为什么删除 --die-with-parent flag 可以解决问题:我们禁用了错误地将线程终止解释为进程死亡的机制。

此行为不是 Tokio 或 Bubblewrap 中的 bug,而是 Linux 内核的一个难以发现的怪癖。 由于 PR_SET_PDEATHSIG 跟踪父线程,而不是整个进程,因此它与动态 parked 和回收线程的运行时发生意外的交互。 Tokio 的线程管理、Bubblewrap 的进程隔离和 Linux 内核内部结构之间的这种微妙的交互在我们最初检查的任何来源中都没有完全记录,这使其成为一个特别棘手的调试挑战。

隧道尽头的光芒

Enter image alt description

经过所有的调试和调查,我能够重新加快速度并完成此优化。 结果是减少了 10 多秒的等待时间。 现在 output media 的首帧时间平均仅为 2-3 秒,从而为我们的客户带来了更加愉快的体验。 超级值得。

通过一次 API 调用访问会话数据

从 Zoom、Google Meet、Microsoft Teams、Webex 和其他视频会议平台获取 audiovideotranscriptsmetadata

集成于: