如何用Rust和async/await实现pid1(附代码)

363 阅读22分钟

几年前,我写了一篇关于Docker的进程1、孤儿、僵尸和信号处理的详细博文。如果你有兴趣,请阅读这些血淋淋的细节,但高层次的总结是:

  • 在Linux上,ID为1的进程被特别对待,因为它通常是一个初始进程。
  • 进程1负责 "收割孤儿",或者对那些在其父进程死亡后死亡的进程调用waitpid 。(是的,这听起来很病态。)
  • 此外,进程1默认不会响应中断信号而关闭,这意味着Ctrl-C不会关闭该进程。
  • 在Docker中,由于它如何使用c组,你启动的进程通常是进程1。
  • 与其重写你的所有进程以支持收割和响应SIGINT,不如编写一个单独的pid1 可执行文件并将其作为你的Docker入口。

三年前的解决方案是一个提供这种功能的Haskell可执行文件和一个基于Ubuntu的Docker镜像。我几乎所有的Docker工作都以那个镜像为基础,问题解决了。

题外话:正如一些人向我指出的那样,包括Reddit上的u/valarauca14,Docker有一个--init 标志,它解决了关于PID1进程丢失的问题。这与ENTRYPOINT 调用pid1 可执行文件相比,仍有一些缺点,包括(1)每次都需要记住额外的标志,以及缺乏高层工具的支持。但这篇文章并不是为了解决一个真正的问题,而是为了玩转Rust!

用Rust重写吧!

FP Complete团队中的一些Haskeller曾讨论过用Rust重写pid1 ,作为一种教育练习,并与Haskell进行良好的比较。没有人去做这件事。然而,当Rust 1.39发布并支持async/await时,我正在寻找一个好的用例来演示,并决定用pid1 。虽然这里的真正动机是向那些好奇的人展示Rust,特别是我那些喜欢Haskell的同事,但在这个用例中,Rust比Haskell有一些真正的优势:

  • 可执行文件比较小,这很好。
  • 做一个Rust的静态可执行文件比做一个Haskell的更容易(尽管后者是可能的)。通常情况下,你需要确保你有正确的libc
  • Rust没有运行时间,一旦启动子进程,像这样的情况基本上是0开销。
  • 交叉编译更容易,显著。这对于在Mac或Windows上创建Docker镜像来说是非常好的。

但要重申的是,这主要是为了学习和教学。因此,这篇文章的其余部分将是通过实现和解释一些有趣的点。我们会打出这样的主题:

  • 期货
  • async/.await 语法
  • 不安全和FFI
  • 错误处理

完整的代码可以在Github上找到,网址是pid1-rust-poc向我的许多同事道歉,他们坚持要我把它改名为 "死神"。

预期的行为

我们正在编写的程序旨在通过命令行调用,如pid1 command arg1 arg2 arg3 。然后它将:

  • 解析命令行参数,如果没有给出命令名称,则以错误退出。
  • 启动所要求的子进程。
  • 安装一个SIGCHLD 信号处理程序,这将表明一个子进程或孤儿进程已经准备好被收割。
  • 安装一个SIGINT 信号处理程序,它将向子进程发送一个SIGINT。这将使Ctrl-C发挥作用。
  • 启动一个循环,在每次SIGCHLD 发生时收割一个子进程。
  • 一旦直接的子进程退出,pid1 将退出。在Docker案例中,这意味着当用户启动的进程退出时,Docker容器将退出。

上面有一个轻微的竞争条件,因为我们在信号处理程序安装之前启动了子进程。为了使代码更容易理解,我保留了这一点,但如果你想寻求挑战,请随时改进这一点

此外,正如Reddit上的/u/wmanley所指出的,在信号处理程序内锁定一个mutex可能会出现死锁。如果你正在寻找另一个挑战,你可以用以下方法重写 signal_hook::pipe.

解析命令

你可以把命令行参数的列表作为一个迭代器来获取。这个迭代器将把当前可执行文件的名称作为第一个值,我们想忽略它。我们想返回一对命令名称和其余参数的向量。如果没有提供命令,我们将使用一个Result 来捕获错误。把这些都放在一起,这个函数看起来像这样:

fn get_command() -> Result<(String, Vec<String>), Pid1Error> {
    let mut args = std::env::args();
    let _me = args.next();
    match args.next() {
        None => Err(Pid1Error::NoCommandGiven),
        Some(cmd) => Ok((cmd, args.collect())),
    }
}

我们必须将std::env::args() 的结果捕获在一个可变的变量中,因为对next() 的每一次后续调用都会使该值发生变化,本质上是将一个值从堆栈中弹出。我们可以忽略第一个值,然后对第二个值进行模式匹配。如果是None ,那么这个命令就丢失了,我们返回一个Err

否则,如果有一个Some 的值,我们就把它作为命令,并把args 的所有剩余参数收集到一个Vec 。有些有趣的事情要指出来,特别是对Haskellers:

  • Rust有和类型,也就是enums。不过不要被愚弄了:这些是全能的和类型。我个人认为Rust中的和类型(enums)和积类型(structs)的分离比Haskell的data 类型要好,但这是另一次讨论。
  • 模式匹配是美丽而强大的。
  • Rust完全没有约束副作用。调用args.collect() 会消耗args 的值,并且是一个更大的表达式的一部分。这对Haskeller来说是很陌生的,但与 "正常 "编程语言是一致的。
  • 尽管Rust允许突变和效应,但由于默认的不变性,实际影响在这里受到了很好的限制。虽然这个函数理论上可以 "发射导弹",但它在这里以一种很好的、几乎是功能性的方式行事。
  • 我认为在Ok((cmd, args)) ,小括号的双重包装看起来很奇怪,但它至少在逻辑上是一致的。
  • 在一般情况下,我们在Rust中的错误是明确的,而不是使用未检查的运行时异常。我在过去经常谈到这两种系统,我的观点很简单:这两种系统都可以,你应该完全接受你当前语言所提倡的最佳实践。在Haskell中,我觉得使用未检查的运行时异常很好。在Rust中,我对创建enums的错误类型和明确传播没有问题。
    • 我还没有给你看Pid1Error 的定义,我把它留到以后。

说得够多了,让我们继续吧!

的类型main

我们的应用程序需要能够处理一些事情:

  • 如果发生任何错误,它们应该传播出去并产生一个错误信息(来自tyepPid1Error )给用户。
  • 我们想使用Rust 1.39中新的async/.await 和Futures的东西(我们稍后会看到怎么做)。
  • 如果一切顺利的话,我们希望能优雅地退出。

我们将用main 函数的签名来表示这一切。这看起来像。

async fn main() -> Result<(), Pid1Error>

通过返回一个Result ,我们告诉编译器:如果这个函数产生一个Err 变体,就向 stderr 打印一个错误信息,并将退出代码设为失败。通过添加async ,我们在说:这个函数可能await 一些东西。在表面之下,这意味着main 实际上产生的值是Future 的一个实例,但我们稍后会讨论这个问题。现在,重要的是要理解,为了运行这样的函数,我们需要有某种调度器可用。

一个选择是将main 改名为main_inner ,然后写一个main 这样的函数:

fn main() -> Result<(), Pid1Error> {
    async_std::task::block_on(main_inner())
}

然而,有一个叫做async-attributes 的板块,它可以让我们做一些更灵活的事情:

#[async_attributes::main]
async fn main() -> Result<(), Pid1Error> {
    // all of our code with .await
}

几乎让Rust感觉像Haskell、Go或Erlang这样的语言,只是内置了一个绿色的线程系统。相反,Rust需要为获得这种异步代码付出更多的努力,但它几乎完全是用户区代码,而不是运行时系统。这也意味着你可以很容易地换掉不同的调度器。

启动和错误处理

在我们的main 函数中,我们首先调用get_command 函数:

let (cmd, args) = get_command()?;

对不熟悉的人来说,可能会冒出两个问题:

  1. 我以为那个函数会返回一个Result 的值,为什么它看起来像是在返回一个对?
  2. 那个问号是什么?

也许并不令人惊讶,其中一个问题可以回答另一个问题。问号可以被添加到Rust的任何表达式中,以方便错误处理。具体的细节比这更复杂,但上面的代码基本上可以转换为:

let (cmd, args) = match get_command() {
    Ok(pair) => pair,
    Err(e) => return Err(e),
};

换句话说,如果值是一个Ok ,它就用这个值继续当前的函数。否则,它就退出这个函数,将错误值传播给自己。对于一个单一的字符来说,这是很好的。明确的错误处理没有太多的噪音。

下一行就比较有趣了:

let child = std::process::Command::new(cmd).args(args).spawn()?.id();

我们用cmd 的值创建一个新的命令,将其参数设置为args ,然后生成进程。生成可能会失败,所以它返回一个Result 。我们能够把? 放在表达式的中间,然后继续链式调用其他方法。这真的很巧妙,而且与我们稍后将看到的.await 语法结合得非常好。

然而,这里有一个奇怪的地方:spawn() 没有使用Pid1Error 来表示出错的地方。相反,它使用std::io::Error 。那么std::io::Error 是如何变成Pid1Error 的呢?在Rust中有一个特殊的特质(就像Haskell中的类型类,或者Java中的接口),叫做From 。而现在我们可以看看我们对Pid1Error 的定义和对From 特质的实现:

#[derive(Debug)]
enum Pid1Error {
    IOError(std::io::Error),
    NoCommandGiven,
    ChildPidTooBig(u32, std::num::TryFromIntError),
}

impl std::convert::From<std::io::Error> for Pid1Error {
    fn from(e: std::io::Error) -> Self {
        Pid1Error::IOError(e)
    }
}

没有必要这么啰嗦;有一些提供帮助属性的辅助工具箱可以更容易地派生出这个特性的实现。但我还是更喜欢啰嗦,也不介意像这样的一些模板。

转换为pid_t

我们在上面得到的child 值的类型是u32 ,意思是 "无符号的32位整数"。这对一个子PID来说是一个合理的表示,因为它们不能是负数。然而,在libc 中,类型pid_t表示为一个有符号的整数type pid_t = i32 。这种区别的原因没有记录,但它是有意义的:libc 有一些函数在特殊情况下使用负值,比如向整个进程组发送信号。我们稍后会看到其中的一个。

总之,从u32i32 的转换可能会失败。像C语言,甚至Haskell语言都鼓励不检查的铸造。但在Rust中,默认的方式是比较明确的:

use std::convert::TryInto;
let child: libc::pid_t = match child.try_into() {
    Ok(x) => x,
    Err(e) => return Err(Pid1Error::ChildPidTooBig(child, e)),
};

TryInto 特质定义了一个我们要使用的方法try_into() 。在Rust中,你需要use 一个trait来获得其方法。幸运的是,编译器在这方面很聪明,提供了有用的错误信息。然后我们对Result 进行模式匹配,如果转换失败,则返回一个Pid1Error::ChildPidToBig 变体。

你可能想知道为什么我们使用这种模式匹配而不是? 。如果有正确的From 实现,? 就可以正常工作了。然而,如果你想在你的错误中包含额外的上下文,比如我们试图转换的值,你需要像上面那样多做一些工作。另外,你也可以玩玩map_err 方法。

灭罪

现在我们知道了子进程的ID,我们可以安装一个信号处理程序来捕获任何传入的SIGINT,并自己发送一个信号给子进程。让我们从实际发送SIGINT 的回调开始:

let interrupt_child = move || {
    unsafe {
        libc::kill(child, libc::SIGINT); // ignoring errors
    }
};

让我们从内部开始。libc::kill 是对C库的kill 函数的直接FFI调用,这是你发送信号的方式。我们传入child PID和我们想要发送的信号。这个函数可能会导致一个错误的结果,理想情况下我们会在Rust中正确地处理这个问题。但我们在这里只是忽略了这种错误。

往外走,我们看到的下一个东西是unsafe 。对libc 的FFI调用都被标记为unsafe 。你可以在Rust书中阅读更多关于不安全的内容

接下来,我们看到这个奇怪的|| { ... } 语法。这些管道是用来定义lambda/closure的。我们可以在管道里放一个逗号分隔的参数列表,但我们没有任何参数。因为我们要创建一个回调,以后会用到,所以某种lambda是必要的。

最后,move 。在我们的lambda里面,我们引用了child 这个变量,它被定义在闭包之外。这个变量在闭包的环境中被捕获。默认情况下,它是通过一个借用来捕获的。这让我们陷入了生命周期的问题,即闭包本身的生命周期必须小于或等于child 本身的生命周期。否则,我们最终会得到一个指向不再被维护的内存片段的闭包。

move 在Rust中,"L "改变了这一点,并使 的值被child 移到了闭包的环境中,从而使闭包成为该值的新主人。通常在Rust中,这意味着 不能再在原来的环境中使用,因为它已经被移动了。然而, 有一些特别之处:它是一个 值,它有一个 的实现。这意味着编译器会在需要时自动创建该值的副本(或克隆)。child child i32 Copy

好了!现在我们有了我们的回调,我们将使用真正有用的signal-hook crate来安装一个SIGINTs的处理器:

let sigid: signal_hook::SigId =
    unsafe { signal_hook::register(signal_hook::SIGINT, interrupt_child)? };

这个寄存器调用也是unsafe ,所以我们有一个unsafe 块。我们传入SIGINTinterrupt_child 回调。我们在结尾处加一个问号,以防失败;如果失败,我们的整个程序将退出,这似乎是合理的。我们捕捉到所产生的sigid ,这样我们就可以在以后取消注册这个处理程序。说实话,在这样的程序中,这并不是真正必要的,但为什么不呢。

我们的main 函数的其余部分看起来像这样:

// something about handling the reaping of zombies...

signal_hook::unregister(sigid);
Ok(())

这个函数取消了处理程序的注册,然后用Ok(()) 来表示一切都很顺利。现在我们只需要处理收割的事情了。

期货、流、信号、任务和唤醒者

我们需要做的最后一件事是在一个循环中收割孤儿,当我们催生的直接孩子自己退出时停止。使用libc 阻塞式waitpid 调用,这实际上可以作为一个正常的带有阻塞式系统调用的循环工作。因为我们的pid1 程序没有其他事情要做,所以阻塞调用不会占用其他有用的系统线程。

然而,这个练习的目的是使用新的async/.await 语法和Futures,并且只使用非阻塞调用。所以,这就是我们要做的事情!要做到这一点,我们就需要谈论任务了。任务类似于线程,但在纯Rust中使用合作多线程实现。用任务代替了操作系统对事物的调度:

  • Rust库里面有一个调度器,比如async-stdtokio
  • 任务用Future 特质来定义它们的工作(我们稍后会讨论这个问题)。
  • 与原始的Futures相比,async/.await 语法提供了更多的用户友好界面。
  • 任务能够表明它们正在等待其他东西的准备,在这种情况下:
    • 它们不会占用操作系统的线程阻塞
    • 当数据准备好时,调度器会唤醒任务

我们希望有能力 "阻塞",直到有新的子线程死亡。我们的应用程序将通过SIGCHLD 信号得到通知。然后,我们希望能够生成一个Stream ,表明一个子进程已经死亡。Stream 是对Future 的轻微扩展,它允许产生多个值,而不是只有一个值。为了表示这一点,我们有一个Zombies 结构:

struct Zombies {
    sigid: signal_hook::SigId,
    waker: Arc<Mutex<(usize, Option<Waker>)>>,
}

当我们注册回调动作时,这个结构会保留生成的SigId ,与我们在上面的SIGINT 一样。它也有一个waker 字段。这个waker 遵循常见的模式,即Arc (原子引用计数),围绕着一些数据的Mutex 。这样就可以通过显式锁定从多个线程读写数据,从而避免了竞赛条件。Rust非常善于利用类型系统本身来避免许多竞赛条件。例如,尝试用一个Rc (非原子引用计数)来替换Arc ,看看会发生什么。

在出Arc<Mutex<...>> ,我们存储了一对值:

  • Ausize ,这是仍然需要被收割的僵尸的数量。每次我们得到一个SIGCHLD ,我们都要将其递增。每次我们从Stream ,我们要减去它。
  • 一个Option<Waker> 。这就是我们与任务系统结合的方式。
    • 当我们在我们的任务里面,要求一个僵尸,我们会检查usize
      • 如果它大于0,我们就递减它并继续下去。
      • 如果它是0,那么我们要进入睡眠状态,直到一个新的SIGCHLD ,然后被唤醒。在这种情况下,我们将把Option<Waker> 设为当前任务的Waker
    • 当我们收到一个SIGCHLD ,我们将首先递增usize ,然后检查Option<Waker> 里面是否有一个值。如果存在,我们就会触发它。

好了,说了这么多代码。让我们来看看Zombies 的实现。

新的僵尸

在我们的impl Zombies { ... } ,我们定义了一个new 函数。这不是一个async 的函数。它将同步地做它的工作,并在一切都设置好后返回。首先,我们要创建我们的Arc<Mutex<...>> 位,并为回调函数制作一个克隆:

let waker = Arc::new(Mutex::new((0, None)));
let waker_clone = waker.clone();

接下来是回调函数,每次我们得到一个SIGCHLD ,就应该调用这个函数。记住我们的目标:增加计数器,如果存在,就调用waker

let handler = move || {
    let mut guard = waker_clone.lock().unwrap();
    let pair: &mut (usize, Option<Waker>) = &mut guard;
    pair.0 += 1;
    match pair.1.take() {
        None => (),
        Some(waker) => waker.wake(),
    }
};

我们使用一个move 闭包来捕获waker_clone 。与之前的usize child 值不同,我们的Arc<Mutex<...>> 不是一个Copy ,所以我们需要显式的进行克隆。接下来,我们锁定mutex。锁定可能会失败,我们用unwrap() 来处理。这将导致恐慌。一般来说,我们不建议这样做,但是如果在一个突变体上加锁失败,这意味着你的程序有一个基本的缺陷。一旦我们有了一个MutexGuard ,我们就可以用它来获得对计数和唤醒者的可变引用。

递增计数是很容易的。调用waker.wake() 也是如此。然而,我们首先要调用take() ,以获得Option 内的值并进行模式匹配。这也是用None 替换了waker ,这样,同一个Waker 就不会被第二次触发。

顺便说一下,如果你想写高尔夫代码,你可以通过调用map 来实现功能:

pair.1.take().map(|waker| waker.wake());

但我个人更喜欢明确的模式匹配。也许是我的Haskeller方式让我对在map 中执行动作感到不舒服,谁知道呢。

最后,我们可以通过注册处理程序来完成new 函数,并返回一个带有Arc<Mutex<...>> 和新信号ID的Zombies 值:

let sigid = unsafe { signal_hook::register(signal_hook::SIGCHLD, handler)? };
Ok(Zombies { waker, sigid })

丢弃Zombies

当我们完成了对Zombies 值的处理后,我们想恢复SIGCHLD 的原始信号处理器。对于我们的应用来说,这实际上并没有什么区别,但在一般情况下可能会更好。在任何情况下,实现Drop 是很容易的:

impl Drop for Zombies {
    fn drop(&mut self) {
        signal_hook::unregister(self.sigid);
    }
}

流媒体

为了与异步系统一起工作,我们需要一些类似于FutureStream 如前所述,我们不是产生一个单一的值,而是产生一个值的流来表示 "有一个新的僵尸要收割"。

每次有僵尸的时候都没有额外的信息,所以我们将使用一个() 单位值来表示僵尸。我们可以定义一个新的结构,或者做一些花哨的事情,我们捕捉信号被接收的时间。但这些都是不必要的。这里是我们的特质实现的开始:

impl Stream for Zombies {
    type Item = ();
    fn poll_next(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<()>> {
        unimplemented!()
    }
}

我们有一个相关的类型Item ,设置为() 。我们的poll_next ,接收一个钉子,对Zombies 值本身的可变引用,以及一个对请求值的任务的Context 的可变引用。我们将返回一个Poll<Option<()>> ,它可以是三个值中的一个:

  • Poll::Ready(Some()) 表示 "现在有一个僵尸在等你"。
  • Poll::Pending 表示 "现在没有僵尸在等你,但将来会有的。"
  • Poll::Ready(None) 意味着 "我知道不会再有僵尸了"。 在现实中,这种情况对我们来说永远不可能发生,因此我们永远不会产生这个值。

现在让我们来看看实现。我们要做的第一件事是锁定waker ,看看是否有一个等待中的僵尸:

let mut guard = self.waker.lock().unwrap();
let pair = &mut guard;
if pair.0 > 0 {
    // there's a waiting zombie
} else {
    // there isn't a waiting zombie
}

在等待僵尸的情况下(pair.0 > 0 ),我们要减去计数器,然后返回我们的Poll::Ready(Some(())) 。这很容易。

pair.0 -= 1;
Poll::Ready(Some(()))

当没有一个等待的僵尸时,我们要把Waker 设置为我们当前任务的Waker (通过Context 发现),然后返回一个Poll::Pending

pair.1 = Some(cx.waker().clone());
Poll::Pending

就这样,我们现在可以产生一个僵尸流了(听起来像是搬到好莱坞的好时机,对吗?)

收割

我们现在要消耗这些僵尸流,在这个过程中收割它们。我们想这样做,直到我们的直接子进程退出。一些关于收割的系统调用是如何工作的信息:

  • 有一个waitpid syscall,我们要使用它
  • 如果你告诉它要收割特殊进程-1 ,它将收割任何可用的进程。
  • 如果你给它的选项是WNOHANG ,它将是一个非阻塞的系统调用,如果没有可用的进程被收割,则返回0 ,如果有错误则返回-1
  • 它需要一个额外的可变指针来返回状态信息,我们并不关心这个。
  • 如果它真的收割了一个进程,它将返回该进程的ID。

让我们来创建我们的无限循环,永远等待僵尸的到来:

while let Some(()) = self.next().await {
    // time to reap
}

panic!("Zombies should never end!");

Stream 特质并不代表无限流的可能性,所以我们需要做两件事。

  1. while 中使用let Some(()) 进行模式匹配。
  2. 在循环之后添加一个panic! (或者只是一个Ok(()) )来处理编译器认为可能发生的情况:self.next().await 将返回一个None

让我们再来看看.await 。这就是Rust 1.39中新的async/.await 语法的真正魔力。.await 可以被附加到任何包含impl Future 的表达式中。在表面之下,编译器将其转换为充满回调的代码。根据以前的经验,手动编写这些代码充其量也就是繁琐的,尤其是。

  • 当你必须处理借贷检查器的时候
  • 当你有某种循环的时候

然而,正如你在这里看到的,代码的编写、阅读和解释都是微不足道的。这对Rust来说是一个巨大的可用性改进。在这里我还能看到另一个潜在的增量改进。

async for () in self {
  ...
}

但这是一个小的改进,而且需要对Stream 特质进行标准化。我对上面的代码比较满意。

该循环的每一步,我们都需要调用waitpid ,并检查其结果。我们有四种情况:

  • 是我们正在等待的那个孩子:退出该函数。
  • 它是一个不同的孩子:忽略。
  • 是一个0 ,表示没有一个等待的僵尸:这是一个程序错误,因为我们已经收到了一个SIGCHLD
  • 这是一个负值,表明系统调用失败。是时候出错了。

你可以用不同的方式来分割错误处理,并决定以不同于我的方式使用panic!ing,但这是我的实现:

let mut status = 0;
let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
if pid == till {
    return Ok(());
} else if pid == 0 {
    panic!("Impossible: I thought there was something to reap but there wasn't");
} else {
    return Err(Pid1Error::WaitpidFailed(pid));
}

返回到main

最后,为了把这一切联系起来,让我们看看我们的main 函数的完整结局是什么样子的,包括僵尸收割代码。

let sigid: signal_hook::SigId =
    unsafe { signal_hook::register(signal_hook::SIGINT, interrupt_child)? };

Zombies::new()?.reap_till(child).await?;

signal_hook::unregister(sigid);
Ok(())

就这样,我们有了一个非阻塞、中断驱动、用户友好的死神收割机。

总结

这是对一个相当简单的程序的详细演练。然而,希望你能从中得到启发,使之成为现实是多么简单。我相信async/.await 语法是Rust的一个真正的游戏改变者。虽然我过去一直坚信绿色线程运行系统可以用于并发应用,但这样一个强大的系统给予安全保证是非常有吸引力的。我期待着在愤怒中使用它,并与我以前的tokio 回调代码以及我将写的Haskell进行比较。