由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_EVENT、CTRL_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)也不是“必杀”,它更像是一个退出请求:如果你覆盖了默认处理并希望“优雅退出”,就必须让主逻辑具备可中断性,否则退出会不受控地拖延。需要强制结束时,才使用SIGKILL(kill -9),但这通常意味着放弃清理与一致性保障。
下次再输入 nohup <command> &,或写下一个长时间运行的遍历/计算循环时,不妨多问一句:当退出请求到来时,我的程序是否能在合理时间内结束,并把资源交还给系统?