将进程 Teleforking 到另一台计算机上!
有一天,一位同事提到他正在考虑用于分布式计算集群的 API,我开玩笑地回答说:“显然,理想的 API 将简单地调用 telefork() 并且您的进程在集群的每台机器上唤醒,返回值是实例 ID ”。我最终被这个想法迷住了:我无法理解这显然很愚蠢,但比我见过的任何远程作业 API 都容易,而且似乎不是计算机可以做的事情。我也有点知道我该怎么做,而且我已经有了一个好名字,这是任何项目中最难的部分,所以我开始工作了。
在一个周末,我有一个基本原型,在另一个周末,我有一个演示,我可以将进程传送到云中的巨型 VM,在许多核心上运行路径跟踪渲染作业,然后将进程传送回来,全部打包在一个简单的 API 中。
这是它在 8 秒内在 64 核云 VM 上运行渲染的视频(加上前后的 Telefork 各 6 秒)。相同的渲染需要 40 秒在我笔记本电脑的容器中本地运行:
如何传送一个进程?
这就是本文要解释的内容!基本思想是,在低级别,Linux 进程只有几个不同的部分,对于每个部分,您只需要一种方法从捐助者那里检索它,通过网络将其流式传输,然后将其复制到克隆的进程中。
你可能会想,“但是等等,你怎么能复制 [一些合理的东西,比如 TCP 连接]?”基本上我只是不复制棘手的东西,这样我就可以保持简单,这意味着它只是一个有趣的技术演示,你可能不应该将它用于任何真实的东西。尽管如此,它仍然可以传送一大类主要是计算程序!
它是什么样子的
我把它写成一个 Rust 库,但理论上你可以将它包装在一个 C API 中,然后通过 FFI 绑定使用它来传送甚至是 Python 进程。该实现只有大约 500 行代码(加上 200 行注释),您可以像这样使用它:
use telefork::{telefork, TeleforkLocation};
fn main() {
let args: Vec<String> = std::env::args().collect();
let destination = args.get(1).expect("expected arg: address of teleserver");
let mut stream = std::net::TcpStream::connect(destination).unwrap();
match telefork(&mut stream).unwrap() {
TeleforkLocation::Child(val) => {
println!("I teleported to another computer and was passed {}!", val);
}
TeleforkLocation::Parent => println!("Done sending!"),
};
}
我还提供了一个名为 yoyo 的助手,它可以传送到服务器,执行你给它的闭包,然后传送回来。这提供了一种错觉,即您可以轻松地在远程服务器上运行一段代码,也许是一个具有更多计算能力的服务器。
// load the scene locally, this might require loading local scene files to memory
let scene = create_scene();
let mut backbuffer = vec![Vec3::new(0.0, 0.0, 0.0); width * height];
telefork::yoyo(destination, || {
// do a big ray tracing job on the remote server with many cores!
render_scene(&scene, width, height, &mut backbuffer);
});
// write out the result to the local file system
save_png_file(width, height, &backbuffer);
Linux 进程剖析
让我们看看 Linux 上的进程(操作系统 telefork 工作)是什么样子的:

内存映射:这些指定了我们程序正在使用的可能内存地址空间中的字节范围,由 4 KB 的“页面”组成。您可以使用 /proc/<pid>/maps 文件检查它们的进程。这些包含我们程序的所有可执行代码以及它正在使用的数据。
这些有几种不同的类型,但我们可以将它们视为需要在同一位置复制和重新创建的字节范围(一些特殊的除外)。
线程:一个进程可以有多个线程在同一块内存上同时执行。它们有 id 可能还有其他一些状态,但是当它们暂停时,它们主要由对应于执行点的处理器的寄存器来描述。一旦我们复制了所有内存,我们就可以将寄存器内容复制到目标进程上的线程中,然后恢复它。
文件描述符:操作系统有一个将普通整数映射到特殊内核资源的表。您可以通过将这些整数传递给系统调用来处理这些资源。这些文件描述符可以指向一大堆不同类型的资源,其中一些像 TCP 连接可能很难克隆。
我只是放弃了这部分,根本不处理它们。唯一有效的是标准输入/标准输出/标准错误,因为它们总是为您映射到 0、1 和 2。
这并不意味着无法处理它们,只是需要一些额外的工作,我稍后会谈到。
杂项:还有一些其他杂项的过程状态,其复制难度各不相同,而且大多数时候并不重要。示例包括 brk 堆指针。其中一些只能通过其他恢复工作添加的奇怪技巧或特殊系统调用(如 PR_SET_MM_MAP)来恢复。
所以我们可以通过弄清楚如何重新创建内存映射和主线程寄存器来实现一个基本的 Telefork。这应该处理简单的程序,这些程序主要进行计算而不与文件等操作系统资源进行交互(以一种需要传送的方式,在一个系统上打开文件并在调用 Telefork 之前关闭它就可以了)。
如何将一个过程远程化
我不是第一个想到在另一台机器上重新创建进程的可能性的人。我给 rr 记录和重放调试器的作者 @rocallahan 发了电子邮件,问了一些问题,因为 rr 做了一些与我想做的非常相似的事情。他让我知道了 CRIU 的存在,这是一个现有的系统,可以将 Linux 进程流式传输到不同的系统,专为在主机之间实时迁移容器而设计。 CRIU 支持恢复各种文件描述符和其他状态,但是代码非常复杂,并且使用了许多需要特殊内核构建或 root 权限的系统调用。从 CRIU wiki 页面链接,我发现 DMTCP 是为快照分布式超级计算机作业而构建的,因此它们可以稍后重新启动,并且更容易遵循代码。
这些并没有阻止我尝试实现我自己的系统,因为它们非常复杂并且需要特殊的运行器和基础设施,我想展示一个基本的传送是多么简单,让它只是一个库调用。因此,我从 rr、CRIU、DMTCP 和一些 ptrace 示例中阅读了一些源代码,并整理了我自己的 telefork 程序。我的方法以它自己的方式起作用,这是不同技术的大杂烩。
为了传送一个进程,需要在调用 telefork 的源进程中完成工作,并在调用接收服务器上的流式进程并从流(telepad)重新创建它的函数时完成工作。这些可以同时发生,但也可以在加载之前进行所有序列化,例如通过转储到文件然后稍后加载。
下面是这两个过程的简化概述,如果您想确切地知道一切是如何发生的,我鼓励您阅读源代码。它被大量注释,全部在一个文件中,并且排序,因此您可以从上到下阅读它以了解一切是如何工作的。
使用telefork发送一个进程
telefork 函数被赋予了一个可写流,它通过该流发送其进程的所有状态。
将这个过程分叉到一个冷冻的孩子身上。进程可能很难检查自己的状态,因为当它检查状态时,堆栈和寄存器之类的东西会发生变化。我们可以通过使用普通的 Unix fork 来避免这种情况,然后让子进程自行停止以便我们检查它。
检查内存映射。这可以通过解析 /proc/<pid>/maps 来找出所有内存映射的位置来完成。我为此使用了 proc_maps 板条箱。
发送特殊内核映射的信息。基于 DMTCP 所做的事情,我们不是复制特殊内核映射的内容,而是重新映射它们,最好在其余映射之前完成,因此我们首先流式传输它们而不包含它们的内容。这些像 [vdso] 这样的特殊映射用于进行某些系统调用,比如加快时间。
循环其他内存映射并将它们流式传输到提供的管道。我首先序列化一个包含有关映射信息的结构,然后循环其中的页面并使用 process_vm_readv 系统调用将内存从子进程复制到缓冲区,然后将该缓冲区写入通道。
发送寄存器。我对 ptrace 系统调用使用 PTRACE_GETREGS 选项,它允许我获取子进程的所有寄存器值。然后我只是通过管道将它们写在一条消息中。
在子进程中运行系统调用
为了将目标进程塑造成传入进程的副本,我们需要让进程自己执行一堆系统调用,而无需访问任何代码,因为我们已将其全部删除。以下是我使用 ptrace 进行远程系统调用的方法,它是一种用于操作和检查其他进程的通用系统调用:
查找系统调用指令。您至少需要一个系统调用指令才能让子执行位于可执行映射中。有些人修补了一个,但我使用 process_vm_readv 来读取内核 [vdso] 映射的第一页,据我所知,到目前为止,在所有 Linux 版本中至少包含一个系统调用,然后在字节中搜索它的抵消。我只执行一次并在移动 [vdso] 映射时更新它。
使用 PTRACE_SETREGS 设置寄存器以执行系统调用。指令指针指向系统调用指令,rax 保存 Linux 系统调用号,rdi、rsi、rdx、r10、r8、r9 保存参数。
使用 PTRACE_SINGLESTEP 选项将进程单步执行一条指令以执行系统调用指令。
使用 PTRACE_GETREGS 读取寄存器以检索系统调用返回值并查看它是否成功。
使用 Telepad 接收进程
使用这个原语和我已经描述过的原语,我们可以重新创建这个过程:
叉一个冻孩子。与发送类似,但这次我们需要一个子进程,我们可以对其进行操作以将其变成流式传输进程的克隆。
检查内存映射。这次我们需要知道所有现有的内存映射,以便我们可以删除它们为传入进程腾出空间。
取消映射现有映射。我们遍历每个映射并操纵子进程在它们上调用 munmap。
重新映射特殊的内核映射。从流中读取它们的目的地并使用 mremap 将它们重新映射到它们的目标目的地。
流入新的映射。使用远程 mmap 创建映射,然后使用 process_vm_writev 将内存页面流式传输到其中。
恢复寄存器。使用 PTRACE_SETREGS 恢复发送过来的主线程的寄存器,但 rax 除外,它是快照进程停止的 raise(SIGSTOP) 的返回值,我们用传递给 telepad 的任意整数覆盖它。
使用任意值,以便 Telefork 服务器可以传递进程进入的 TCP 连接的文件描述符,以便它可以发回数据,或者在 yoyo 的情况下通过同一连接执行 Telefork。
使用 PTRACE_DETACH 以全新的内容重新启动该过程。
正确地做更多的事情
在我的 Telefork 实现中仍有一些问题。我知道如何修复它们,但我对我已经实现了多少感到满意,有时它们很难修复。这描述了这些事情的一些有趣的例子:
正确处理 vDSO。我以与 DMTCP 相同的方式重新映射 vDSO,但事实证明只有在完全相同的内核版本上恢复时才有效。相反,复制 vDSO 内容可以在同一版本的不同构建中工作,这就是我让路径跟踪演示工作的方式,因为在 glibc 中获取 CPU 内核的数量会使用 vDSO 检查当前时间以缓存计数。但是,实际正确执行此操作的方法是修补所有 vDSO 函数以仅像 rr 那样执行系统调用指令,或者修补每个 vDSO 函数以从施主进程跳转到 vDSO 函数。
恢复 brk 和其他杂项状态。我尝试使用 DMTCP 中的方法来恢复 brk 指针,但它仅在目标 brk 大于捐赠者的 brk 时才有效。正确的恢复其他东西的方法是 PR_SET_MM_MAP,但这需要提升的权限和内核构建标志。
恢复线程本地存储。 Rust 中的线程本地存储似乎只是工作™,大概是因为 FS 和 GS 寄存器已恢复,但显然有某种 pid 和 tid 的 glibc 缓存可能会弄乱另一种线程本地存储。 CRIU 可以使用花哨的命名空间做的一种解决方案是使用相同的 PID 和 TID 恢复进程。
恢复一些文件描述符。这可以通过对每种类型的文件描述符使用单独的策略来完成,例如检查目标系统上是否存在具有相同名称/内容的文件,或者使用 FUSE 将所有读/写转发到进程源系统。然而,要支持所有类型的文件描述符(例如运行 TCP 连接)需要付出大量努力,因此 DMTCP 和 CRIU 只是煞费苦心地实现了最常见的类型并放弃了诸如 perf_event_open 句柄之类的东西。
处理多个线程。普通的 Unix fork() 不会这样做,但它应该只涉及在内存流之前停止所有线程,然后复制它们的寄存器并在克隆进程的线程中恢复它们。
更疯狂的想法
我认为这表明,一些你可能认为不可能的疯狂事情实际上可以在正确的低级接口下完成。这里有一些扩展基本 telefork 想法的想法,这些想法完全可以实现,尽管可能只有一个非常新的或修补过的内核:
集群 Telefork。 telefork 最初的灵感是把一个进程流式传输到计算集群中的每台机器上。您甚至可以使用 UDP 多播或点对点技术来更快地将内存分配到整个集群。您可能还想提供通信原语。
惰性内存流。 CRIU 向内核提交了补丁以添加名为 userfaultfd 的东西,它可以比 SIGSEGV 处理程序和 mmap 更有效地捕获页面错误并映射到新页面。这可以让您仅在程序访问它们时才流式传输新的内存页面,从而允许您以较低的延迟传送进程,因为它们基本上可以立即开始运行。
远程线程!你可以透明地让一个进程认为它运行在一台拥有一千个核心的机器上。您可以使用 userfaultfd 加上一个用于 userfaultfd 写保护的补丁集,该补丁集刚刚在本月早些时候合并,以实现像 MESI 这样的高速缓存一致性算法,以有效地在机器集群之间复制进程内存,这样内存只需要在一个机器读取了自上次读取后另一人写入的页面。然后线程只是一组寄存器,通过将它们交换到内核线程池的寄存器中,在机器之间分配非常便宜,并智能地重新排列,使它们与与之通信的其他线程在同一台机器上。您甚至可以通过暂停系统调用指令、将线程转移到原始主机、执行系统调用然后再传回来使系统调用工作。这基本上是您的多核或多插槽 CPU 的工作方式,除了使用页面而不是缓存线和网络而不是总线。用于多核编程的线程之间的共享最小化等相同的技术将使程序在这里高效运行。我认为这实际上可能非常酷,尽管它可能需要更多的内核支持才能无缝工作,但它可以让您像编写多核机器一样编写分布式集群,并且(使用我没有的一堆优化技巧'还没有写)它是否与您本来会编写的分布式系统具有竞争效率。
结论
我认为这些东西真的很酷,因为它是我最喜欢的技术之一的一个实例,它正在深入寻找一个鲜为人知的抽象层,这使得看起来几乎不可能的事情实际上没有那么多工作。传送计算似乎是不可能的,或者它需要像序列化所有状态、将二进制可执行文件复制到远程机器,并使用特殊的命令行标志在那里运行以重新加载状态等技术。但是在你最喜欢的编程语言下面有一个抽象层,你可以在其中选择一个相当简单的子集,从而可以用任何语言在一个周末用 500 行代码传送至少大多数纯计算。我认为这种深入研究通常会导致更简单、更通用的解决方案。我的另一个这样的项目是 Numderline。
当然,它们通常看起来像是被诅咒的黑客,而且在很大程度上确实如此。他们以没有人预料到的方式做事,当他们中断时,他们会在他们不应该中断的抽象层中断,就像你的文件描述符神秘地消失一样。有时尽管您可以恰到好处地触及抽象层并处理所有情况,从而使一切都是无缝且神奇的,但我认为这方面的好例子是 rr(尽管 telefork 设法被诅咒到足以对其进行段错误)和云 VM 实时迁移(基本上是 hypervisor 层的 Telefork)。
我也喜欢将这些东西视为计算机系统工作的替代方式的灵感。为什么我们的集群计算 API 比仅仅运行一个向集群广播函数的程序更难使用?为什么网络系统编程比多线程编程困难得多?当然,您可以给出各种充分的理由,但它们主要是基于其他现有系统如何工作的难度。也许通过正确的抽象或足够的努力,一个项目可以无缝地使其工作,这似乎从根本上是可能的。
文章来源:https://thume.ca/2020/04/18/telefork-forking-a-process-onto-a-different-computer/