在本博文中,Meta 内核工程师 Chris Down 将探讨在 Linux 生产环境中使用 Linux 信号的隐患,以及开发者为什么应该尽量避免使用信号。
信号是 Linux 系统为响应某些情况而生成的事件。信号可以由内核发送给进程,由进程发送给另一个进程,或由进程向自身发送。在收到信号后,进程可能会采取措施。
信号是 Unix 类操作环境的核心部分,基本上从此类系统面世以来就一直存在。信号是操作系统中许多核心组件(核心转储、进程生命周期管理等)的通信方式。在我们使用信号的五十年左右的时间内,其整体表现一直很好。因此,当有人提出使用信号进行进程间通信 (IPC) 可能存在危险时,人们可能会认为这些言论是无稽之谈,而提出这些观点的人无非是急于“重造轮子”。但是,本文将展示一些因信号而造成生产问题的情况,并提供一些可行的缓解措施和替代方案。
信号具备标准化和高可用性,并且除了操作系统提供的依赖项,无需其他依赖项,因此可能看起来很有吸引力,但是您很难以安全的方式使用信号。信号会作出大量假设。应用程序必须仔细验证这些假设是否满足其要求,如果不满足,就必须谨慎采取正确配置。实际上,许多应用程序(甚至非常知名的应用程序)都不会采取这些操作,因此可能导致日后发生难以调试的事件。
我们来看看最近在 Meta 生产环境中发生过的事件,深入了解使用信号的隐患。我们将简单介绍一些信号的历史,以及这些信号如何帮助我们成就今天的局面,然后结合当前在生产中发现的需求和问题进行对比说明。
我们首先回顾一段过往的经历。LogDevice 团队清理了代码库,移除了不用的代码和功能。其中一个被移除的功能是一种日志,用于记录服务执行的某些操作。此功能冗余,不受用户青睐,因此被移除。如需了解更改信息,请前往 GitHub 查看。介绍到这里,一切都还算正常。
在更改后,系统短时间内没出现什么异常,生产继续稳步推进,正常提供流量服务。几周后,团队收到一份报告,显示服务节点正以惊人的速度丢失。这个问题是在新版本推出后出现的,但 哪里 出错却尚未可知。是哪些更改导致出错?
相关团队将问题范围缩小到上述代码更改,即停用该类型日志。但这是为什么呢?代码存在什么问题?如果您还不知道原因,请查看上述 diff 文件,尝试找出问题。此问题并不明显,而且大家都可能犯。
logrotate 基本上是使用 Linux 时用于日志轮替的标准工具。此工具已经有近三十年了,其概念很简单:通过轮替和删除日志管理日志生命周期。
logrotate 本身并不发送任何信号,因此在 logrotate 主页或其文档中找不到太多有关信号的内容。但是,logrotate 可在轮替前后获取要执行的任意命令。以下基本示例展示了 CentOS 中 logrotate 的默认配置:
/var/log/cron /var/log/maillog /var/log/messages /var/log/secure /var/log/spooler { sharedscripts postrotate /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true endscript }
此配置虽不够可靠,但我们可以接受,而且认为它可以按照预期方式运行。此配置表明,logrotate 轮替列出的任意文件后,应将 SIGHUP
发送至 /var/run/syslogd.pid
中包含的 pid(应为运行中的 syslogd
实例的 pid)。
这种操作适用于使用稳定公共 API 的标准(例如 syslog)。但一些内部标准的 SIGHUP
实现是可随时更改的内部实现详情,对于这些标准,此操作是否合适?
这里的一个问题是:除了 SIGKILL
和 SIGSTOP
等信号(无法在用户空间中捕获,因而只有一种意义),其他信号的语意意义是由应用程序开发者和用户解释和编写。在一些情况下,语意意义区别主要是理论区别,比如人们普遍会将 SIGTERM
理解为“尽快正常终止”。但相比之下,SIGHUP
的语意意义却不太明确。
SIGHUP
是专为串行线路设计的,最初用于表示线路的另一端掉线。如今,我们当然还是沿袭这种用法,在现代类似情况(伪终端或虚拟终端断开连接)下仍然发送 SIGHUP
,并因此使用 nohup
等工具忽略该信号。
在早期的 Unix 中,需要实现守护程序的重新加载,其中通常至少包括在不重新启动的情况下,重新打开配置或日志文件,而信号看上去是无需任何依赖项即可完成此任务的方法。当然,过去没有完成这类任务的信号,但由于这些守护程序没有控制终端,应该没有理由接收 SIGHUP
,该信号就像可以借用的便捷信号,不会产生任何明显的不良影响。
但是此计划有一个小问题。信号的默认状态不是“忽略”,而是根据具体信号而定。例如,您不必为程序手动配置 SIGTERM
以终止应用程序。只要您不为程序设置任何其他信号处理程序,内核就可免费终止其程序,不需要在用户空间中使用任何代码,很方便!
但不便之处在于 SIGHUP
还有默认行为:立即终止程序。此行为适用于最初的挂断情况,因为可能不再需要使用这些应用程序,但并不适用于此信号的新意义。
如果我们移除可能需要向程序发送 SIGHUP
的所有情况,这种行为当然没问题。问题是在任何大型成熟代码库中,很难实现此操作。对于严格控制的 IPC 调用,您可以使用 grep 轻松处理代码库,但是 SIGHUP
与此调用不同。信号可以随时随地生成,并且系统几乎不会检查信号运行(除了最基本的“您是否是用户本人或是否使用 CAP_KILL
”)。最重要的是,我们很难确定信号的发出位置,而通过使用更明确的 IPC,我们就会知道此信号对我们没有任何意义,应该将其忽略。
现在,我想大家可能已开始猜到发生了什么。在那个下午,执行上述代码更改的 LogDevice 版本开始推出,问题开始接踵而至。起初一切安然无恙,但次日午夜,系统莫名其妙地开始崩溃。这是因为计算机的 logrotate 配置中存在以下代码段,此代码段向 LogDevice 守护程序发送那时未处理的(因此是致命的)SIGHUP
:
/var/log/logdevice/audit.log { daily # [...] postrotate pkill -HUP logdeviced endscript }
在移除重大功能时,很容易漏掉一小段 logrotate 配置,这种情况很常见。不幸的是,我们也很难立即确认是否移除了存在的所有残余代码。即使是在更易于验证的情况下,在清理代码时误留下残余代码也是很常见的。通常,这也不会造成什么毁灭性后果,残余代码只是死代码或无操作代码。
从概念上讲,事件及解决办法都很简单:不发送 SIGHUP
,让 LogDevice 操作的时间更分散(即不在午夜正点运行此工具)。但是,我们应关注的不只是这个事件中的细微差别问题。更重要的是,我们必须以此事件为契机,劝阻用户,除最基本的情况外,在生产中处理问题时不要使用信号。
首先,将信号用作影响操作系统进程状态改变的机制 是 有根据的。这包括 SIGKILL
等信号以及 SIGABRT
、SIGTERM
、SIGINT
、SIGSEGV
、SIGQUIT
等信号的内核默认行为。对于 SIGKILL 这样的信号,您无法为其安装信号处理程序,这些信号也不可能按照您的预期运行;而对于 SIGABRT 等信号的内核默认行为,通常用户和程序员能透彻理解。
这些信号的共同点是:在您收到这些信号后,它们都会在内核中运行,以使终端进入终止状态。换言之,在您收到没有用户空间信号处理程序的 SIGKILL
或 SIGTERM
后,系统将不再执行用户空间指令。
终端终止状态很重要,因为这通常意味着您在逐渐降低当前执行的堆栈和代码的复杂度。随着并发和代码流变得更混乱,其他预期状态通常会导致复杂度更高,而且更难推理。
您可能注意到,我们没有提及同样默认有终止行为的一些其他信号。以下列出了默认有终止行为的所有标准信号(不包括 SIGABRT
或 SIGSEGV
等核心转储信号,因为这些信号都是合理的):
这些信号可能看似合理,但存在一些异常,具体情况如下:
随着程序的需求改变,在拥有默认终止行为的信号中,差不多三分之一的信号在理想情况下只是存在隐患;而在最糟糕的情况下,这些信号已经构成威胁。更严重的是,当用户忘记对据称“用户定义”的信号明确使用 SIG_IGN
时,这类信号也迟早会造成问题。即使是无害的 SIGUSR1
或 SIGPOLL
也可能造成事故。
这并不是熟悉不熟悉的问题。不管您多了解信号的运行方式,都很难在第一次时编写出信号正确的代码,因为信号虽然表面上看起来很简单,但实际上非常复杂。
程序员通常不会花一整天的时间去考虑信号的内部运行,因此在实际执行信号处理时,他们通常会犯些错。
我所说的错误并不是信号处理函数安全这类“小”错误,这类错误一般只需更改 sig_atomic_t
或使用 C++ 原子信号围栏即可解决。这些错误通常可通过计算机搜索轻松查找,而且用户在第一次遭遇可怕的信号经历后就难以忘记。难度更高的问题是在复杂程序收到信号时,推理一小部分程序的代码流。执行此任务时,需要不断且明确地考虑信号在各部分应用程序生命周期的相关问题,例如对于 EINTR
,使用 SA_RESTART
是否足够?如果该信号提前终止,我们应使用什么流程?现在有并发程序,那又涉及到什么问题?或者,在执行此任务时,为某些部分的应用程序生命周期设置 sigprocmask
或 pthread_setmask
,并祈祷代码流永远不会更改。但是在快速发展的环境中,此想法肯定不切实际。此时使用 signalfd
或在专用线程中运行 sigwaitinfo
会有所帮助,但这两个解决方法存在很多边缘情况,并且涉及可用性的问题,所以我不建议使用这些方法。
我们相信现在大部分有经验的程序员都知道正确编写线程安全的代码是很困难的,即使是蹩脚的版本也是如此。而如果您认为正确编写线程安全的代码很难,那么正确编写信号安全的代码明显更难。信号处理程序必须只依赖拥有原子数据结构的完全无锁代码,这样做有两个原因:(1) 执行的主流程已暂停,我们不知道其持有什么锁;(2) 执行的主流程可能执行非原子操作。这些程序还必须是完全可重入程序(即必须能够嵌套到自己的程序中),因为在发送多次信号(或 1 次 SA_NODEFER
信号)时,信号处理程序会重叠。这就是不能在信号处理程序中使用 printf
或 malloc
等函数的一个原因,因为这些函数依赖全局互斥锁进行同步。如果您收到信号时持有该锁,然后再次调用需要该锁的函数,则您的应用程序最终会进入死锁状态。要对这种情况进行推理非常困难。因此,许多程序员在处理信号时只编写以下代码:
static volatile sig_atomic_t received_sighup; static void sighup(int sig __attribute__((unused))) { received_sighup = 1; } static int configure_signal_handlers(void) { return sigaction( SIGHUP, &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART}, NULL); } int main(int argc, char *argv[]) { if (configure_signal_handlers()) { /* failed to set handlers */ } /* usual program flow */ if (received_sighup) { /* reload */ received_sighup = 0; } /* usual program flow */ }
这种做法存在的问题是:虽然 signalfd
或尝试异步处理信号的其他方法可能看起来相当简单可靠,却忽略了一点,那就是中断时间与收到信号后执行的操作一样重要。例如,假设用户空间代码正在执行 I/O 或更改内核中对象的元数据(例如 inode 或 FD),则中断时您实际可能位于内核空间堆栈中。例如,在尝试关闭文件描述符时,线程的显示内容可能如下:
# cat /proc/2965230/stack [<0>] schedule+0x43/0xd0 [<0>] io_schedule+0x12/0x40 [<0>] wait_on_page_bit+0x139/0x230 [<0>] filemap_write_and_wait+0x5a/0x90 [<0>] filp_close+0x32/0x70 [<0>] __x64_sys_close+0x1e/0x50 [<0>] do_syscall_64+0x4e/0x140 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9
其中,__x64_sys_close
是 close
系统调用的 x86_64 变体,用于关闭文件描述符。在执行此代码时,我们要等待备份存储更新(此 wait_on_page_bit
代码可用于该用途)。由于 I/O 操作通常比其他操作慢几个数量级,此处的 schedule
可自动提示内核的 CPU 调度程序,表明我们即将执行高延迟操作(如磁盘或网络 I/O),现在调度程序应考虑寻找其他要调度的进程来代替当前进程。此线程很好,支持用户向内核发送信号,表明最好选择一个实际使用 CPU 的进程,而不是将时间耗费在一个不等待响应就无法继续运行的进程上,而且等待响应可能需要一段时间。
假设我们向正在运行的进程发送一个信号,发出的信号会在接收线程中运行用户空间处理程序,因此我们将重新回到用户空间。终止此争用的其中一种方式是:内核尝试脱离 schedule
,进一步展开堆栈,最后向用户空间返回 ESYSRESTART
或 EINTR
的 errno,表明操作已中断。但关闭时任务的进展如何?现在文件描述符处于什么状态?
既然已返回用户空间,我们将运行信号处理程序。当信号处理程序退出时,我们会将错误依次传播到用户空间 libc 的 close
包装器和应用程序,这样理论上可以对遇到的情况采取相关措施。之所以说“理论上”是因为对于这些涉及信号的情况,我们很难清楚如何处理其中的大部分情况,而且生产中的许多服务无法妥善处理这些边缘情况。在某些不那么注重数据完整性的应用程序中,这种做法可能还好。但是,在 非常 注重数据一致性和完整性的生产应用程序中,这种做法存在一个重大问题:内核无法通过任何精细方式了解工作进展情况、完成情况,以及实际应如何处理相关情况。更糟的是,如果 close
返回 EINTR
,则文件描述符将为未指定状态:
“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”
如果遇到这种情况,那么祝您好运,设法推理如何在应用程序中安全处理。通常,对于正常的系统调用,处理 EINTR
很复杂。此过程涉及大量细微问题,这是只使用 SA_RESTART
无法解决问题的很大一部分原因。并非所有系统调用都可重新开始,而且如果您希望应用程序的每一个开发者都了解在各个调用站点获取每一个系统调用信号的深层细微差别,并且消除这些差别,那么就是在自找麻烦。例如 man 7 signal
:
“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”
同样,如果您使用 sigprocmask
并希望代码流保持静态,那么也是自找麻烦。因为开发者通常不会花时间考虑信号处理的限制,或者如何生成或维护信号正确的代码。在专用线程中使用 sigwaitinfo
处理信号也是如此,最终容易导致 GDB 和类似工具无法调试进程。如果代码流或错误处理操作出现细微错误,会导致出现漏洞、崩溃、难以调试损坏项、死锁及更多问题,使您不得不频繁使用事件管理工具。
如果您认为有关并发、重入和原子性的所有问题已够糟糕了,那么再加上多线程问题简直就是雪上加霜。鉴于许多复杂的应用程序隐式运行单独的线程(例如 jemalloc、GLib 或类似工具的一部分),这个问题显得尤为重要。其中一些库本身就安装了信号处理程序,这又会带来一连串的复杂问题。
总之,man 7 signal
对这个问题的说明如下:
“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”
简而言之,“对于大多数信号而言,内核会将信号发送至未使用 sigprocmask
屏蔽该信号的所有线程”。SIGSEGV、SIGILL 等类似陷阱,可让信号明确指向违规线程。不管您想出什么方法,即使是使用 tgkill
或 pthread_kill
,也无法将大多数信号明确发送到线程组中的单个线程。
这意味着,只要有一组线程,您就不能轻易更改所有信号处理特征。如果服务需要在主线程中使用 sigprocmask
定期屏蔽信号,您需要以某种方式与外部其他线程通信,说明这些线程应该如何处理信号。否则,其他线程可能会捕获信号,使其不再出现。当然,您可以在子线程中屏蔽信号,以免发生这种情况。但如果子线程需要处理自己的信号,则即使是使用 waitpid
等简易函数,也会让情况变得复杂。
与其他情况一样,这些问题并非无法通过技术解决。但是,人们会放松警惕,忽略一个问题:正常运行代码所需的同步是一项非常复杂繁重的作业,可能会导致出现漏洞、混乱,甚至是更糟糕的问题。
信号在内核中异步传播。在进程或线程的相关 task_struct
中记录待处理信号后,kill
系统调用才会返回。因此,即使信号不被屏蔽,也不能保证及时传递结果。
即使 及时 传递信号,也无法向信号发送方回报其操作请求的状态如何。因此,您不应该通过信号传递任何有意义的操作,因为信号发后即弃,没有报告传递成功与否以及后续操作状态的实际机制。正如我们在前文看到的一样,如果未在用户空间进行配置,即使貌似无害的信号也存在危险。
长期使用 Linux 的人肯定都遇到过这样的情况:本想终止某个进程,却发现此进程无响应,即使是对 SIGKILL
等所谓的致命信号也是如此。问题在于 kill(1) 看起来有点误导性,它的用途不是终止进程,而是将请求加入内核中的队列(未指明服务时间),用户会请求内核执行某些操作。
kill
系统调用的任务是:在内核的任务元数据中将信号标记为待处理,即使 SIGKILL 任务未终止,此操作也能成功执行。具体而言,在使用 SIGKILL
时,内核保证不再执行用户模式指令,但我们仍必须执行内核模式指令,从而完成操作(否则可能导致数据损坏)或者释放资源。因此,即使状态为 D(不间断睡眠),我们仍可成功执行此系统调用任务。kill 本身不会失败,除非出现以下情况:您提供无效信号;您无权发送相关信号;您请求将信号发送到的 pid 不存在,因此不可用于以可靠方式向应用程序传播非终端状态。
避免陷入信号处理泥潭的方法是:完全不处理信号。但是,对于必须对使用 SIGTERM
等特殊情况采取措施的应用程序处理状态进程,最好使用高级别 API(如 folly::AsyncSignalHandler
),这些 API 可以让大量问题更加直观。
sigprocmask
将信号范围限制到程序或线程子部分,减少定期检查信号是否正确时需要审查的代码数量。请记住,如果代码流或线程处理策略变更,使用 sigprocmask 可能达不到预期效果。signal(SIGHUP, SIG_IGN); signal(SIGQUIT, SIG_IGN); signal(SIGUSR1, SIG_IGN); signal(SIGUSR2, SIG_IGN);
信号行为极其复杂,即使使用编写得很好的程序也很难推理,而且使用信号会给应用程序带来不必要的风险,因此可使用其他替代方案。通常而言,请勿使用信号与程序的用户空间部分通信,可改为让程序以透明方式处理事件(例如,使用 inotify),或者使用用户空间通信(例如 Thrift、gRPC 或类似项),此方式可向发送方回报错误,并且在编译时可枚举并可论证。
希望看完本文,您已认识到信号虽然可能看似简单,但实际上绝非如此。信号的简约性使其成为用户空间软件 API,但这也掩饰了一系列不适合现代大多数生产用例的隐式设计决策。
首先我们要明确一点:信号存在有效用例。在没有用户空间组件时,在向内核传达所需进程状态(例如要终止进程)的基本通信中使用信号是非常适合的。但是,您很难在第一次时就编写出信号正确的代码,因为信号可能会留在用户空间。
信号具备标准化和高可用性,而且无依赖项,因此看起来可能很有吸引力,但实际上存在大量隐患,而且这些隐患只会随着项目的发展让人更加担心。希望本文能为您提供一些缓解措施和替代方案,帮助您以更安全直观但不那么复杂的方式实现目标。
如需了解有关 Meta Open Source 的更多信息,请访问我们的 Open Source 网站、订阅我们的 YouTube 频道,或在 Twitter、Facebook 和 LinkedIn 上关注我们。