📚 本文收录于「流浪」的系列专栏
| 🐧Linux系统 | ⚙️C++ |
| 📊数据结构与算法 | 🐍Python |
| 🔗LangChain & LangGraph | 🗄️MySQL 数据库 |
| 🌿Git 工具 | 🌐计算机网络 |
| 🤖LLM | 💯大厂面试、八股 |
| 📚学习筑基专栏 |
🏠 博客主页:流浪 | 📝 原创首发于 CSDN
前言:讲进程控制时提过
./可执行占住终端、加&退到后台,也讲过 Ctrl+C 变成 SIGINT 投给前台,但后台为什么打不中、终端一关为什么跟着没,前面没交代。这篇补中间那层:进程组、会话、控制终端怎么套在一起,守护进程又怎么一次摘干净。
一、一次作业在终端里就是一个进程组
1.1 三个 sleep 的父进程是同一个
1. 管道作业由 shell 统一建组
- 敲
sleep 1000 | sleep 2000 | sleep 3000 &,shell 返回作业号和 PID,三个 sleep 在后台一起睡着 - 管道要求前一个的输出接后一个的输入,三个进程必须同时启动、一起被管
- 用作业控制格式打出它们的身份
psaxj|head-1&&psaxj|grepsleep|grep-vgrep2. 三行输出里 PPID、PGID、SID 各自成组
j是 BSD 的作业控制格式,等价于-o ppid,pid,pgid,sid,tty,tpgid,stat,uid,time,command- 示例里 bash 的 PID 是 1358016,三个 sleep 的 PID 是 1358036、1358037、1358038
- PPID 全是 1358016,说明三个都是同一个 bash fork 出来的,互为兄弟;但兄弟只说明来源相同,绑成整体的是 PGID 那一列
1.2 PGID 相同说明三个进程在同一个组里
1. PGID 就是组长的 PID
- 手册的定义很直接:进程组 ID 等同于该组组长的 PID
- 一次作业对应一个进程组,组内共享同一个 PGID,也是内核投递信号的单位
kill -PGID能一次打中整组,作业控制全靠这个
2. 管道作业的组长是最左边那个进程
- 示例里 PGID 是1358306,正好等于
sleep 1000的 PID——管道最左边这个进程当了组长 - 所以 PGID 不等于 bash 的 1358016:组是这条管道自己的,不是 shell 的
- 交互 shell 为每个作业单独建组;同样的命令写进脚本跑,没有作业控制,子进程直接继承 shell 的 PGID
- 单进程作业同理:
./可执行 &丢到后台,它自己就是组长
作业在终端里的身份从来不是「一个进程」,而是「一个进程组」。
二、一个会话对应一次登录,SID 全会话统一
2.1 同一会话里所有进程的 SID 相同
SID(Session ID)=会话首进程(session leader)的 PID,会话是一个或多个进程组的集合,用于终端作业控制、信号管理。
1. SID 取会话首进程的 PID
- 上面三行的 SID 都是 1358016,也就是 bash 的 PID:bash 是会话首进程,会话 ID 直接取它的 PID
- 一个会话可以装多个进程组,但不管装几个组,SID 都是同一个值
2. 会话是进程组的上层容器
- 层级固定两层:会话包含进程组,进程组包含进程,一个进程同时只属于一个组、一个会话
- 这个归属 fork 时整份继承、exec 也不改,所以新起的进程默认落在父进程的会话里
- 换组用
setpgid,换会话只有setsid一条路,而且带着「不能是组长」的硬门槛
2.2 登录一次就新建一个会话
1. xshell 登录时系统做了四件事
- 分配一个伪终端设备
/dev/pts/N,这是连接在系统里的落脚点 - fork 一个子进程,exec 成 bash,就是窗口里看到的 shell
- bash 调 setsid 建新会话,自己成为会话首进程,SID 与 PGID 都等于自己的 PID
- 于是 bash 既是独立进程,又独占一个进程组,是会话里资格最老的成员
2. 之后敲的每条命令都继承同一个 SID
- 在 bash 里起的进程 SID 一律继承 1358016,PGID 由 shell 按作业重新分配
- 同一个窗口里
ps axj | grep pts/0,会看到一长串 SID 相同的进程 - 多开一个窗口就是多一个会话:新的 pts 设备、新的 bash、新的 SID
会话解决的是「这个终端上的一切归谁管」:终端只有一个,登录的人却要开很多作业,会话把它们圈成可整体撤销的单位。
三、控制终端决定了信号的投递范围
3.1 bash 一出生就拿着三个指向终端的描述符
1. 0、1、2 全部指向同一个终端设备
- 会话首进程的标准输入、输出、错误都指向
/dev/pts/0,是登录程序开好再传下来的 - 不是 bash 自己 open 的,终端关掉时它们还留在原地,这正是后面要重定向的原因
- 一个会话最多挂一个控制终端,终端同一时刻只认一个前台进程组
2. /proc 里能看到这三条链接
ls-l/proc/1358016/fdps axj的 TTY 列写 pts/0 说明进程还挂在终端上,写?说明已经脱离- TPGID 列写的是终端当前的前台进程组,示例里是 1358016,也就是 bash 所在的组
3.2 键盘信号只投前台进程组,终端挂断才牵连后台
1. Ctrl+C 的投递范围是前台进程组
- 按下 Ctrl+C,终端驱动把它翻译成 SIGINT,投给当前终端上前台进程组的每个成员
- 键盘只有一个,输入的数据必须明确交给一个进程组,而它只给前台进程组
- 所以会话内部的进程组必须分成前台和后台:一次会话有且只有一个前台进程组,后台进程组可以有多个
- 后台作业的组不在前台,键盘输入碰不到它,所以打不中;fg 把它拉回前台就能打中
- 直接启动一个任务就敲不进命令,是它占着前台;丢到后台还能继续敲,是前台让回给了 shell
- 作业控制就是这套关系的入口:jobs 列出后台任务,fg 把指定任务提到前台,Ctrl+Z 把前台任务暂停并切到后台,bg 让停下的任务继续跑
- 从按键到信号位图这条链路在《Linux系统篇29——信号(一):从 Ctrl+C 到内核位图》里拆过,这里只取结论
2. 终端挂断时 SIGHUP 沿会话传下来
- 会话挂着控制终端时,终端一旦断开(关窗口、SSH 掉线),内核给会话首进程发 SIGHUP
- bash 收到后退出,退出时再向自己名下的各作业转发一遍 SIGHUP
- 作业没忽略这个信号就跟着死,这就是服务器上进程跑着跑着就没了的完整链条
3. nohup 只挡信号,setsid 才换会话
nohup cmd &做的是让 cmd 忽略 SIGHUP,会话没换、终端关系还在setsid cmd换的是会话本身,进程直接脱离原终端,bash 的转发也够不着它- 两者都能让进程活过掉线,只有后者改了进程关系
Ctrl+C 打不中后台,是投递范围的问题;终端一关全死,是会话归属的问题。两件事都指向同一个东西——控制终端。
四、setsid 建立新会话并脱离控制终端
4.1 组长身份是调用 setsid 的硬门槛
1. 组长调用必然返回 EPERM
setsid(2)的失败条件:只要有任何进程的 PGID 等于调用者 PID,就返回 -1 并把 errno 置成 EPERM- 自己就是组长时这条必然成立,所以组长调 setsid 一定失败
- 手册给的理由是保住两级层次:组长带着组进新会话、组里其他人留在原会话,会话和组就断了
2. fork 一次让子进程获得调用资格
- fork 出的子进程拿到新 PID,不可能等于继承来的 PGID,天生不是组长
- 父进程
_exit之后,子进程去调 setsid 就能成功 - 这一退一留顺手把进程变成孤儿,被上层收养——「守护进程的父进程是 1」是这么来的
4.2 setsid 成功后 SID 和 PGID 都变成自己的 PID
1. 四项状态一起变
| 项 | 调用前(fork 出的子进程) | 调用后 |
|---|---|---|
| SID | bash 的 PID | 自己的 PID |
| PGID | 父进程所在组的 PID | 自己的 PID |
| 身份 | 普通组员 | 会话首进程 + 新组组长 |
| 控制终端 | /dev/pts/0 | 无,TTY 显示? |
- 手册原话:调用者成为新会话首进程(SID 等于自己 PID),同时成为新会话里新组的组长(PGID 等于自己 PID)
- 新会话一开始没有任何控制终端,要重新获得得走
credentials(7)那套流程 ps axj上可见两个变化:TTY 从 pts/0 变?,STAT 里多出s标记
2. setsid 不搬文件描述符
- setsid 改的是归属关系,0、1、2 还指着原来那个终端设备,没关也没换
- 终端已经关了,往这几条写就出错;终端还在,输出仍会漏到原来那个窗口
- 所以标准流得守护进程自己重定向,setsid 不替它做
五、守护进程由 setsid 加五步环境处理组成
5.1 六步各自解决一个问题
1. 前四步改身份,后三步改环境
- fork 后父进程退出
- 让子进程不是组长,能调 setsid
- 顺带转入后台,被 init 或 subreaper 收养
setsid()建新会话- 脱离控制终端,成为新会话首进程
- 再 fork 一次
- 让干活的进程不是会话首进程,永远拿不回控制终端
chdir("/")切到根目录- 不占着某个挂载点,避免
umount报设备忙
- 不占着某个挂载点,避免
umask(0)清掉权限掩码- 文件权限完全由
open、mkdir的 mode 参数决定
- 文件权限完全由
- 0、1、2 重定向到
/dev/null- 断掉残留的终端引用,读立即 EOF,写直接丢弃
2. 二次 fork 防的是重新拿到终端
- 会话首进程只要 open 一个 tty 设备节点,就可能重新挂上控制终端
- 程序或依赖库某一处
open漏了O_NOCTTY,一次 fork 的守护进程就会被拽回终端 - 再 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- PPID 是 1,说明原父进程已退出,被收养了
- PGID 与 SID 都等于它自己的 PID,说明它是新会话首进程、新组组长
- TTY 是
?,说明控制终端已经摘掉
2. /proc 一屏验重定向
ls-l/proc/<pid>/fd- 0、1、2 三条链接全部指向
/dev/null,和终端再无关系 - 如果还指着
/dev/pts/N,说明第六步没做,或者做在了 fork 之前
3. 日志一屏验存活
tail-f/tmp/my_daemon.log- 每 5 秒多一行,说明进程在终端关掉之后照样活着
- 三处对上,四条判据齐全:父为 1、自成会话、无控制终端、标准流已重定向
守护进程不是特殊进程类型,是普通进程把「会话、进程组、控制终端、标准流」四样东西逐条切断之后的结果。
六、孤儿被收养,僵尸等父进程回收
6.1 两种进程只差有没有人收尾
1. 孤儿进程被 init 或 subreaper 收养
- 父进程先退出、子进程还活着,子进程成了孤儿,被 PID 1(或设了 subreaper 的祖先)接管
- 孤儿照常运行,退出时由收养者回收,不会留下僵尸
- fork 加 setsid 造出来的就是这一条,所以守护进程天然是孤儿
2. 僵尸进程是退出了但没人回收
- 子进程先退出、父进程没有
wait,进程实体没了,task_struct还留着等父进程取退出码 - 这份残留占一个 PID 表项,
ps里状态显示Z,杀不掉也唤不醒 - 父进程退出后僵尸才由收养者收走;父进程长期不退,僵尸就一直堆着
七、全篇总结
1. 一次作业在终端里是一个进程组
- PGID 等于组长的 PID,管道作业的组长是管道最左边的进程
- 交互 shell 为每个作业单独建组,脚本里不建,子进程直接继承 shell 的 PGID
2. 一个会话对应一次登录
- 会话首进程的 PID 就是 SID,会话内所有进程 SID 相同
- 换组用 setpgid,换会话只有 setsid,而且调用者不能是组长
3. 控制终端决定信号投给谁
- Ctrl+C 只投前台进程组,后台作业打不中
- 终端挂断时 SIGHUP 先给会话首进程,再由 shell 转给各作业
4. setsid 一次改掉身份和终端关系
- 成功后 SID 与 PGID 都等于自己的 PID,身份变成会话首进程兼新组组长
- 控制终端断开、TTY 显示
?,但描述符不会跟着搬走
5. 守护进程是这套关系的标准组合
- 两次 fork 加 setsid 解决身份,chdir、umask、重定向解决环境
- 判据四条:父为 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 系统篇持续更新。