Linux内核学习轨迹第四部: 进程组、会话与终端控制(第七小节)

Linux内核学习轨迹第四部: 进程组、会话与终端控制(第七小节) 9. 进程组、会话与终端控制进程组、会话与终端控制是Linux进程管理中最容易被忽略的部分却是shell作业控制、守护进程、终端交互的底层核心。很多工程师写的后台服务、守护进程出现异常退出、终端关闭后程序被杀、信号无法正确处理等问题根源都是没有理解进程组、会话与控制终端的底层机制。本章节完整拆解进程组、会话、控制终端的内核实现、作业控制机制、相关系统调用以及守护进程的正确实现规范解决工程中常见的终端相关问题。9.1 进程组的定义与内核实现进程组Process Group是一组相关进程的集合它的设计目的是为了方便对一组进程进行统一的信号发送和管理。最典型的场景就是shell执行管道命令时会把管道中的所有进程放到同一个进程组中按下CtrlC时shell会给整个进程组发送SIGINT信号终止管道中的所有进程。9.1.1 进程组的核心特性进程组IDPGID每个进程组有一个唯一的进程组ID等于进程组组长的PID进程组组长创建进程组的进程就是进程组组长组长的PID等于PGID生命周期进程组的生命周期从组长创建开始直到组内最后一个进程退出哪怕组长已经退出进程组依然存在信号管理可以通过killpg()系统调用给整个进程组发送信号组内的所有进程都会收到信号作业控制shell的作业控制本质就是对进程组的管理每个后台/前台作业对应一个进程组。9.1.2 进程组的内核实现进程组的核心信息保存在task_struct结构体中相关字段struct task_struct { // 进程所属的进程组 struct pid *pgrp; // 进程所属的会话 struct pid *session; };内核用struct pid结构体管理进程组ID和会话ID每个PGID对应一个struct pid实例所有属于该进程组的进程pgrp字段都指向这个实例。9.1.3 进程组相关的系统调用1.setpgid()设置进程的进程组ID创建新的进程组// 把pid进程的PGID设置为pgid// pid0表示当前进程pgid0表示用pid作为PGID创建新的进程组int setpgid(pid_t pid, pid_t pgid);典型用法创建子进程后在父子进程中都调用setpgid(child_pid, child_pid)把子进程设置为新进程组的组长避免竞态问题限制只能把进程加入到同一会话内的进程组不能跨会话修改进程组进程组长不能修改自己的PGID不能加入其他进程组。2.getpgid()获取进程的进程组IDpid_t getpgid(pid_t pid); // pid0返回当前进程的PGIDpid_t getpgrp(void); // 等价于getpgid(0)killpg()给整个进程组发送信号// 给pgid对应的进程组发送sig信号int killpg(pid_t pgid, int sig);等价于kill(-pgid, sig)给PGID为pgid的所有进程发送信号。9.2 会话的定义与内核实现会话Session是一组进程组的集合通常一个会话对应一个用户登录会话shell创建的所有进程组都属于同一个会话。会话的设计目的是把用户登录相关的所有进程组织在一起和控制终端绑定实现终端的登录、退出、作业控制。9.2.1 会话的核心特性会话IDSID每个会话有一个唯一的会话ID等于会话首进程的PID会话首进程创建会话的进程就是会话首进程首进程的PID等于SID控制终端一个会话最多只能有一个控制终端通常是用户登录的终端、ssh连接、伪终端前台进程组一个会话中只有一个前台进程组控制终端的输入、信号CtrlC、CtrlZ会发送给前台进程组后台进程组会话中除了前台进程组之外的所有进程组都是后台进程组无法读取控制终端的输入写入终端会被SIGTTOU信号暂停会话生命周期会话从首进程创建开始直到会话内最后一个进程退出。9.2.2 会话相关的系统调用1.setsid()创建一个新的会话pid_t setsid(void);核心作用创建一个新的会话当前进程成为新会话的首进程同时成为新进程组的组长新会话没有控制终端限制调用进程不能是进程组组长否则会调用失败这就是为什么创建守护进程时必须先fork子进程再在子进程中调用setsid()因为父进程是进程组组长无法创建新会话返回值成功返回新会话的SID失败返回-1。2.getsid()获取进程的会话IDpid_t getsid(pid_t pid); // pid0返回当前进程的SID9.3 控制终端与作业控制控制终端是会话和用户交互的接口每个会话可以绑定一个控制终端实现用户和进程之间的交互、信号发送、作业控制。我们平时使用的bash/zsh就是基于控制终端和作业控制实现的。9.3.1 控制终端的核心特性会话与控制终端的绑定一个会话最多绑定一个控制终端一个控制终端最多绑定一个会话终端断开信号当控制终端断开时比如ssh连接关闭、串口断开内核会给会话首进程发送SIGHUP信号默认行为是终止进程这就是为什么终端关闭后运行的程序会被杀掉前台进程组控制终端会把输入、信号发送给前台进程组shell执行命令时会把命令对应的进程组设置为前台进程组shell自己进入后台后台进程组限制后台进程组读取控制终端时会收到SIGTTIN信号默认暂停进程后台进程组写入控制终端时会收到SIGTTOU信号默认暂停进程避免后台进程干扰前台交互。9.3.2 作业控制的完整流程shell的作业控制本质就是对进程组、前台/后台的管理我们以bash执行命令为例拆解完整的作业控制流程场景1执行前台命令 ls -l | grep testbash解析命令创建管道fork两个子进程分别执行ls和grepbash把两个子进程加入同一个新的进程组PGID等于第一个子进程的PIDbash调用tcsetpgrp()把这个新进程组设置为控制终端的前台进程组bash自己的进程组变为后台进程组bash等待进程组中的所有进程退出用户按下CtrlC控制终端给前台进程组发送SIGINT信号ls和grep进程收到信号终止执行进程组中的所有进程退出后bash调用tcsetpgrp()把自己的进程组重新设置为前台进程组bash打印提示符等待用户输入下一个命令。场景2执行后台命令 sleep 100 bash解析命令fork子进程执行sleepbash把子进程加入新的进程组设置为后台进程组不改变终端的前台进程组bash不等待子进程退出直接打印作业号和PID继续等待用户输入后台进程组的sleep进程运行不会收到终端的信号也无法读取终端输入用户执行fg %1bash调用tcsetpgrp()把sleep的进程组设置为前台进程组等待进程退出用户按下CtrlZ控制终端给前台进程组发送SIGTSTP信号sleep进程暂停终端给bash发送SIGCHLD信号bash把sleep的作业标记为停止把自己重新设置为前台进程组用户执行bg %1bash给sleep的进程组发送SIGCONT信号sleep进程继续在后台运行。9.3.3 终端作业控制相关的系统调用tcgetpgrp()/tcsetpgrp()获取/设置控制终端的前台进程组// 获取终端fd对应的前台进程组PGIDpid_t tcgetpgrp(int fd);// 把终端fd的前台进程组设置为pgidint tcsetpgrp(int fd, pid_t pgid);fd必须是会话控制终端的文件描述符通常是0标准输入这是shell实现前台/后台作业切换的核心系统调用。tcgetsid()获取终端绑定的会话SIDpid_t tcgetsid(int fd);9.4 守护进程的正确实现规范守护进程Daemon是运行在后台的服务进程没有控制终端不受用户登录/退出的影响比如httpd、sshd、mysqld等都是守护进程。很多工程师写的守护进程出现各种异常根源是没有遵循正确的实现规范没有脱离控制终端和会话。9.4.1 守护进程的核心要求运行在后台没有控制终端不受终端断开的影响父进程是1号init进程不会产生僵尸进程有独立的会话和进程组不继承父进程的会话、进程组、控制终端关闭所有继承的文件描述符设置标准输入/输出/错误到/dev/null设置正确的工作目录通常是根目录/避免占用挂载的文件系统导致无法卸载设置正确的umask避免创建的文件权限不符合预期处理SIGCHLD信号回收子进程避免僵尸进程处理SIGHUP信号通常用于重新加载配置文件因为守护进程没有控制终端不会因为终端断开收到SIGHUP。9.4.2 守护进程的标准实现代码#include stdio.h #include stdlib.h #include unistd.h #include signal.h #include sys/stat.h #include fcntl.h #include sys/resource.h void daemonize(void) { pid_t pid; struct rlimit rl; int fd0, fd1, fd2; // 1. 第一步fork子进程父进程退出 // 目的子进程不是进程组组长为后续setsid()做准备父进程退出让shell认为命令执行完成 if ((pid fork()) 0) { perror(fork failed); exit(1); } else if (pid ! 0) { exit(0); // 父进程直接退出 } // 2. 第二步创建新的会话成为会话首进程脱离控制终端 // 目的创建新会话脱离父进程的会话和控制终端成为新会话的首进程 if (setsid() 0) { perror(setsid failed); exit(1); } // 3. 第三步再次fork父进程退出子进程不再是会话首进程 // 目的会话首进程可以重新打开控制终端再次fork后子进程不是会话首进程永远无法打开控制终端彻底脱离终端 if ((pid fork()) 0) { perror(fork failed); exit(1); } else if (pid ! 0) { exit(0); // 第一个子进程退出孙子进程继续执行 } // 4. 第四步设置工作目录为根目录 // 目的避免占用挂载的文件系统导致无法卸载 if (chdir(/) 0) { perror(chdir failed); exit(1); } // 5. 第五步设置umask为0清除继承的文件权限掩码 // 目的守护进程创建文件时权限完全可控不受父进程umask的影响 umask(0); // 6. 第六步关闭所有继承的文件描述符 // 目的关闭从父进程继承的所有文件描述符包括控制终端的文件描述符 if (getrlimit(RLIMIT_NOFILE, rl) 0) { perror(getrlimit failed); exit(1); } if (rl.rlim_max RLIM_INFINITY) { rl.rlim_max 1024; } for (int i 0; i rl.rlim_max; i) { close(i); } // 7. 第七步把标准输入、输出、错误重定向到/dev/null // 目的避免程序中读写标准输入输出导致异常所有输出都丢弃 fd0 open(/dev/null, O_RDWR); fd1 dup(0); fd2 dup(0); // 检查重定向是否成功 if (fd0 ! 0 || fd1 ! 1 || fd2 ! 2) { exit(1); } // 8. 第八步处理SIGCHLD信号回收子进程避免僵尸进程 signal(SIGCHLD, SIG_IGN); } // 守护进程的主体函数 void daemon_main(void) { // 守护进程的业务逻辑 while (1) { // 业务代码 sleep(10); } } int main(int argc, char *argv[]) { // 守护进程化 daemonize(); // 执行守护进程主体 daemon_main(); return 0; }9.4.3 关键步骤的深度解释1.为什么要fork两次第一次fork让子进程不是进程组组长才能调用setsid()创建新会话父进程退出让shell认为命令执行完成回到提示符。第二次fork会话首进程可以通过打开终端设备重新获取控制终端再次fork后子进程不再是会话首进程永远无法获取控制终端彻底脱离终端这是守护进程最关键的一步很多工程师只fork一次导致守护进程依然可能重新绑定控制终端出现异常。2.为什么要设置工作目录为根目录如果守护进程的工作目录是一个挂载的文件系统比如/mnt/data那么这个文件系统无法被卸载因为守护进程的工作目录在这个分区上会一直占用。设置为根目录可以避免这个问题。3.为什么要关闭所有文件描述符子进程会继承父进程打开的所有文件描述符包括控制终端的文件描述符如果不关闭守护进程依然可以通过这些文件描述符和终端交互无法彻底脱离终端。4.为什么要重定向标准输入输出到/dev/null守护进程没有控制终端标准输入输出没有对应的设备程序中如果调用printf、scanf等函数会导致异常重定向到/dev/null可以避免这个问题所有的输入输出都被丢弃。9.5 工程实践与避坑指南1.终端关闭后程序被杀掉的问题很多工程师在终端中运行程序关闭终端后程序就被杀掉了根源是终端关闭时内核会给会话首进程通常是shell发送SIGHUP信号shell退出时会给会话内的所有进程发送SIGHUP信号默认行为是终止进程。解决方案用nohup启动程序nohup ./your_program nohup会忽略SIGHUP信号把标准输出重定向到nohup.out用setsid启动程序setsid ./your_program创建新的会话脱离当前终端终端关闭不会收到SIGHUP信号程序中注册SIGHUP信号处理函数忽略SIGHUP信号按照标准规范实现守护进程彻底脱离控制终端。2.后台进程读取终端输入被暂停的问题后台进程组的进程尝试读取控制终端时会收到SIGTTIN信号默认暂停进程这是内核的作业控制机制避免后台进程干扰前台交互。解决方案后台进程不要读取标准输入或者把标准输入重定向到/dev/null用fg命令把后台进程切换到前台再读取输入程序中设置SIGTTIN信号的处理函数忽略该信号但是读取依然会失败返回错误。3.守护进程的日志输出守护进程没有控制终端标准输出被重定向到/dev/null不能用printf输出日志必须使用系统日志服务syslog或者自己实现日志文件输出。最佳实践使用syslog记录守护进程的日志syslog是Linux系统的标准日志服务支持日志分级、轮转、远程传输// 打开syslog设置程序名、选项、设施openlog(mydaemon, LOG_PID | LOG_CONS, LOG_DAEMON);// 输出日志syslog(LOG_INFO, daemon started successfully);syslog(LOG_ERR, failed to open file: %s, strerror(errno));// 关闭syslogcloselog();4.禁止在守护进程中调用system()函数system()函数会fork子进程执行shell命令而守护进程已经关闭了标准输入输出脱离了控制终端system()调用会出现各种异常甚至导致程序挂起。最佳实践用forkexecve直接执行程序不要通过shell避免不必要的异常。5.守护进程的单实例运行很多守护进程只能运行一个实例比如sshd、mysqld需要实现单实例锁机制避免同时启动多个实例。最佳实践用文件锁实现单实例运行程序启动时给/var/run/mydaemon.pid文件加排他锁如果加锁失败说明已经有实例在运行直接退出int lockfile(int fd) { struct flock fl; fl.l_type F_WRLCK; fl.l_start 0; fl.l_whence SEEK_SET; fl.l_len 0; return fcntl(fd, F_SETLK, fl); } int single_instance_running(void) { int fd open(/var/run/mydaemon.pid, O_RDWR | O_CREAT, 0644); if (fd 0) { return -1; } if (lockfile(fd) 0) { close(fd); return -1; // 加锁失败已有实例运行 } // 把当前PID写入文件 ftruncate(fd, 0); char buf[32]; sprintf(buf, %d\n, getpid()); write(fd, buf, strlen(buf)); return 0; }