https://www.youtube.com/watch?v=zhbkp_Fzqoo&list=PL85XCvVPmGQgR1aCC-b0xx7sidGfopjCj&index=6
Summary
这篇内容主要讲述了Unix信号处理的复杂性及其在编程中的应用。演讲者通过介绍信号的基本概念、处理方式以及遇到的各种挑战,强调了在编写命令行工具和服务时如何正确处理信号的重要性。
Highlights
- [🚦] 信号是Unix系统中用于进程间通信和控制的基本机制,例如SIGINT用于中断进程。
- [🔒] 信号处理函数必须使用异步信号安全函数,避免使用会造成死锁或内存分配问题的函数。
- [🔄] 大多数现代程序使用self-pipe技巧来简化信号处理,这在异步Rust中尤为突出。
- [📡] 异步Rust通过Tokio select实现了灵活的信号处理,适用于复杂的程序控制流。
- [🛠️] 处理子进程和进程组的信号是Unix编程中的高级技巧,需要细致的状态管理和错误处理。
Keywords
- Unix信号处理
- 异步编程
- Rust程序设计
我很乐意帮您整理并翻译这篇演讲的内容。以下是整理后的中文版本:
我将从提一个问题开始。请举手示意:有多少人曾经在运行命令行程序时按过Ctrl+C?好的,看来几乎所有人都有过这种经历。那么,有多少人因为在错误的时候按了Ctrl+C而导致数据损坏?看来也是大多数人。
我是Rain,我使用”他们/them”作为代词。今天我要讨论的是我在cargo next test项目中学到的关于信号的知识。这个演讲分为三个部分,第一部分是信号的介绍。
首先,你可能会问:为什么要关心信号?如果你正在运行一个服务,那么该服务的生命周期通常会涉及信号。这里有一张Kubernetes文档的截图,解释了当你的服务关闭时,你会收到一个叫做SIGTERM的信号。Docker的工作方式也是一样的,你可以用Docker stop来验证这一点。
如果你正在编写命令行工具,你也应该关心信号。事实上,大多数用户都非常没有耐心,如果你的程序感觉有点慢,他们就会按Ctrl+C向你的进程发送信号。一般来说,如果你只是在读取数据,可能不需要太关心信号。但是如果你在写入数据,尤其是如果你正在协调一个有外部依赖的操作,你真的需要考虑用户发送信号时会发生什么。
在深入讨论之前,我想设定一些讨论的边界。在这次演讲中,我们将聚焦于Unix而不是Windows。我还会专注于可移植的信号处理,也就是在每个Unix系统上都能工作的机制。有些Unix平台上有一些替代的信号处理方式,比如Linux上的signalfd,但这些超出了本次讨论的范围。
这里有一个小小的”犯罪现场”。这本来是一段视频,但我被告知会议的Wi-Fi可能不太稳定,所以这是我运行cargo build然后在中途按Ctrl+C的截图。这里发生了什么?首先,我在终端中按了Ctrl+C。然后,终端向cargo进程发送了一个名为SIGINT的信号,其中INT代表中断。这个信号导致cargo进程以及所有下面的rustc编译进程被中断和终止。
我们在这里看到了信号的两种模式。一种是作为内核中断进程的标准化和广泛理解的方式,另一种模式是作为执行进程间通信(IPC)的基本和有限的方式。
作为用户,你也可以加入发送IPC信号的行列。除了快捷键之外,主要的方式是使用名为kill的命令。如果你想向一个进程发送SIGINT或Ctrl+C,你可以使用kill -INT加上进程ID。每个信号也都有一个关联的数字,对于SIGINT,这个数字总是2。所以你也可以使用kill -2加上进程ID。如果你不指定信号,它默认会发送SIGTERM。SIGTERM是一种通用的终止进程信号。如果你在编程环境中,比如写一个Rust或C程序,那么libc有一个kill函数,你可以调用它来做同样的事情。
正如我刚才提到的,每个信号都有一个名字和一个关联的数字。如果你在命令行中输入”man 7 signal”,你会看到一长串的信号列表。我们不会一一讨论,但我们已经看到了SIGINT和SIGTERM。另一个是SIGKILL,有人在这里曾经对一个进程运行过kill -9吗?看来是所有人。SIGKILL是一个特别的终止进程信号。我相信几乎每个人都遇到过段错误(segfault),如果一个进程遇到段错误,你实际上会得到一个叫做SIGSEGV的信号。
最后几个我想提到的信号是SIGTSTP和SIGCONT。这些信号用于作业控制。如果你曾经在Vim中按过Ctrl+Z,或者使用过fg或bg命令,那么它就使用了这些信号。顺便说一下,如果你还没用过,你应该试试,因为这是Unix的一个很酷的部分,也是早期多任务处理的一个有趣例子。
接下来我想让你注意”默认动作”这一列。所有的信号都有一个默认动作。一些默认动作的例子包括终止进程,终止进程并产生核心转储(core dump是内存和其他信息的快照,可以用来后续调试进程),以及停止或继续进程。
几乎所有的信号,除了SIGKILL之外,都允许你自定义默认行为,这是通过所谓的信号处理器(signal handler)来实现的。信号处理器是一个用来拦截特定信号的自定义函数。通过信号处理器,你作为进程可以告诉内核调用你的函数,而不是遵循默认行为。从这个意义上说,信号处理器是一个反向的系统调用,有时候这种术语被称为”上行调用”(upcall)。
例如,如果你在写入或读取数据,你会使用系统调用来执行该操作,比如write或read。在这种情况下,是你在驱动进程并调用内核。而对于上行调用,是内核调用你的进程和其中的函数。重要的是,在信号的情况下,这几乎可以在任何时候发生。这就是我们开始遇到信号处理的真正问题的地方,这也是黑暗角落开始的地方。
为了了解这一点,让我们通过一个非常具体的例子来看。对于这个演讲,我们要讨论的例子是一个非常简单的下载管理器。举手if你认识这个?如果你在2000年代就接触过互联网,那么我相信很多人都用过下载管理器。我是在网络非常糟糕的环境下长大的,所以这些工具对我来说是救命稻草,因为它们可以恢复下载,使下载更快等等。
我们要写一个非常简单的下载管理器。它非常简单,假设这个下载工具获取了一堆URL,并在数据库中维护它们的状态,同时并行下载它们。你想要处理Ctrl+C,对吧?这是一个有外部依赖的操作,是信号处理的一个很好的候选。当用户按Ctrl+C时,我们想取消所有正在运行的下载,将内存中的任何数据刷新到磁盘,然后在数据库中将状态标记为中断。这看起来是一组非常直接和合理的事情,你可能想在信号处理器中做这些事情。
所以,你的第一个想法可能是把所有这些逻辑放在一个信号处理器中。你能这么做吗?答案是不能,而为什么不能这样做对于说明信号处理器的一些陷阱非常有帮助。
我之前提到,信号处理器可以在任何时候被调用,这就是问题所在。考虑一下,如果在你持有一个锁的时候调用了信号处理器会发生什么?结果是,尝试再次获取同一个锁会导致死锁。这意味着你不能在信号处理器中调用试图获取锁的函数。那么,哪些函数会获取锁呢?分配内存需要锁,因为它会查看全局结构。这意味着你不能在信号处理器中分配内存,这意味着你甚至不能在信号处理器中构造像字符串这样基本的东西。这就关闭了你在信号处理器中可以做的事情的很大一部分。
另一件可能发生的事情是,当一个信号处理器正在运行时,你可能收到一个不同的信号并调用第二个信号处理器。这使得系统变得非常难以推理。在维护的常见弱点枚举(CWE)数据库中,有四个单独的CWE与信号处理相关,这是非常疯狂的。这就是我们生活的世界,真是疯狂。
如果你在Linux上输入”man 7 signal-safety”(我确定这在一些其他Unix系统上也适用),你会得到一个被描述为可以在信号处理器中安全调用的函数列表,这个列表非常短。你可以做的一件事是写入文件描述符,但你不能打开或寻找文件,也不能分配内存。在信号处理器中可以安全调用的函数被称为异步信号安全(async-signal-safe)函数。
这个术语有点令人困惑,因为这里的”async”与Rust中的async完全无关。在很多方面,这实际上与async Rust相反,因为async Rust的定义特征是你不能在任何时候被中断或抢占,你只能在await点被抢占。而对于信号处理器,你可能在任何可能的时间被抢占。
那么,大多数现代程序是如何处理信号的呢?要理解这一点,我们需要简单介绍一下自管道(self-pipe)的概念。这在网上被描述为”一个很酷的Unix技巧”和”诅咒”。你们中的许多人可能熟悉在shell中运行命令的概念,对吧?如果你运行类似”find | xargs”这样的命令(这是很多人经常做的事),那么它会创建一个管道。管道只是一个有读端和写端的东西,在这个例子中,写端由find持有,读端由xargs持有。
自管道的想法是,你可以让同一个进程同时持有读端和写端。此时你可能会想,这有什么意义呢?大多数时候确实没有意义,这基本上是无用的。但在信号处理器的特定上下文中,它确实增加了价值。
大多数现代程序的工作方式是,它们保持信号处理器非常简单。它们首先创建一个自管道,然后将管道的写端交给信号处理器,自己保留读端。然后,信号处理器写入这个自管道,这是可以的,因为管道是一个文件描述符,你可以在程序的其他地方从中读取。
我认为大多数C程序都是手动实现这个的。但既然这是Rust,我们有一个很大的crate生态系统,这很可爱,所以你不必手动编写这个。有几个crate实现了这种模式,大多数人只是使用其中之一。
回到我们的例子,我们已经解决了异步信号安全的问题,我们已经进入了一个不需要担心这个的世界。你已经写入了一个管道,那么如何处理管道的读端呢?一旦你收到了一个信号,如何处理它?
一个选择是,我们可以在每次循环迭代时(或者每10次、每1000次,随你喜欢)检查是否收到了信号。这对于小型的、CPU密集型的程序来说工作得很好,可能只有几个循环。但根据我的经验,这对于大型程序来说并不真的可扩展,因为这些程序往往有很多循环,而且往往是I/O密集型而不是CPU密集型。
另一个一些程序使用的潜在解决方案是,基本上将所有状态存储在一个互斥锁中,然后通过锁定所有工作线程来获得程序的控制权。这是一些程序使用的解决方案,但根据我的经验,协调工作线程和信号处理器之间的状态非常困难,因为你必须将所有状态藏在互斥锁里面,然后就变成了一团糟。我个人真的不推荐采用这种方法。如果你喜欢大量的互斥锁,那就随你便吧。
我认为对于大多数程序来说,最合理的方法是使用消息传递,即处理信号的程序部分被通知已经发生了信号。这在同步模型中是可能的,但会增加很大的复杂性,因为最终你会创建一个线程的嵌套,每个线程只是在你的程序的各个部分之间传递这些消息。
好消息是,处理这种消息在async Rust中要简单得多。在这一点上,你可能会想,”Rain,我只是一个简单的下载器,为什么我需要async呢?”。async的营销一直是针对这些大规模并发的web或后端服务器。但秘密是,这主要是营销。它的范围要广泛得多,恰好非常适合大多数程序的信号处理。主要原因是async Rust提供了非常富有表现力和强大的控制流方式。
这里有一个在async Rust下处理Ctrl+C的简单例子。Tokio内置了信号处理,它使用了我之前提到的自管道技巧。如果你想看源代码,可以去查看一下。这里我们设置了一个中断或Ctrl+C信号的流,然后等待名为receive的方法。receive方法在进程每次收到Ctrl+C时解析。
这本身并不特别,你可以很容易地用同步代码实现这一点。但这个模型在更复杂的代码部分真正闪耀,这是因为async Rust的一个很棒的特性叫做tokio::select。
如果你不熟悉tokio::select,它是一个非常强大的控制流工具,可以并发地等待一组Futures准备就绪,并在其中一个完成时立即解析。tokio::select真正特别的地方在于,它不仅可以处理特定的异步源,还可以处理任意的异步源。我们将看到它与信号、计时器、异步函数等一起使用,tokio::select非常适合信号处理。
让我们回到我们的下载管理器示例。有几种方法可以组织这个,这里是其中一种方式。我们将引入两个构造来简化我们的生活:一个是叫做JoinSet的东西,它代表一组并行运行的工作任务集;另一个是广播通道,它允许单个生产者向多个消费者发送消息。这里的想法是主任务将接收信号,然后向工作者广播与这些信号相对应的消息。
我们首先像之前一样设置一个信号流,然后为每个工作者在JoinSet上生成一个任务,其中包含下载所需的任何参数以及这些广播消息的接收者。
至于等待值,如果我们没有任何信号处理,你会这样做:我们循环并等待每个工作任务完成,直到全部完成,然后在此过程中处理错误。为了处理信号,我们使用有两个分支的tokio::select。第一个分支等待工作任务完成,第二个分支等待来自流的Ctrl+C消息。如果它确实遇到Ctrl+C消息,它就会向工作者广播一条消息。
让我们开始编写我们的工作者函数。我们首先定义我们的函数,它接受帮助我们下载文件的参数,也接受信号端的接收者。我们在异步块内编写我们的操作,然后让我们把它折叠起来腾出一些空间。
现在,我们写一个循环,然后使用另一个tokio::select调用,它有两个可能的分支。第一个分支推进操作,第二个分支等待通过广播通道接收的消息。
这就是使用tokio::select进行基本信号处理的全部内容。这个模型最大的特点是,与同步代码不同,它可以很好地扩展到额外的复杂性。
我想指出两个具体的例子。首先,你可能想要处理其他信号,比如SIGTERM,因为SIGINT可能不是你唯一会收到的信号。另一个常见的扩展是使用所谓的”双Ctrl+C”模式。这个想法是,当用户第一次按Ctrl+C时,你进行常规清理,就像你预期的那样。但如果用户再次按Ctrl+C,你可以假设你的清理过程somehow卡住了,所以你立即退出。
这里有一个包含下载管理器工作演示的仓库链接,我为这次演讲准备了。这里有一堆练习,它们被标记为TODO/exercise。如果你有兴趣,请尝试完成它们,因为我认为这会很有启发性。
这里还有我的Mastodon、我的电子邮件和我的博客。演讲结束后,在Mastodon上或在会议的Discord上与我聊天,我会在晚上在那里回答问题。
非常感谢你们听我的演讲,也非常感谢所有在线收听的人。
现在,如果你的进程产生其他进程,Unix中的信号就会变得非常棘手。如果你是一个shell或任何其他产生进程的东西,这就很相关了。我们将在第三部分更深入地探讨一个特定的例子。
还记得我在开始时说过,当你在终端中按Ctrl+C时,SIGINT被发送到进程吗?这其实不太准确。它实际上是将信号发送到所谓的进程组,而不仅仅是一个进程。
什么是进程组?进程组只是一组Unix进程,但不仅仅是任何组,它是可以一次性(原子地)向其发送信号的组。这个想法是内核跟踪这个,你告诉内核向一个进程组发送信号,然后内核负责将该信号分发给该组内的所有进程。
当你在shell中运行一个命令时,它实际上总是为那个命令创建一个进程组。如果你在Linux上,你可以用这个命令打印进程组,你会得到一些看起来像这样的输出。在这种情况下,Z shell创建了一个编号为4100的cargo进程,当它这样做时,它还创建了一个具有相同编号的相应进程组。当cargo build运行rustc时,这些进程组被继承,即使每个进程的进程ID都不同。
如果你想向进程组发送信号,我们再次使用kill命令。如果你想向进程发送信号,你使用kill -INT加进程号。如果你想向进程组发送信号,这对Unix来说非常典型,你使用负数。所以你使用kill -INT加上一个负数,在这种情况下,SIGINT将被发送到整个进程组。这就是当你实际按Ctrl+C时真正发生的事情,它向整个进程组分发这个kill -INT。
假设你生成了一堆进程,你认为这些进程可能会生成它们自己的子进程。如果你也想加入这个派对呢?杀死不仅仅是你的子进程,还有它们的孙子进程,等等。这听起来很合理,对吧?听起来很神秘。你自己可以使用Command::new上的这个方法为你的子进程创建一个进程组。大多数时候,你会传入-1作为进程组,这告诉内核它将为你创建一个新的进程组,其编号与进程ID相同。这就是shell所做的。
现在你可能想知道,如果你有一个CLI进程,用户按Ctrl+C会发生什么。你可能会假设进程组形成某种树,就像Unix中有父进程和子进程的概念一样,你可能会假设有父进程组和子进程组。可惜,事实并非如此。进程组不形成树,每个进程组实际上都是自己的孤岛。
这意味着,作为这种管理自己进程组的边界进程,你有责任将信号转发给子进程组。好消息是,这很容易融入我们的async Rust模型,因为你可以搭载在你用于信号的相同广播通道上,也能够向那些工作者负责的进程组发送相应的信号。
值得考虑如果你设置进程组,你会承担的其他责任。大多数程序会希望尽可能地表现得像它们没有设置进程组的世界一样。为了达到这个目的,你至少要转发这些信号。我们没有太多讨论SIGQUIT,但如果你按Ctrl+,那就类似于SIGTERM或SIGINT,只是它还会做一个核心转储。还有SIGTSTP和SIGCONT,这两个实际上处理起来非常有趣。
比如说,你有一个进程,你正在生成一堆进程,在这个过程中,你想给你的进程设置超时。如果你处理Ctrl+Z,像SIGTSTP和SIGCONT,你实际上可以暂停那些计时器,然后在你的进程再次唤醒时恢复它们。这是一个非常有用的事情。你知道,这是大多数程序不做的事情,所以用户实际上并不期望这种行为发生,但你知道,这是你可以做并宣传的事情,它很酷。
也值得考虑一下你想发送的其他信号,你知道,就是有一个连贯的故事,比如说”好的,这些是我在收到信号时想做的事情”。你确实变得对更多事情负责,但你也有所有这些额外的能力来处理进程。
这就结束了我们对Unix信号世界的简短介绍,我们仅仅触及了信号复杂性的表面。我要留给你们的结束思考是,Doug McIlroy,Unix管道的创造者,在2015年的一个邮件列表帖子中写道,信号从来就不是为IPC设计的。我认为,人们在信号处理中遇到的很多问题,以及这些缺陷,如果你从”这从来就不是为了那样使用而设计的”的角度来看,实际上是很有道理的。然后我们有一个又一个的解决方案堆积在上面,你知道,我们今天仍在与这些原始设计决策共存。有一些尝试在一些Unix系统中解决这个问题,但这些又不是可移植的。
好消息是,有了async Rust,我认为很多这些都被抽象出来了,你所需要做的就是以异步的方式编写你的代码,你知道,很多事情对你来说变得更简单了。
好的,这里是我承诺的链接,指向一个包含下载管理器工作演示的仓库。这里有一堆练习,它们被标记为TODO/exercise。如果你有任何兴趣,请尝试完成它们,因为我认为这会非常有启发性。
这里也是我的Mastodon、我的电子邮件和我的博客。演讲结束后在Mastodon上或在会议的Discord上与我聊天,我会在晚上在那里回答问题。非常感谢你们听我的演讲,也非常感谢所有在线收听的人。
原文链接: https://dashen.tech/2018/07/13/RustConf-2023-Beyond-Ctrl-C-the-dark-corners-of-Unix-signal-handling/
版权声明: 转载请注明出处.