欢迎光临
我们一直在努力

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

9. 进程组、会话与终端控制

进程组、会话与终端控制是Linux进程管理中最容易被忽略的部分,却是shell作业控制、守护进程、终端交互的底层核心。很多工程师写的后台服务、守护进程出现异常退出、终端关闭后程序被杀、信号无法正确处理等问题,根源都是没有理解进程组、会话与控制终端的底层机制。

本章节完整拆解进程组、会话、控制终端的内核实现、作业控制机制、相关系统调用,以及守护进程的正确实现规范,解决工程中常见的终端相关问题。

9.1 进程组的定义与内核实现

进程组(Process Group)是一组相关进程的集合,它的设计目的是为了方便对一组进程进行统一的信号发送和管理。最典型的场景就是shell执行管道命令时,会把管道中的所有进程放到同一个进程组中,按下Ctrl+C时,shell会给整个进程组发送SIGINT信号,终止管道中的所有进程。

9.1.1 进程组的核心特性

  • 进程组ID(PGID):每个进程组有一个唯一的进程组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

    // 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 会话的核心特性

  • 会话ID(SID):每个会话有一个唯一的会话ID,等于会话首进程的PID;
  • 会话首进程:创建会话的进程就是会话首进程,首进程的PID等于SID;
  • 控制终端:一个会话最多只能有一个控制终端,通常是用户登录的终端、ssh连接、伪终端;
  • 前台进程组:一个会话中,只有一个前台进程组,控制终端的输入、信号(Ctrl+C、Ctrl+Z)会发送给前台进程组;
  • 后台进程组:会话中除了前台进程组之外的所有进程组,都是后台进程组,无法读取控制终端的输入,写入终端会被SIGTTOU信号暂停;
  • 会话生命周期:会话从首进程创建开始,直到会话内最后一个进程退出。
  • 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 控制终端的核心特性

  • 会话与控制终端的绑定:一个会话最多绑定一个控制终端,一个控制终端最多绑定一个会话;
  • 终端断开信号:当控制终端断开时(比如ssh连接关闭、串口断开),内核会给会话首进程发送SIGHUP信号,默认行为是终止进程,这就是为什么终端关闭后,运行的程序会被杀掉;
  • 前台进程组:控制终端会把输入、信号发送给前台进程组,shell执行命令时,会把命令对应的进程组设置为前台进程组,shell自己进入后台;
  • 后台进程组限制:后台进程组读取控制终端时,会收到SIGTTIN信号,默认暂停进程;后台进程组写入控制终端时,会收到SIGTTOU信号,默认暂停进程,避免后台进程干扰前台交互。
  • 9.3.2 作业控制的完整流程

    shell的作业控制,本质就是对进程组、前台/后台的管理,我们以bash执行命令为例,拆解完整的作业控制流程:

    场景1:执行前台命令 ls -l | grep test

  • bash解析命令,创建管道,fork两个子进程,分别执行ls和grep;
  • bash把两个子进程加入同一个新的进程组,PGID等于第一个子进程的PID;
  • bash调用tcsetpgrp(),把这个新进程组设置为控制终端的前台进程组,bash自己的进程组变为后台进程组;
  • bash等待进程组中的所有进程退出;
  • 用户按下Ctrl+C,控制终端给前台进程组发送SIGINT信号,ls和grep进程收到信号,终止执行;
  • 进程组中的所有进程退出后,bash调用tcsetpgrp(),把自己的进程组重新设置为前台进程组;
  • bash打印提示符,等待用户输入下一个命令。
  • 场景2:执行后台命令 sleep 100 &

  • bash解析命令,fork子进程执行sleep;
  • bash把子进程加入新的进程组,设置为后台进程组,不改变终端的前台进程组;
  • bash不等待子进程退出,直接打印作业号和PID,继续等待用户输入;
  • 后台进程组的sleep进程运行,不会收到终端的信号,也无法读取终端输入;
  • 用户执行fg %1,bash调用tcsetpgrp(),把sleep的进程组设置为前台进程组,等待进程退出;
  • 用户按下Ctrl+Z,控制终端给前台进程组发送SIGTSTP信号,sleep进程暂停;
  • 终端给bash发送SIGCHLD信号,bash把sleep的作业标记为停止,把自己重新设置为前台进程组;
  • 用户执行bg %1,bash给sleep的进程组发送SIGCONT信号,sleep进程继续在后台运行。
  • 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 守护进程的核心要求

  • 运行在后台,没有控制终端,不受终端断开的影响;
  • 父进程是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));

    // 关闭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;
    }

    赞(0)
    未经允许不得转载:171主机测评 » Linux内核学习轨迹第四部: 进程组、会话与终端控制(第七小节)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址