☰
Linux系统篇48:番外篇(一)——进程组、会话与守护进程,Ctrl+C 打不中后台、终端一关进程就死,原因都在这一层关系
2026/10/28 10:09:42 网站建设 项目流程


📚 本文收录于「流浪」的系列专栏

🐧Linux系统⚙️C++
📊数据结构与算法🐍Python
🔗LangChain & LangGraph🗄️MySQL 数据库
🌿Git 工具🌐计算机网络
🤖LLM💯大厂面试、八股
📚学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言:讲进程控制时提过./可执行占住终端、加&退到后台,也讲过 Ctrl+C 变成 SIGINT 投给前台,但后台为什么打不中、终端一关为什么跟着没,前面没交代。这篇补中间那层:进程组、会话、控制终端怎么套在一起,守护进程又怎么一次摘干净。


一、一次作业在终端里就是一个进程组

1.1 三个 sleep 的父进程是同一个

1. 管道作业由 shell 统一建组
  1. 敲sleep 1000 | sleep 2000 | sleep 3000 &,shell 返回作业号和 PID,三个 sleep 在后台一起睡着
  2. 管道要求前一个的输出接后一个的输入,三个进程必须同时启动、一起被管
  3. 用作业控制格式打出它们的身份
psaxj|head-1&&psaxj|grepsleep|grep-vgrep
2. 三行输出里 PPID、PGID、SID 各自成组
  1. j是 BSD 的作业控制格式,等价于-o ppid,pid,pgid,sid,tty,tpgid,stat,uid,time,command
  2. 示例里 bash 的 PID 是 1358016,三个 sleep 的 PID 是 1358036、1358037、1358038

  1. PPID 全是 1358016,说明三个都是同一个 bash fork 出来的,互为兄弟;但兄弟只说明来源相同,绑成整体的是 PGID 那一列

1.2 PGID 相同说明三个进程在同一个组里

1. PGID 就是组长的 PID
  1. 手册的定义很直接:进程组 ID 等同于该组组长的 PID
  2. 一次作业对应一个进程组,组内共享同一个 PGID,也是内核投递信号的单位
  3. kill -PGID能一次打中整组,作业控制全靠这个
2. 管道作业的组长是最左边那个进程

  1. 示例里 PGID 是1358306,正好等于sleep 1000的 PID——管道最左边这个进程当了组长
  2. 所以 PGID 不等于 bash 的 1358016:组是这条管道自己的,不是 shell 的
  3. 交互 shell 为每个作业单独建组;同样的命令写进脚本跑,没有作业控制,子进程直接继承 shell 的 PGID
  4. 单进程作业同理:./可执行 &丢到后台,它自己就是组长

作业在终端里的身份从来不是「一个进程」,而是「一个进程组」。

二、一个会话对应一次登录,SID 全会话统一

2.1 同一会话里所有进程的 SID 相同

SID(Session ID)=会话首进程(session leader)的 PID,会话是一个或多个进程组的集合,用于终端作业控制、信号管理。

1. SID 取会话首进程的 PID

  1. 上面三行的 SID 都是 1358016,也就是 bash 的 PID:bash 是会话首进程,会话 ID 直接取它的 PID
  2. 一个会话可以装多个进程组,但不管装几个组,SID 都是同一个值
2. 会话是进程组的上层容器
  1. 层级固定两层:会话包含进程组,进程组包含进程,一个进程同时只属于一个组、一个会话
  2. 这个归属 fork 时整份继承、exec 也不改,所以新起的进程默认落在父进程的会话里
  3. 换组用setpgid,换会话只有setsid一条路,而且带着「不能是组长」的硬门槛

2.2 登录一次就新建一个会话

1. xshell 登录时系统做了四件事
  1. 分配一个伪终端设备/dev/pts/N,这是连接在系统里的落脚点
  2. fork 一个子进程,exec 成 bash,就是窗口里看到的 shell
  3. bash 调 setsid 建新会话,自己成为会话首进程,SID 与 PGID 都等于自己的 PID
  4. 于是 bash 既是独立进程,又独占一个进程组,是会话里资格最老的成员
2. 之后敲的每条命令都继承同一个 SID
  1. 在 bash 里起的进程 SID 一律继承 1358016,PGID 由 shell 按作业重新分配
  2. 同一个窗口里ps axj | grep pts/0,会看到一长串 SID 相同的进程
  3. 多开一个窗口就是多一个会话:新的 pts 设备、新的 bash、新的 SID

会话解决的是「这个终端上的一切归谁管」:终端只有一个,登录的人却要开很多作业,会话把它们圈成可整体撤销的单位。

三、控制终端决定了信号的投递范围

3.1 bash 一出生就拿着三个指向终端的描述符

1. 0、1、2 全部指向同一个终端设备
  1. 会话首进程的标准输入、输出、错误都指向/dev/pts/0,是登录程序开好再传下来的
  2. 不是 bash 自己 open 的,终端关掉时它们还留在原地,这正是后面要重定向的原因
  3. 一个会话最多挂一个控制终端,终端同一时刻只认一个前台进程组
2. /proc 里能看到这三条链接
ls-l/proc/1358016/fd

  1. ps axj的 TTY 列写 pts/0 说明进程还挂在终端上,写?说明已经脱离
  2. TPGID 列写的是终端当前的前台进程组,示例里是 1358016,也就是 bash 所在的组

3.2 键盘信号只投前台进程组,终端挂断才牵连后台

1. Ctrl+C 的投递范围是前台进程组
  1. 按下 Ctrl+C,终端驱动把它翻译成 SIGINT,投给当前终端上前台进程组的每个成员
  2. 键盘只有一个,输入的数据必须明确交给一个进程组,而它只给前台进程组
  3. 所以会话内部的进程组必须分成前台和后台:一次会话有且只有一个前台进程组,后台进程组可以有多个
  4. 后台作业的组不在前台,键盘输入碰不到它,所以打不中;fg 把它拉回前台就能打中
  5. 直接启动一个任务就敲不进命令,是它占着前台;丢到后台还能继续敲,是前台让回给了 shell
  6. 作业控制就是这套关系的入口:jobs 列出后台任务,fg 把指定任务提到前台,Ctrl+Z 把前台任务暂停并切到后台,bg 让停下的任务继续跑
  7. 从按键到信号位图这条链路在《Linux系统篇29——信号(一):从 Ctrl+C 到内核位图》里拆过,这里只取结论


2. 终端挂断时 SIGHUP 沿会话传下来
  1. 会话挂着控制终端时,终端一旦断开(关窗口、SSH 掉线),内核给会话首进程发 SIGHUP
  2. bash 收到后退出,退出时再向自己名下的各作业转发一遍 SIGHUP
  3. 作业没忽略这个信号就跟着死,这就是服务器上进程跑着跑着就没了的完整链条
3. nohup 只挡信号,setsid 才换会话
  1. nohup cmd &做的是让 cmd 忽略 SIGHUP,会话没换、终端关系还在
  2. setsid cmd换的是会话本身,进程直接脱离原终端,bash 的转发也够不着它
  3. 两者都能让进程活过掉线,只有后者改了进程关系

Ctrl+C 打不中后台,是投递范围的问题;终端一关全死,是会话归属的问题。两件事都指向同一个东西——控制终端。

四、setsid 建立新会话并脱离控制终端

4.1 组长身份是调用 setsid 的硬门槛

1. 组长调用必然返回 EPERM
  1. setsid(2)的失败条件:只要有任何进程的 PGID 等于调用者 PID,就返回 -1 并把 errno 置成 EPERM
  2. 自己就是组长时这条必然成立,所以组长调 setsid 一定失败
  3. 手册给的理由是保住两级层次:组长带着组进新会话、组里其他人留在原会话,会话和组就断了
2. fork 一次让子进程获得调用资格
  1. fork 出的子进程拿到新 PID,不可能等于继承来的 PGID,天生不是组长
  2. 父进程_exit之后,子进程去调 setsid 就能成功
  3. 这一退一留顺手把进程变成孤儿,被上层收养——「守护进程的父进程是 1」是这么来的

4.2 setsid 成功后 SID 和 PGID 都变成自己的 PID

1. 四项状态一起变
项调用前(fork 出的子进程)调用后
SIDbash 的 PID自己的 PID
PGID父进程所在组的 PID自己的 PID
身份普通组员会话首进程 + 新组组长
控制终端/dev/pts/0无,TTY 显示?
  1. 手册原话:调用者成为新会话首进程(SID 等于自己 PID),同时成为新会话里新组的组长(PGID 等于自己 PID)
  2. 新会话一开始没有任何控制终端,要重新获得得走credentials(7)那套流程
  3. ps axj上可见两个变化:TTY 从 pts/0 变?,STAT 里多出s标记
2. setsid 不搬文件描述符
  1. setsid 改的是归属关系,0、1、2 还指着原来那个终端设备,没关也没换
  2. 终端已经关了,往这几条写就出错;终端还在,输出仍会漏到原来那个窗口
  3. 所以标准流得守护进程自己重定向,setsid 不替它做

五、守护进程由 setsid 加五步环境处理组成

5.1 六步各自解决一个问题

1. 前四步改身份,后三步改环境
  1. fork 后父进程退出
    • 让子进程不是组长,能调 setsid
    • 顺带转入后台,被 init 或 subreaper 收养
  2. setsid()建新会话
    • 脱离控制终端,成为新会话首进程
  3. 再 fork 一次
    • 让干活的进程不是会话首进程,永远拿不回控制终端
  4. chdir("/")切到根目录
    • 不占着某个挂载点,避免umount报设备忙
  5. umask(0)清掉权限掩码
    • 文件权限完全由open、mkdir的 mode 参数决定
  6. 0、1、2 重定向到/dev/null
    • 断掉残留的终端引用,读立即 EOF,写直接丢弃
2. 二次 fork 防的是重新拿到终端
  1. 会话首进程只要 open 一个 tty 设备节点,就可能重新挂上控制终端
  2. 程序或依赖库某一处open漏了O_NOCTTY,一次 fork 的守护进程就会被拽回终端
  3. 再 fork 一次,干活的进程不是会话首进程,就彻底没这个机会

这套流程是 SysV 的老做法,systemd 托管的服务由它代劳,不需要程序自己写。

5.2 my_daemon.c 完整实现六步

#include<stdio.h>#include<stdlib.h>#include<unistd.h>#include<fcntl.h>#include<sys/stat.h>#include<time.h>#defineLOG_FILE"/tmp/my_daemon.log"intmain(void){pid_tpid=fork();if(pid<0){perror("fork");return1;}if(pid>0)exit(0);// 第一步:父进程退出,子进程不再是组长if(setsid()<0){perror("setsid");return1;}// 第二步:建新会话,脱离控制终端pid=fork();if(pid<0){perror("fork");return1;}if(pid>0)exit(0);// 第三步:再退一次,保证不是会话首进程chdir("/");// 第四步:不占任何挂载点umask(0);// 第五步:权限由 open 的 mode 直接决定intfd=open("/dev/null",O_RDWR);if(fd<0)return1;dup2(fd,STDIN_FILENO);// 第六步:三条标准流全部指向 /dev/nulldup2(fd,STDOUT_FILENO);dup2(fd,STDERR_FILENO);if(fd>STDERR_FILENO)close(fd);while(1){// 干活循环:每 5 秒往日志里写一行FILE*fp=fopen(LOG_FILE,"a");if(fp){time_tnow=time(NULL);fprintf(fp,"daemon alive: %s",ctime(&now));fclose(fp);}sleep(5);}return0;}

编译运行一行搞定:gcc my_daemon.c -o my_daemon && ./my_daemon。命令返回说明父进程已退,没有前台进程占着终端。

5.3 跑起来看三处

1. ps 一屏验身份
psaxj|head-1&&psajx|grepmy_daemon|grep-vgrep
  1. PPID 是 1,说明原父进程已退出,被收养了
  2. PGID 与 SID 都等于它自己的 PID,说明它是新会话首进程、新组组长
  3. TTY 是?,说明控制终端已经摘掉
2. /proc 一屏验重定向
ls-l/proc/<pid>/fd
  1. 0、1、2 三条链接全部指向/dev/null,和终端再无关系
  2. 如果还指着/dev/pts/N,说明第六步没做,或者做在了 fork 之前
3. 日志一屏验存活
tail-f/tmp/my_daemon.log
  1. 每 5 秒多一行,说明进程在终端关掉之后照样活着
  2. 三处对上,四条判据齐全:父为 1、自成会话、无控制终端、标准流已重定向

守护进程不是特殊进程类型,是普通进程把「会话、进程组、控制终端、标准流」四样东西逐条切断之后的结果。

六、孤儿被收养,僵尸等父进程回收

6.1 两种进程只差有没有人收尾

1. 孤儿进程被 init 或 subreaper 收养
  1. 父进程先退出、子进程还活着,子进程成了孤儿,被 PID 1(或设了 subreaper 的祖先)接管
  2. 孤儿照常运行,退出时由收养者回收,不会留下僵尸
  3. fork 加 setsid 造出来的就是这一条,所以守护进程天然是孤儿
2. 僵尸进程是退出了但没人回收
  1. 子进程先退出、父进程没有wait,进程实体没了,task_struct还留着等父进程取退出码
  2. 这份残留占一个 PID 表项,ps里状态显示Z,杀不掉也唤不醒
  3. 父进程退出后僵尸才由收养者收走;父进程长期不退,僵尸就一直堆着

七、全篇总结

1. 一次作业在终端里是一个进程组
  1. PGID 等于组长的 PID,管道作业的组长是管道最左边的进程
  2. 交互 shell 为每个作业单独建组,脚本里不建,子进程直接继承 shell 的 PGID
2. 一个会话对应一次登录
  1. 会话首进程的 PID 就是 SID,会话内所有进程 SID 相同
  2. 换组用 setpgid,换会话只有 setsid,而且调用者不能是组长
3. 控制终端决定信号投给谁
  1. Ctrl+C 只投前台进程组,后台作业打不中
  2. 终端挂断时 SIGHUP 先给会话首进程,再由 shell 转给各作业
4. setsid 一次改掉身份和终端关系
  1. 成功后 SID 与 PGID 都等于自己的 PID,身份变成会话首进程兼新组组长
  2. 控制终端断开、TTY 显示?,但描述符不会跟着搬走
5. 守护进程是这套关系的标准组合
  1. 两次 fork 加 setsid 解决身份,chdir、umask、重定向解决环境
  2. 判据四条:父为 1、自成会话、无终端、标准流已重定向

八、文末面试题

8.1 推导题

1. fork 出来的子进程,PGID 和 SID 会变吗?

答(推导):不会。fork 只分配新的 PID,进程组和会话归属从父进程整份继承,子进程直接落在父进程的组和会话里。改组用setpgid,换会话用setsid,都不会在 fork 时自动发生。

2. 一条管道里的三个进程属于同一个进程组吗?

答(推导):属于。管道作业在 shell 里是一个 job,交互 shell 会为它单独建组,组长是管道最左边的进程,另外两个成员的 PGID 都指向组长。三者 PPID 也相同,但 PPID 相同只说明兄弟关系,真正绑定的是 PGID。

3. setsid 为什么必须先 fork 一次?

答(推导):setsid 的失败条件是「有任何进程的 PGID 等于调用者 PID」,自己就是组长时这条必然成立,调用直接返回 EPERM。fork 出的子进程 PID 是新的,不可能等于继承来的 PGID,由它调用才能成功。内核这么拦,是避免组长带着组进新会话、组里其他人留在原会话,把两层结构撕开。

4. 守护进程为什么通常 fork 两次?

答(推导):第一次 fork 让调用 setsid 的进程不是组长,否则 setsid 直接失败。第二次 fork 让真正干活的进程不再是会话首进程——会话首进程打开 tty 设备时可能重新获得控制终端,程序某处open漏了O_NOCTTY就会中招;不是首进程就永远没这个机会。

5. setsid 之后,为什么还要把 0、1、2 重定向到 /dev/null?

答(推导):setsid 只改会话和进程组的归属,不碰已打开的描述符,0、1、2 仍指向原来那个终端设备。终端关闭后往这些描述符写就出错,终端还在输出也会漏到别人的窗口。重定向之后读立即 EOF、写直接丢弃,才算和终端彻底断干净。

8.2 真题

1. SSH 断开后,之前启动的进程为什么可能挂掉?

答(推导 · 已对照解析,转述):终端挂断时内核向会话首进程发 SIGHUP,shell 收到后退出,退出时再向自己名下的作业转发 SIGHUP,作业没忽略这个信号就会终止。要让进程活下来,要么忽略 SIGHUP(nohup),要么换新会话(setsid),要么交给 systemd 托管。

【真题·转述自 CSDN《搞懂Linux进程间关系:进程组、会话与控制终端》】

2. 创建守护进程为什么要 fork 两次?

答(推导 · 已对照解析,转述):第一次 fork 让子进程不是组长,否则 setsid 会失败;setsid 之后子进程成为新会话首进程,第二次 fork 让干活的进程不再是会话首进程,从而无法重新获得控制终端,父进程退出后它被 init 接管,成为后台服务进程。

【真题·转述自 Today I Learned《为什么创建 Daemon 进程要 Fork 两次?》】


💬结语:进程组管一次作业,会话管一次登录,控制终端决定信号投给谁,守护进程把这四层关系一次摘干净。评论区聊聊你在服务器上把进程跑丢过几次,觉得有用点个赞,Linux 系统篇持续更新。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询