9. 进程组、会话与终端控制
进程组、会话与终端控制是Linux进程管理中最容易被忽略的部分,却是shell作业控制、守护进程、终端交互的底层核心。很多工程师写的后台服务、守护进程出现异常退出、终端关闭后程序被杀、信号无法正确处理等问题,根源都是没有理解进程组、会话与控制终端的底层机制。
本章节完整拆解进程组、会话、控制终端的内核实现、作业控制机制、相关系统调用,以及守护进程的正确实现规范,解决工程中常见的终端相关问题。
9.1 进程组的定义与内核实现
进程组(Process Group)是一组相关进程的集合,它的设计目的是为了方便对一组进程进行统一的信号发送和管理。最典型的场景就是shell执行管道命令时,会把管道中的所有进程放到同一个进程组中,按下Ctrl+C时,shell会给整个进程组发送SIGINT信号,终止管道中的所有进程。
9.1.1 进程组的核心特性
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
// pid=0表示当前进程,pgid=0表示用pid作为PGID,创建新的进程组
int setpgid(pid_t pid, pid_t pgid);
典型用法:创建子进程后,在父子进程中都调用setpgid(child_pid, child_pid),把子进程设置为新进程组的组长,避免竞态问题;
限制:只能把进程加入到同一会话内的进程组,不能跨会话修改进程组;进程组长不能修改自己的PGID,不能加入其他进程组。
2.getpgid():获取进程的进程组ID
pid_t getpgid(pid_t pid); // pid=0返回当前进程的PGID
pid_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 会话的核心特性
9.2.2 会话相关的系统调用
1.setsid():创建一个新的会话
pid_t setsid(void);
核心作用:创建一个新的会话,当前进程成为新会话的首进程,同时成为新进程组的组长,新会话没有控制终端;
限制:调用进程不能是进程组组长,否则会调用失败,这就是为什么创建守护进程时,必须先fork子进程,再在子进程中调用setsid(),因为父进程是进程组组长,无法创建新会话;
返回值:成功返回新会话的SID,失败返回-1。
2.getsid():获取进程的会话ID
pid_t getsid(pid_t pid); // pid=0返回当前进程的SID
9.3 控制终端与作业控制
控制终端是会话和用户交互的接口,每个会话可以绑定一个控制终端,实现用户和进程之间的交互、信号发送、作业控制。我们平时使用的bash/zsh,就是基于控制终端和作业控制实现的。
9.3.1 控制终端的核心特性
9.3.2 作业控制的完整流程
shell的作业控制,本质就是对进程组、前台/后台的管理,我们以bash执行命令为例,拆解完整的作业控制流程:
场景1:执行前台命令 ls -l | grep test
场景2:执行后台命令 sleep 100 &
9.3.3 终端作业控制相关的系统调用
tcgetpgrp()/tcsetpgrp():获取/设置控制终端的前台进程组
// 获取终端fd对应的前台进程组PGID
pid_t tcgetpgrp(int fd);
// 把终端fd的前台进程组设置为pgid
int tcsetpgrp(int fd, pid_t pgid);
fd必须是会话控制终端的文件描述符,通常是0(标准输入);
这是shell实现前台/后台作业切换的核心系统调用。
tcgetsid():获取终端绑定的会话SID
pid_t tcgetsid(int fd);
9.4 守护进程的正确实现规范
守护进程(Daemon)是运行在后台的服务进程,没有控制终端,不受用户登录/退出的影响,比如httpd、sshd、mysqld等都是守护进程。很多工程师写的守护进程出现各种异常,根源是没有遵循正确的实现规范,没有脱离控制终端和会话。
9.4.1 守护进程的核心要求
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两次?
2.为什么要设置工作目录为根目录?
如果守护进程的工作目录是一个挂载的文件系统,比如/mnt/data,那么这个文件系统无法被卸载,因为守护进程的工作目录在这个分区上,会一直占用。设置为根目录可以避免这个问题。
3.为什么要关闭所有文件描述符?
子进程会继承父进程打开的所有文件描述符,包括控制终端的文件描述符,如果不关闭,守护进程依然可以通过这些文件描述符和终端交互,无法彻底脱离终端。
4.为什么要重定向标准输入输出到/dev/null?
守护进程没有控制终端,标准输入输出没有对应的设备,程序中如果调用printf、scanf等函数,会导致异常,重定向到/dev/null可以避免这个问题,所有的输入输出都被丢弃。
9.5 工程实践与避坑指南
1.终端关闭后程序被杀掉的问题
很多工程师在终端中运行程序,关闭终端后程序就被杀掉了,根源是:终端关闭时,内核会给会话首进程(通常是shell)发送SIGHUP信号,shell退出时,会给会话内的所有进程发送SIGHUP信号,默认行为是终止进程。
解决方案:
2.后台进程读取终端输入被暂停的问题
后台进程组的进程尝试读取控制终端时,会收到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));
// 关闭syslog
closelog();
4.禁止在守护进程中调用system()函数
system()函数会fork子进程执行shell命令,而守护进程已经关闭了标准输入输出,脱离了控制终端,system()调用会出现各种异常,甚至导致程序挂起。
最佳实践:用fork+execve直接执行程序,不要通过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;
}





