文章

nohup 到底做了什么:会话断开与进程存活机制

nohup 到底做了什么:会话断开与进程存活机制

引言:从一次 vLLM 掉线谈起

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

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

服务跑了一会儿,我关闭了 Windows Terminal。重新登录后发现模型服务也停了。排查下来原因很直接:即便加了 &,进程仍然属于当前会话(session)。会话断开、控制终端消失时往往触发 SIGHUP,Shell 退出时也可能向作业转发 SIGHUP,于是进程就被默认终止。

大家的第一反应通常是加 nohup。但这次把结论拆开理解:nohup 到底做了哪两件最小但关键的事?在更复杂的工程场景里,我们又该如何设计可控退出,而不只是追求进程不退出?

本文从这次掉线出发,理清 nohup 与 Linux 信号机制的关系,并结合我们在 LSP(Language Server Protocol)服务上的跨平台退出治理实践,给出一套偏工程视角的理解框架。

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

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

当终端关闭或会话结束时,进程可能收到 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
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 继承,让进程不随会话结束而退出。
  • SIGTERM 不是必杀,而是一个退出请求:若你覆盖了默认处理并希望优雅退出,就必须让主逻辑具备可中断性,否则退出会被无期限拖延。需要强制结束时才使用 SIGKILLkill -9),但那意味着放弃清理与一致性保障。
本文由作者按照 CC BY 4.0 进行授权