
为什么需要理解进程间关系我们平时写程序习惯了一个进程单打独斗。但在 Linux/Unix 系统中进程从来不是孤立的你在终端敲下ls | grep txtShell 会创建两个进程它们属于同一个进程组你按下CtrlC信号不是发给某一个进程而是发给整个前台进程组你关闭终端后台跑着的服务却依然活着——这就是守护进程的功劳。理解进程组、会话、控制终端、作业控制这几层关系是写出健壮后台服务比如 TCP 服务器的基础。而守护进程Daemon正是这些概念的集大成者。下面我们从最底层的进程组开始逐层向上剖析。进程组Process Group什么是进程组每一个进程除了有PID进程 ID之外还属于某个进程组。进程组一个或多个进程的集合。PGID进程组 ID每个进程组有唯一的 ID类型为pid_t本质是一个正整数。可以通过ps命令查看ps -eo pid,pgid,ppid,comm | grep test输出示例PID PGID PPID COMMAND 2830 2830 2259 test-eevery显示所有进程-o以逗号分隔指定要输出的列可以看到test进程的 PID 和 PGID 相同说明它是组长进程。组长进程Process Group Leader组长进程的 PID 等于其 PGID。看一个例子ps -o pid,pgid,ppid,comm | cat输出PID PGID PPID COMMAND 2806 2806 2805 bash 2880 2880 2806 ps 2881 2880 2806 cat分析ps进程的 PID PGID 2880所以ps是组长cat的 PGID 也是 2880所以它和ps属于同一个进程组管道|会把ps和cat编成一个进程组。组长的作用可以创建一个进程组可以创建该组中的进程。进程组的生命周期从进程组创建开始到其中最后一个进程离开为止注意只要组内还有任意一个进程存在进程组就存在与组长是否终止无关。组长死了组还在只是群龙无首。组长进程终止后进程组并不会消失但该组不再有组长。此时组内其他进程的 PGID 依然是原来的值。会话Session什么是会话会话是一个或多个进程组的集合。每个会话有唯一的SID会话 ID。用一张图表示层级关系会话 (Session, SID) ├── 进程组 1 (PGID100) │ └── proc1 ├── 进程组 2 (PGID200) │ ├── proc2 │ └── proc3 └── 进程组 3 (PGID300) ├── proc4 ├── proc5 └── proc6通常我们用管道把几个进程编成一个进程组再用放到后台proc2 | proc3 proc4 | proc5 | proc6 表示将进程组放在后台执行。实验观察进程组与会话sleep 100 | sleep 200 | sleep 300 ps axj | head -n1 ps axj | grep sleep | grep -v grep输出PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 2806 4223 4223 2780 pts/2 4229 S 1000 0:00 sleep 100 2806 4224 4223 2780 pts/2 4229 S 1000 0:00 sleep 200 2806 4225 4223 2780 pts/2 4229 S 1000 0:00 sleep 300观察三个 sleep 的PGID 相同4223说明属于同一个进程组它们的SID 相同2780说明属于同一个会话TTY 都是pts/2说明共享同一个控制终端。参数说明a显示所有用户进程x显示没有控制终端的进程j显示与作业控制相关的信息PGID、SID、TTY、TPGID 等。如何创建会话——setsid()#include unistd.h /* * 功能创建会话 * 返回值成功返回 SID失败返回 -1 */ pid_t setsid(void);调用setsid()后会发生三件事调用进程变成新会话的会话首进程此时新会话中只有它一个进程调用进程变成新进程组的组长新 PGID 当前进程 PID该进程失去控制终端。如果之前有控制终端调用后联系被切断。前提调用进程不能是进程组的组长否则会报错。常见用法先fork()出子进程父进程退出子进程继续执行。因为子进程会继承父进程的 PGID但 PID 是新分配的所以它不是组长可以安全调用setsid()。if (fork() 0) exit(0); // 父进程退出 setsid(); // 子进程创建新会话会话 IDSID会话首进程创建会话的那个进程具有唯一的 PIDSID 会话首进程的 PID又因为会话首进程总是它所在进程组的组长所以SID 也等于该进程组的 PGID两者等价。控制终端Controlling Terminal什么是控制终端在 Linux 系统中用户登录后得到一个Shell 进程这个终端就成了 Shell 的控制终端。控制终端信息保存在PCB进程控制块中。fork()会复制 PCB所以 Shell 启动的子进程默认也以同一个终端为控制终端。默认情况下无重定向标准输入 → 控制终端读键盘标准输出 / 标准错误 → 控制终端写屏幕会话、进程组与控制终端的关系一个会话最多有一个控制终端会话首进程打开终端后该终端成为该会话的控制终端建立与控制终端连接的会话首进程称为控制进程一个会话中的进程组分为前台进程组1 个后台进程组0 个或多个终端产生的中断信号发给前台进程组的所有进程如果终端检测到调制解调器或网络断开会向控制进程发送挂断信号SIGHUP。结构示意图会话 (Session) ┌───────────────────────────────────────────────┐ │ 后台进程组 后台进程组 前台进程组 │ │ [登录 shell] [proc1 proc2] [proc3 proc4]│ │ 会话首进程控制进程 [proc5] │ └───────────────────────────────────────────────┘ ↑ ↑ 挂断信号(SIGHUP) 终端输入和终端产生的信号 │ │ └──────────── 控制终端 ────────────┘ ↑ 键盘输入作业控制Job Control作业与作业控制作业Job从用户角度看是为完成某项任务而启动的进程集合。一个作业可以是一个进程也可以是一组协作的进程通常是管道。作业控制Job ControlShell 分前后台控制的是作业/进程组而不是单个进程。一个前台作业可以由多个进程组成一个后台作业也可以。Shell 可以同时运行1 个前台作业 任意多个后台作业。例如cat /etc/filesystems | head -n 5这就是一个作业包含两个进程cat和head在前台运行。作业号在命令后加可让 Shell 把它放到后台cat /etc/filesystems | grep ext 输出[1] 2202 ext4 ext3 ext2 [1] 完成 cat /etc/filesystems | grep --colorauto ext[1]作业号2202进程号PGID默认作业完成后会显示完成默认作业规则默认作业最近一次提交的-即将成为默认作业的倒数第二个无符号其他作业一个用户同时只能有一个和一个-作业状态状态含义Running后台作业正在执行Done作业完成返回码为 0Done(code)作业完成并退出返回码非 0Stopped前台作业被CtrlZ挂起Terminated作业被终止作业的挂起与切回1挂起CtrlZ运行一个死循环程序#include stdio.h int main() { while (1) { printf(hello\n); } return 0; }运行./test按CtrlZ[1] 已停止 ./test2切回前台fgfg %%fg参数表参数含义%n作业号 n%string以 string 开头的命令对应的作业%?string包含 string 的命令对应的作业%或%%最近提交的作业%-倒数第二个提交的作业不带参数时默认把作业切到前台。查看后台/挂起作业jobsjobs # 查看作业 jobs -l # 显示详细信息含 PID jobs -p # 只显示 PID示例sleep 300 ./test # CtrlZ jobs -l输出[1]- 2265 运行中 sleep 300 [2] 2267 停止 ./test作业控制相关的信号按键信号含义CtrlCSIGINT中断字符Ctrl\SIGQUIT退出字符CtrlZSIGTSTP挂起字符关键点这些信号只会发送给前台进程组中的所有进程后台进程组不受影响。终端的 I/O标准输入/输出和终端产生的信号总是与前台进程组关联。守护进程Daemon什么是守护进程守护进程是在后台长期运行、脱离控制终端的进程。典型例子sshd、httpd、mysqld。它的特点没有控制终端独立于任何会话终端通常随系统启动而启动随系统关闭而终止名字通常以d结尾daemon。创建守护进程的步骤#pragma once #include iostream #include cstdlib #include signal.h #include unistd.h #include fcntl.h #include sys/types.h #include sys/stat.h const char *root /; const char *dev_null /dev/null; void Daemon(bool ischdir, bool isclose) { // 1. 忽略可能引起程序异常退出的信号 signal(SIGCHLD, SIG_IGN); signal(SIGPIPE, SIG_IGN); // 2. 让自己不要成为组长 if (fork() 0) exit(0); // 3. 设置让自己成为一个新的会话子进程继续执行 setsid(); // 4. 是否将当前进程的 CWD 更改为根目录 if (ischdir) chdir(root); // 5. 已经变成守护进程不再和用户的输入输出、错误关联 if (isclose) { close(0); close(1); close(2); } else { // 一般建议用这种重定向到 /dev/null int fd open(dev_null, O_RDWR); if (fd 0) { dup2(fd, 0); dup2(fd, 1); dup2(fd, 2); close(fd); } } }主程序使用int main(int argc, char *argv[]) { if (argc ! 2) { std::cout Usage : argv[0] port std::endl; return 0; } uint16_t localport std::stoi(argv[1]); Daemon(false, false); std::unique_ptrTcpServer svr(new TcpServer(localport, HandlerRequest)); svr-Loop(); return 0; }每一步为什么这么做步骤操作原因1忽略 SIGCHLD、SIGPIPE防止子进程退出或写管道出错导致进程异常终止2fork()后父进程退出确保子进程不是进程组组长为setsid()做准备3setsid()创建新会话脱离原控制终端4chdir(/)避免占用可卸载的文件系统如 U 盘防止 CWD 被删除导致问题5重定向 0/1/2 到/dev/null守护进程不需要终端输入输出避免资源浪费和意外阻塞更完整的守护进程实际工程中守护进程通常还会二次 fork确保进程不是会话首进程从而永远不会获得控制终端。设置文件权限掩码umask(0)避免继承的掩码影响创建文件。写 PID 文件/var/run/xxx.pid方便管理。日志系统用syslog替代 stdout/stderr。二次 fork 的代码示例if (fork() 0) exit(0); setsid(); if (fork() 0) exit(0); // 二次 fork确保不是会话首进程 // 继续执行守护进程逻辑总结与知识地图进程 ├── PID进程 ID ├── 属于某个进程组 │ ├── PGID │ └── 组长进程PID PGID ├── 属于某个会话 │ ├── SID │ └── 会话首进程PID SID └── 可能拥有控制终端 ├── 前台进程组 ← CtrlC / Ctrl\ / CtrlZ └── 后台进程组 ← 启动 作业控制 ├── jobs / fg / bg ├── CtrlZ 挂起 └── 信号SIGINT、SIGQUIT、SIGTSTP 守护进程 ├── fork → setsid → chdir → 重定向 ├── 脱离控制终端 └── 长期后台运行