文章

由nohup命令引起的思考

由nohup命令引起的思考

引言:从一次 vLLM 掉线谈起

最近在公司的 8 卡 L40S 服务器上部署大模型推理服务(以 Qwen2.5-72B-Instruct 为例)时,我采用了常见的启动方式:

vllm serve Qwen/Qwen2.5-72B-Instruct ... &

服务运行一段时间后,SSH 连接因网络波动中断。重新连接后发现,模型服务也随会话结束而停止。进一步排查后确认:即使使用 & 将进程放入后台,它仍属于当前登录会话(session)。当会话断开、控制终端消失时,通常会触发 SIGHUP(hangup);此外,Shell 退出时也可能向其管理的作业转发 SIGHUP,从而导致进程退出。

常见做法是为命令加上 nohup。但这次问题促使我把结论拆开来理解:nohup 具体做了哪些最小但关键的处理,才让进程在终端关闭后仍能继续运行?在更复杂的工程场景里,我们又该如何设计“可控退出”的生命周期,而不是只追求“进程不退出”?

本文从一次 vLLM 掉线的现象出发,梳理 nohup 与 Linux 信号机制的关系,并结合我们在开发 LSP(Language Server Protocol)服务时遇到的跨平台退出治理问题,给出一套偏工程实践的理解框架。

1. nohup 的本质:不是“守护”,而是“隔离”

很多人(包括我以前)会把 nohup 理解为“把进程变成守护进程(daemon)”。更准确的说法是:nohup 并不会监控或托管进程;它只是在启动目标程序之前,做了两件与“终端断开”强相关的隔离工作。

当终端关闭、SSH 断开或会话结束时,进程可能收到 SIGHUP。默认行为通常是终止进程。nohup 的核心作用主要包括:

  • 忽略 SIGHUP:将 SIGHUP 的处理方式设置为忽略,让程序不因会话断开而退出。
  • 处理标准输出/错误的去向:如果 stdout 仍连接在终端上,nohup 会将输出重定向到 nohup.out(或用户指定的重定向目标),并让 stderr 跟随 stdout,避免进程继续向已关闭的终端写入而出现异常行为。

这也解释了为什么 nohup 常与 & 一起出现:nohup 解决的是“会话断开后的信号与 I/O 继承问题”,而 & 解决的是“让命令不占用当前前台交互”。两者关注点不同,但经常同时使用。

2. 动手验证:用 Python 实现一个“简化版 nohup”

比起只记结论,一个可运行的最小复现更能帮助建立直觉。下面用 Python 写一个极简版本,逻辑与 nohup 的关键路径一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import os
import sys
import signal

def main():
    if len(sys.argv) < 2:
        print("Usage: python mynohup.py <command> [args...]")
        sys.exit(1)

    # 1) 核心步骤:忽略 SIGHUP
    # 关键点:该信号处置会在 exec 后被新程序继承
    signal.signal(signal.SIGHUP, signal.SIG_IGN)

    # 2) 若 stdout 仍在终端上,则重定向输出到 nohup.out,并让 stderr 跟随 stdout
    if sys.stdout.isatty():
        fd = os.open("nohup.out", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
        os.dup2(fd, 1)  # stdout -> nohup.out
        os.dup2(fd, 2)  # stderr -> nohup.out
        os.close(fd)

    # 3) 进程替换:exec 后 PID 不变,但代码换成目标命令
    os.execvp(sys.argv[1], sys.argv[1:])

if __name__ == "__main__":
    main()

这段代码说明:nohup 并不“守护”进程,它只是在 execvp 之前完成必要的信号与 I/O 设置,随后用 execvp 原地替换为目标程序;从这一刻起,继续运行的是目标程序本身。

3. 进阶实践:让服务“可控退出”,而不是“被动存活”

nohup 解决的是“会话断开导致进程退出”的问题。但在实际工程里,另一类更常见的挑战是:如何让进程在需要退出时尽快且可预期地退出(释放端口、写回缓存、结束子任务),而不是拖延很久,甚至造成资源堆积。

3.1 场景:code-server 的快速重载带来的竞态

我们有一个 C/C++ 的 LSP 服务,作为 VS Code 插件后端运行。在本地 VS Code 场景下,关闭窗口通常会走完 deactivate 流程,退出相对平稳。

但在 code-server(浏览器版 VS Code)中,用户“刷新页面 / 重载窗口”的行为更频繁,前后端会出现典型竞态:

  • 前端:旧 WebSocket 连接瞬间断开,新窗口几乎同时初始化并请求启动新的 LSP 进程。
  • 后端:旧 LSP 进程虽然已收到退出请求,但可能仍在执行重任务;新进程已经启动,导致短时间内并存多个高负载进程。

如果用户连续多次刷新,服务器上就可能堆积多个占用 100% CPU 的进程,互相争抢资源,最终拖慢整台机器。

3.2 排查:SIGTERM 已送达,为何进程仍持续占用 CPU?

排查时我们确认:旧进程确实收到了 SIGTERM。之所以没有立刻退出,是因为我们为了在退出前做清理,注册了自己的信号处理逻辑(覆盖了默认的“立即终止”行为),并在信号回调里仅设置了“退出请求”的标志。

问题出在主逻辑:enumerateFiles 会遍历超大规模工程并做重计算。如果主循环不检查退出标志,那么即使退出请求已经产生,进程仍会继续执行较长时间。

下面是问题代码的示意:

1
2
3
4
5
6
// ❌ 问题示意:循环内没有任何退出检查
void enumerateFiles(const std::string& rootPath) {
  for (const auto& file : recursive_directory_iterator(rootPath)) {
    heavy_parsing(file);
  }
}

3.3 解决:引入退出标志与周期性检查

解决思路并不复杂:在收到退出请求后,尽快让重计算循环“可中断”。一种常见做法是引入全局退出标志,并在循环中周期性检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <atomic>
#include <csignal>

static std::atomic<bool> g_exit_requested{false};

extern "C" void signal_handler(int /*signum*/) {
  g_exit_requested.store(true, std::memory_order_relaxed);
}

void enumerateFiles(const std::string& rootPath) {
  for (const auto& file : recursive_directory_iterator(rootPath)) {
    if (g_exit_requested.load(std::memory_order_relaxed)) {
      Log("Exit requested, aborting enumeration...");
      break;
    }
    heavy_parsing(file);
  }
}

引入这一“探头”后,即使用户频繁重载窗口,旧进程也能更快响应退出请求,及时释放 CPU、文件句柄与端口资源,为新进程让出运行空间。

备注:上面代码用于说明思路。生产环境中还需要结合线程模型、异步任务取消、子进程回收等细节;但无论如何,“主循环可中断”是退出治理的基础。

4. 跨平台补齐:Windows 下的退出信号从哪里来

既然 LSP 服务是跨平台的,就必须面对现实差异:Windows 并没有完整的 POSIX 信号语义。对于控制台程序,更常见的退出事件来源是“控制台控制事件”(如 CTRL_C_EVENTCTRL_CLOSE_EVENT)。

为了在 Windows 上获得与 Linux 类似的退出触发点,可以注册 SetConsoleCtrlHandler,并复用同一套“退出标志”的上层逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifdef _WIN32
#include <windows.h>

BOOL WINAPI WindowsCtrlHandler(DWORD ctrlType) {
  switch (ctrlType) {
    case CTRL_C_EVENT:
    case CTRL_CLOSE_EVENT:
      g_exit_requested.store(true, std::memory_order_relaxed);
      return TRUE;
    default:
      return FALSE;
  }
}

void setup_exit_handler() {
  SetConsoleCtrlHandler(WindowsCtrlHandler, TRUE);
}
#endif

底层机制虽然不同,但上层目标一致:将“退出请求”统一抽象为一个可被主逻辑轮询的状态,从而保持跨平台行为一致、可预测。

结语:从“让进程活下来”到“让进程按预期退出”

从一次 vLLM 掉线,到 code-server 场景下的 LSP 进程退出治理,可以归纳为两点:

  • nohup 的价值不在于“守护”,而在于忽略 SIGHUP 并处理 I/O 继承,让进程不随会话结束而退出。
  • kill(更准确地说是 SIGTERM)也不是“必杀”,它更像是一个退出请求:如果你覆盖了默认处理并希望“优雅退出”,就必须让主逻辑具备可中断性,否则退出会不受控地拖延。需要强制结束时,才使用 SIGKILLkill -9),但这通常意味着放弃清理与一致性保障。

下次再输入 nohup <command> &,或写下一个长时间运行的遍历/计算循环时,不妨多问一句:当退出请求到来时,我的程序是否能在合理时间内结束,并把资源交还给系统?

本文由作者按照 CC BY 4.0 进行授权