(不)使用 Ctrl+C 中断后台任务



在从命令行交互使用 R 时,可以使用 Ctrl+C 键组合中断当前计算并输入新命令。这在 Unix 终端和 Rterm 中的 Windows 控制台上都适用。此类计算可以在 R 或 C 中实现,并且在等待结果时可能会执行外部命令,例如通过 system(,wait=TRUE)

但是,在 R 4.3 及更早版本中,Ctrl+C 还会中断后台任务,例如通过 system(,wait=FALSE)pipe() 执行的任务。这在 PR#17764 中针对 Unix 进行了报告,但事实证明在 Windows 上也会发生。此类后台任务不会阻止用户向 REPL(读取-求值-打印循环)输入新的 R 命令。通常它们不会产生任何输出,用户甚至可能没有意识到它们。此类任务不会在具有 REPL 的其他系统中中断,包括 Unix shell。此问题已在 R 的开发版本 R-devel 中得到修复。

问题

为了便于阅读,此文本抽象了一些细节。

当用户按 Ctrl+C 时,一些进程会收到来自操作系统的信号。该信号可能会被忽略,然后什么都不会发生。它也可能有一个默认或非默认处理程序。默认处理程序终止进程。非默认处理程序可以由应用程序提供。

R 在与 REPL 交互运行时,不希望响应 Ctrl+C 而终止。因此,它有自己的处理程序。该处理程序只是记录中断正在进行,并让主计算继续。一旦可以安全地响应用户中断(但不要太频繁以至于无法完成其他操作),R 会检查是否有任何中断正在进行,如果有,则会对其进行响应。在实践中,这意味着 R 需要在长时间运行的循环中不时检查是否有中断正在进行。它必须专门处理对操作系统的阻塞调用,以便不会花费太长时间来中断计算。

但是,R 本身也可以运行来执行 R 脚本。它可以来自 R 的另一个实例,通过 system(,wait=TRUE) 或来自其他应用程序。在这种情况下,R 应该被 Ctrl+C 终止。但是,当通过 system(,wait=FALSE) 或类似方式从另一个应用程序运行时,它不应该被 Ctrl+C 终止(错误报告)。因此,需要有一种独立于应用程序的方法来传达 Ctrl+C 应该对子进程做什么,并且这应该进一步继承到其子进程。

操作系统提供了这种通信方式。父进程可能能够安排其给定的子进程(及其子进程)不接收 Ctrl+C 的信号。此外,父进程可能能够安排其给定的子进程(及其子进程)忽略中断信号。

要实现此目的,应用程序需要协作。默认情况下,它们会协作。当应用程序未设置任何信号处理程序,并且仅保留忽略信号的标志时,它会继承预期行为:要么通过默认处理程序操作响应中断而终止,要么在信号未到达或被忽略时继续执行。

设置自己的中断处理程序的应用程序(例如 R)必须更加小心。当继承的标志表示应忽略信号时,它们需要避免安装/启用其处理程序。并且它们需要确保为其子进程正确设置标志。

在 Unix 上

信号被忽略的信息由名为 SIG_IGN 的特殊处理程序编码。真正的信号处理程序本身无法被继承,因为它存在于父进程的地址空间中,但当它被设置为 SIG_IGN 时,它会被继承。

可以使用 sigaction() 找出信号是否被忽略,并且当它被忽略时,不应设置任何自定义处理程序。较旧的 signal() 调用在设置新处理程序时返回先前的信号处理程序,因此如果它是 SIG_IGN,则应立即恢复旧处理程序。有关 Unix 上信号处理的良好资料是 GNU libc 文档,其中也提到了此原则。R 已修复为执行此操作。

SIGINT 信号由终端发送,以响应它控制的前台进程组中的 Ctrl+C。组中的所有进程都会收到该信号。每个进程都恰好属于一个进程组,并且默认情况下,其子进程属于同一组。

R 的 system(,wait=FALSE)(如文档所述)使用 Unix (POSIX) shell /bin/sh 运行后台进程,并使用附加到它的 & 运行给定的命令。通常,以这种方式调用的 shell 会禁用作业控制(也称为禁用监视模式),这意味着它的子进程(命令)将在同一进程组中执行,但会忽略 SIGINT 信号,因此虽然它会收到 Ctrl+C,但不会被它中断。

如果所有应用程序都遵循尊重被忽略的 SIGINT 的规则,那么此机制足以确保 Ctrl+C 不会中断 R 中的后台任务,但它们不会这样做,包括旧版本的 R,因此需要进一步增强鲁棒性。

当启用 Unix shell 作业控制时,shell 会在新进程组中执行后台任务。因此,当按下 Ctrl+C 时,它们不会收到信号,因此即使不遵守规则,它们也不会被中断。R 的 system(,wait=FALSE) 的实现不能在启用作业控制的情况下使用 /bin/sh,但它可以安排子进程(/bin/sh)在新进程组中运行。

R 已经具备了以新进程组运行命令的关键部分,因为它已经通过重新实现 C/POSIX system() 来使用 system(,timeout>0) 执行此操作。此代码已略作概括,现在甚至 system(,wait=FALSE) 也在新进程组中运行子进程。

虽然从原则上来说,pipe() 的问题是一样的,但使其更加健壮需要更多的工作。pipe() 的实现使用了 C/POSIX 调用 popen()pclose(),但这些调用不允许请求新的进程组。因此,popen()pclose() 必须在 R 代码库中重新实现。

在 Windows 上

忽略 Ctrl+C(和其他一些事件)信号的信息由子进程继承,但没有记录的方法可以从操作系统中获取它。它存储在 PEB(进程环境块)中,在进程参数下,在 ConsoleFlags 中,但虽然一些终端实现使用它,但它不在公共 API 中。

但是,与 Unix 不同,Windows 将此信息与实际信号处理程序分开保存。因此,可以无条件地设置一个处理程序(通过 SetConsoleCtrlHandler)。仅当信号未被忽略时才使用该处理程序。而且处理程序本身实际上不会被继承,因为它是在进程地址空间中的一个地址。

但是,有时,像 R 这样的应用程序可能需要确保它不会被 Ctrl+C 中断。R.exe 是一个单独的应用程序,它执行 Rterm.exe。当 R 旨在与 REPL 交互使用时,它不应该被 Ctrl+C 终止。换句话说,Rterm.exe 应该负责中断当前计算,而 R.exe 应该在 Ctrl+C 上不执行任何操作(但绝对不能终止)。这过去是通过在 R.exe 中忽略信号,然后在 Rterm.exe 中取消忽略(通过 SetConsoleCtrlHandler(NULL,))来实现的。这破坏了继承但无法检索的标志,该标志指示信号是否被忽略。

R 已得到修复,以便“忽略”Ctrl+CR.exe 现在为该信号安装了自己的处理程序。处理程序仅返回 TRUE,这与忽略信号具有相同的效果,但它不会破坏继承的标志。对 SetConsoleCtrlHandler(NULL,) 的危险调用已被删除。我相信这是 Windows 上应该遵循的模式,但我在 Microsoft 文档或其他地方没有找到任何对此效果的建议。

此外,当 R 执行一个不应该通过 Ctrl+C 中断的后台进程时(例如 system(,wait=FALSE)),它需要确保子进程忽略该信号。这可以通过进程创建标志 CREATE_NEW_PROCESS_GROUP 来完成,而 R 现在会这样做。

但是,进程组与 Unix 上的进程组不同。它不会“分组”进程,它只会确保子进程在忽略信号的情况下执行。此属性由子进程继承,但任何子进程都可以使用 SetConsoleCtrlHandler(NULL,) 更改它,因此它将再次被 Ctrl+C 中断。Windows 允许实际“分组”进程,放入“作业”,但这在这种情况下没有帮助。

Windows 上的 R 已经使用了它自己的实现,作为 C system() 的替代方案,用于执行外部进程。这已扩展为对后台进程(例如 system(,wait=FALSE))使用 CREATE_NEW_PROCESS_GROUP

再次需要更多工作来修复 pipe()。R 将 Rterm 作为控制台,使用了 C popen()pclose() 调用,它们不允许忽略子进程中的中断。具有 GUI(例如 Rgui)的 R 已经使用了它自己的 pipe() 实现,该实现现在也用于控制台,但必须进行概括。

摘要

可以在 R Bugzilla 和源代码中找到有关这些更改的其他技术详细信息。应报告与后台进程执行相关的任何回归,以便在下一个 R 版本之前修复它们。

Windows 上的一个已知限制是,直接调用 SetConsoleCtrlHandler(NULL,) 的应用程序仍可能被 Ctrl+C 终止,即使不希望这样做。这包括链接到 Cygwin/Msys2 运行时(因此也包括 Rtools)的应用程序。

Unix 上的用户可能会观察到作业控制的行为差异,或者当后台进程从终端读取或写入终端时,但这些应该远没有 Ctrl+C 终止后台进程那么烦人。