
做了这么多年Linux我发现自己跟人聊得最多的问题反而是那些看起来“基础得不行”的东西。比如一个训练脚本在服务器上跑得好好的你把笔记本盖子一合或者SSH会话断了那么几秒再连上去脚本没了。更诡异的是有时候明明是在同一个终端里起的服务按CtrlC就是杀不干净进程列表里留了一堆“孤儿”还在抢资源。遇到这种问题如果你脑子里只有PID这一个概念很容易被折腾到怀疑人生。今天这篇就把Linux进程管理的“中间层”好好捋一遍进程组process group和会话session。它俩解决什么问题一句话就能说清——让信号和终端的控制能力从“单个进程”变成“一组进程”并且让不同终端之间的进程互不干扰。后面你再看nohup、setsid、守护进程、tmux这些工具很多“为什么这么做”的问题都会迎刃而解。1. 为什么我们需要进程组和会话1.1 靠PID管进程的尴尬在哪刚入门的朋友看到ps -ef里的PID觉得每个进程都有唯一编号操作起来很直接。想结束一个进程kill 12345就完事了。但真实场景远没有这么干净。你执行一条命令比如tar -czf backup.tar.gz /data你以为只有一个进程在跑其实tar通常会派生出gzip等子进程。更不用说跑个Web服务主进程会fork出一堆worker进程。此时你按CtrlC如果操作系统只给“命令进程”发信号而命令进程自己没处理干净那些子进程就会变成没人管的“孤儿”继续占着IO和CPU。反过来如果某个进程卡住了你想终止整个任务只杀最外层那个命令往往杀掉之后底下的子进程还在继续写文件最后留下一堆残缺数据。这些都是“单点管理”的局限。操作系统需要一种机制把“一条命令或者一个任务派生出的一堆进程”归拢成一个整体统一发信号、统一控制。进程组就是这个整体。1.2 进程、进程组、会话的三层结构Linux把进程管理分成了三个层级从下往上分别是进程process、进程组process group、会话session。进程就是正在运行的程序实例进程组是一组进程的集合会话是一组进程组的集合。为什么要套这么多层因为只有进程组还不够。你在一台服务器上可能开了好几个终端窗口每个终端里都跑着若干进程组。如果这些进程组之间没有隔离边界那你在终端A按CtrlC总不能把终端B里正在跑的进程也杀掉吧每个终端必须拥有自己的“势力范围”这个范围在Linux里就是会话。我用公司结构来打个比方进程相当于普通员工进程组是项目组会话是事业部。事业部老大就是会话首进程手里握着唯一的“终端”作为对外接口。键盘上的CtrlC相当于大老板给事业部打电话只说一遍指令事业部内部再往下分发。如果每次通知都要挨个找到每个员工效率太低了。这套分层模型的核心价值就是让操作系统能以“广播”的方式管理一批进程而不是一个一个点名。2. 进程组让进程抱团协作2.1 认识进程组和PGID在Linux里每个进程不仅有一个PID还一定属于某个进程组。每个进程组有一个唯一的进程组ID也就是PGIDProcess Group ID。这个PGID不是随便定的它等于创建这个进程组的组长进程的PID。怎么查看进程组比如你开两个终端分别跑一些命令然后执行ps -o pid,ppid,pgid,sid,tpgid,stat,tty,cmd你会看到PID和PGID两列。很多情况下你在终端里执行一条简单命令比如sleep 300它的PGID可能等于它自己的PID也可能等于Shell的PID。这取决于Shell是否开启了作业控制job control。交互式Shell通常会为每个“作业”单独分配一个新的进程组这样就能单独对某个作业发送信号而不影响Shell本身。管道命令是个好例子。你执行cat file | grep keyword | sort这三个进程一般会被Shell放到同一个进程组里。为什么因为这三个进程共同组成一个“流水线任务”要么一起跑要么一起停。用户在键盘上按CtrlC时信号发给整个进程组流水线上的所有进程都会收到不会出现“grep已退出cat还在后面傻乎乎地读文件”的尴尬局面。2.2 进程组的生命周期进程组的生命周期并不依赖组长进程。组长进程退出之后进程组依然可以存在只要组里还有成员在运行。这一点很多人会搞混。举个生活中的例子你启动了一个后端服务组长进程这个服务又fork了几个worker进程然后组长进程自己崩了。此时worker进程还在运行它们仍然属于原来的进程组PGID不变。你甚至还可以用kill -TERM -PGID把这个进程组里剩下的进程全部终止掉。组长进程只是“名义上的代表”不是“必须存在的管家”。进程组的消失条件只有一个组里最后一个进程退出或者离开该进程组。当最后一个成员退出时进程组自动消失它的PGID也可以被系统重新分配使用。进程组是怎么创建的通过setpgid()系统调用。一般情况下我们不直接写代码操作它Shell已经帮我们处理好了。你启动一个后台任务、一个管道命令时Shell都会在fork子进程之后马上调用setpgid把相关进程放进一个新的进程组。这样做的一个重要原因是尽早把子进程放到新的进程组里避免它们和终端的前台进程组绑定太深导致误收信号。2.3 前台进程组与后台进程组在一个会话里同一时刻只能有一个进程组是“前台进程组”foreground process group。这个进程组能直接读取终端的输入键盘产生的信号CtrlC、CtrlZ、Ctrl\也只发给它。其他进程组都是“后台进程组”。后台进程组里的进程如果试图从终端读取输入内核会发送一个SIGTTIN信号把它停住。这就是为什么你运行some_program 之后如果这个程序还要从终端读输入它往往会在后台“卡住”。看起来像死锁其实是被终端驱动冻结了。反过来如果终端开启了TOSTOP选项后台进程往终端写内容也可能收到SIGTTOU信号而被停住。这个机制的意义在于终端作为会话里的“公共资源”同一时刻只能有一个使用方。如果允许多个进程组同时读终端输入用户按下的每个键到底给谁根本没法分。所以内核强制规定了“前台独占输入”的规则Shell通过tcsetpgrp()系统调用来切换哪个进程组处于前台。3. 会话给进程一个“隔离空间”3.1 会话的本质会话session是比进程组更高一层的容器。它包含多个进程组。怎么判断一个进程属于哪个会话看SIDSession ID。SID等于会话首进程session leader的PID。你打开一个SSH终端登录Shell就是会话首进程它的PID就是整个会话的SID。会话的作用简单来说就是隔离。每个终端窗口对应一个独立的会话会话之间的进程互不干扰。你在终端A里按CtrlC信号只发送给终端A所在会话的前台进程组终端B那边完全不受影响。这样一来多个用户可以同时登录同一台服务器各自操作也可以一个用户开多个终端处理不同任务谁也不会误伤谁。可以试着执行ps -o pid,ppid,sid,pgid,tpgid,tty,stat,cmd你会看到登录Shell的PID和SID是同一个数字而它下面启动的所有任务不管是前台还是后台的SID都继承自它。整个SSH会话就是一个巨大的“家族树”树的根就是Shell。3.2 控制终端与TTY一个会话可以拥有一个控制终端controlling terminal。这个控制终端通常是/dev/tty类型的设备文件比如/dev/pts/0SSH终端模拟器对应的伪终端。控制终端和会话的关系有几个关键点一个会话最多有一个控制终端。只有会话首进程可以打开控制终端或者通过ioctl把自己的某个终端设置为控制终端。控制终端一旦关闭比如SSH断开、终端窗口被关闭内核会向会话中的相关进程发送挂断信号SIGHUP。进程可以通过打开/dev/tty来访问自己的控制终端哪怕它的标准输入、输出、错误都被重定向到了文件。你可以这么理解控制终端就是会话的“遥控器”。人通过键盘操作这个遥控器把信号和输入传给会话里的前台进程组。遥控器关掉了整个会话的“电源”也就断了内核会通知大家“收拾东西走人”。3.3 一次登录如何建起会话从SSH登录到拿到Shell提示符背后正好创建了一个新会话。大致流程是这样的SSH服务器sshd为每个连接分配一个伪终端PTY然后fork出一个子进程。这个子进程会调用setsid()创建新会话把自己变成会话首进程同时它也没了控制终端。接着它通过ioctl把前面分配的伪终端设置成自己的控制终端。最后这个子进程执行登录Shell比如bashShell就自然而然地成了会话首进程并且持有控制终端。也就是说我们平时用的交互式Shell几乎都是会话首进程。这也是为什么关掉终端控制终端断开时Shell会收到SIGHUP然后Shell再把SIGHUP转发给仍然处于运行状态的作业之前跑着的命令就跟着一起退出了。4. 信号、终端与进程组会话的联动机制4.1 键盘信号如何“精准打击”你按下的每个特殊按键都是由终端驱动TTY driver解释成信号的。终端驱动会维护一个“当前前台进程组ID”t_pgrp。每次Shell切换前后台时都会通过tcsetpgrp()告诉内核“现在哪个进程组是前台”。之后你按CtrlC内核就把SIGINT发给这个前台进程组里的每一个进程。下面是常见的终端按键和信号对照表按键信号默认行为发送范围CtrlCSIGINT中断进程前台进程组CtrlZSIGTSTP暂停进程前台进程组Ctrl\SIGQUIT退出并生成core dump前台进程组终端关闭SIGHUP挂断并终止进程会话首进程 前台进程组这里有个很实用的结论如果你把一个进程放到了后台cmd 那么CtrlC发不到它。后台进程组不是前台进程组终端驱动压根不理它。想专门结束后台进程得用kill命令显式地给它发信号。4.2 SIGHUP的完整发送链路SIGHUP挂断信号是会话体系里最重要的信号。它的触发条件是控制终端发生挂断hangup。最常见的情况有两种SSH连接断开、终端模拟器窗口被关闭。当控制终端断开时内核会给以下对象发送SIGHUP会话首进程通常是登录Shell。当前控制终端的前台进程组里的所有进程。Shell收到SIGHUP后默认行为是退出。但在退出之前Shell有责任给那些还在运行的子作业包括后台任务逐个转发SIGHUP。这也是为什么你SSH断开后很多进程跟着死掉的根本原因。如果想让某个进程在会话断掉之后继续存活思路就清晰了让进程忽略SIGHUP信号这就是nohup的原理。让进程彻底脱离当前会话不再属于原控制终端的“势力范围”这就是setsid和守护进程的原理。或者换一个思路不脱离会话但让整个会话保持“不断开”这就是tmux、screen这类终端复用工具的思路。4.3 作业控制Shell层面的“调度器”讲到进程组和会话不得不提作业控制job control。作业其实是Shell层面的概念一个作业由一条命令行启动的所有进程组成。Shell在作业控制开启时会把每个作业放进一个独立的进程组并对每个作业分配不同的组ID。通过jobs -l可以查看当前Shell管理的作业和对应的PGID。比如$ sleep 300 $ jobs -l [1] 12345 Running sleep 300 这里12345就是sleep 300这个作业里进程的PGID。通过kill -TERM -12345可以精确终止这个作业里的所有进程而不是误杀别的后台任务。Shell还提供了fg和bg命令切换作业的前后台状态。这两个命令底层就是调用tcsetpgrp()把目标进程组设置为前台进程组或者取消它。理解这一点你就能明白为什么fg之后按CtrlC可以杀掉刚拉回前台的命令而在bg状态时按CtrlC无效。5. 实战用进程组和会话管理进程5.1 查清进程的“户口信息”遇到进程管理问题第一步不是急着杀进程而是先查清楚它属于哪个进程组、哪个会话、谁在管它。推荐用下面这条命令ps -eo pid,ppid,pgid,sid,tpgid,stat,tty,comm各列含义PID是进程IDPPID是父进程IDPGID是进程组IDSID是会话IDTPGID是当前终端的前台进程组ID-1表示没有控制终端STAT是进程状态。STAT里有两类标志很关键s表示session leader会话首进程表示该进程属于前台进程组。举个例子某台机器上执行这条命令后看到PID PPID PGID SID TPGID STAT TTY COMMAND 1000 999 1000 1000 1000 Ss pts/0 bash 1234 1000 1234 1000 1234 S pts/0 python train.py 1235 1234 1234 1000 1234 S pts/0 python dataloader.py 5678 1000 5678 1000 1000 S pts/0 sleep 300在这个输出里bash是会话首进程SID等于自己的PID 1000。python训练任务的前台进程组PGID是1234TPGID也是1234说明它是当前终端的前台任务。后面的dataloader进程和train.py在同一个进程组所以信号会一起到。sleep 300是后台任务独立进程组TPGID是1000不是它的PGID所以CtrlC不会影响它。5.2 向整个进程组发送信号如果你想把某个任务的进程组一起干掉负号PID有特殊含义。kill -TERM -1234表示向PGID为1234的进程组里的所有进程发送SIGTERM信号。这和kill -TERM 1234只发信号给PID 1234这个进程是完全不同的。实际运维时可以用pkill -g PGID来对某个进程组执行终止操作也可以直接kill -TERM -$(ps -o pgid -p 12345 | tr -d )这条命令先查出进程12345的PGID然后用负号构造进程组信号。简单粗暴但对梳理一组进程非常有效。一个很有用的技巧是先发送SIGTERM温和的终止信号给进程留出清理资源的时间。等几秒之后如果进程还没退出再发送SIGKILL内核直接强制杀死进程没有机会做任何清理。不要一上来就kill -9进程可能因为没来得及释放临时文件或锁文件导致下次启动报错。5.3 脱离当前会话setsid和nohup还有disown如果想让一个进程在退出登录、关闭终端后继续存活有几种常用手段它们的原理各不相同。nohup的原理是启动命令时把SIGHUP设为忽略。这样即使终端断开、Shell广播SIGHUP这个进程也能“装死”不受影响。它并不会让进程脱离会话或终端只是忽略了一个信号。使用方式nohup ./start_server.sh server.log 21 setsid的原理更彻底。它让进程调用setsid()系统调用创建一个全新的会话并把进程自己变成新会话的会话首进程。因为新会话没有控制终端终端断不断开都跟它无关。命令本身setsid ./start_server.sh server.log 21 Shell层面还有个disown命令它不改变进程本身只是把某个后台作业从Shell的作业表里移除。Shell退出时就不会再向这个作业转发SIGHUP了。适合那些已经在跑、来不及用nohup重启的任务sleep 300 disown -h %1注意disown -h只是让Shell不再向它发送SIGHUP效果和nohup类似。如果进程本身通过其他路径收到了SIGHUP该挂还是会挂。5.4 守护进程化的标准步骤在更严肃的场景里比如要写一个后台服务程序一般会走完整守护进程化流程。核心就是调用setsid()脱离会话和控制终端然后做一系列收尾。一个标准的守护进程化步骤大致如下fork()创建子进程父进程立即exit()。这是为了让子进程不是进程组组长从而保证下一步的setsid()一定成功。子进程调用setsid()创建新会话子进程成为会话首进程并失去控制终端。可选再次fork()。这一步是为了确保进程不是会话首进程防止它以后通过ioctl重新获取控制终端。chdir(/)切换工作目录避免占住某个挂载点。umask(0)重置文件权限掩码保证程序创建文件时有完全控制权。关闭标准输入、输出、错误文件描述符或者把它们重定向到/dev/null或日志文件。写成代码片段大概是if (fork() ! 0) exit(0); setsid(); if (fork() ! 0) exit(0); chdir(/); umask(0); close(0); close(1); close(2); open(/dev/null, O_RDONLY); open(/var/log/app.log, O_WRONLY|O_CREAT|O_APPEND, 0644); dup2(1, 2);很多语言都有对应的守护化封装比如Python的python-daemon库。核心机制就是上面这套理解了流程之后再去看各种框架里的“daemon模式”基本都是一回事。6. 高频问题与排查技巧6.1 为什么SSH一断开进程就挂了这是面试和运维里最常被问的问题。进程挂掉不是因为它被“直接杀死”而是因为SSH连接断开时控制终端挂断内核向会话首进程Shell和前台进程组发送了SIGHUP。Shell默认会把SIGHUP转发给子作业默认行为又是终止进程所以链路就断了。有一种情况更隐晦你的进程自己并不是直接由Shell启动的而是由某个后台服务启动的。如果这个后台服务在SSH会话里那它收到SIGHUP退出时如果没处理好子进程也可能带着子进程一起退出。排查时先看父进程链pstree -ps PID如果最上层是bashPPID链指向登录Shell基本可以断定是SIGHUP引起的。解决方案要么nohup忽略信号要么setsid脱离会话要么用tmux/systemd来接管。6.2 为什么CtrlC杀不掉那个进程按CtrlC杀不掉的进程通常就三种情况。第一它不在前台进程组里。比如你用放到了后台或者Shell切换了前台进程组你这个进程已经不是TPGID了。终端驱动发出的SIGINT找不到它自然杀不掉。解决办法是fg把它调回前台或者直接kill -TERM PID。第二进程显式忽略了SIGINT信号。很多守护类程序因为不想被终端按键打断会在代码里对SIGINT做忽略处理。这种情况CtrlC当然无效只能用kill -TERM或者kill -KILL。第三进程陷入不可中断睡眠D状态内核层面的IO请求一直没返回。这种情况下连SIGKILL都杀不掉只能等IO恢复。排查时看STAT是不是D。如果长期D状态往往是存储设备或者内核文件系统有问题得从硬件和内核层面解决。6.3 孤儿进程和僵尸进程的区别这两个概念很容易被混在一起。孤儿进程是指父进程已经退出但进程还在运行的子进程。它会被系统托管给PID为1的init进程在容器里可能是tini或某个subreaperPPID变成1。孤儿进程不一定有问题它可能照常运行比如daemon化进程在父进程退出后就成了“孤儿”。僵尸进程是另一种情况子进程已经退出但父进程没有调用wait()系统调用去回收它所以内核不能彻底删除进程描述符进程状态显示为Z。僵尸进程不占用CPU和内存只占一个极小内核结构体但大量积累会耗尽PID空间。僵尸进程本身无法通过kill -9删除因为它在概念上已经死了你只能杀掉它的父进程让init去回收僵尸或者等父进程自己wait。排查僵尸进程用ps -eo pid,ppid,stat,cmd | awk $3Z6.4 快速定位进程群的实用组合拳最后一个实战技巧也是我日常排查问题用得最多的一套组合命令。先用一条命令把关键字段打出来再做分析ps -eo pid,ppid,pgid,sid,tpgid,stat,tty,cmd --sortpgid按PGID排序之后同一任务链的进程会挨在一起一眼能看出哪些进程共享同一个进程组和会话。如果想要一击定位某个进程的整个进程组信息可以这样PID12345 ps -eo pid,ppid,pgid,sid,tpgid,stat,tty,cmd | awk -v pid$PID $1pid || $3pid || $4pid它会找出指定PID对应的进程本身、同进程组的所有进程、同会话的所有进程。很多时候你发现“为什么杀了一个进程另一个进程还在”就是因为它们同属一个会话或进程组但你只杀了一个点。如果想把某个进程组一次性“优雅带走”我习惯用PGID$(ps -o pgid -p 12345 | tr -d ) kill -TERM -$PGID sleep 3 kill -KILL -$PGID先给进程组里的所有进程一个体面的退出机会再强制清理顽固分子。这套组合在批量重启服务、清理残留进程时非常可靠。我在实际使用中最大的体会是Linux进程管理的问题十有八九不是“某个进程怎么样”而是“这堆进程之间的关系怎么样”。把进程组的脉络和会话的边界摸清了再遇到那些诡异的后台任务消失、进程杀不掉、服务重启不干净的问题你就有了一套清晰的排查地图。最后分享一个小习惯我一般在服务器上会配置一个别名alias psgps -eo pid,ppid,pgid,sid,tpgid,stat,tty,cmd排查问题时先用它把全景拉出来然后再动手。工具不在多能把关系理清的那条命令就是最好的命令。