欢迎光临
我们一直在努力

【Linux网络加餐(二)】《进程组,会话,控制终端,作业控制和守护进程》---详解

一.进程组

1.什么是进程组

之前我们提到了进程的概念, 其实每⼀个进程除了有⼀个进程ID(PID)之外 还属于⼀个进程组

进程组是⼀个或者多个进程的集合, ⼀个进程组可以包含多个进程。 每⼀个进程组也有⼀个唯⼀的进程组ID(PGID), 并且这个PGID类似于进程ID, 同样是⼀个正整数, 可以存放在pid_t数据类型中。

我们来验证看一下:

sleep 100000 | sleep 200000 | sleep 300000

管道 | 的含义:把前一个命令输出交给下一个命令;管道会创建子进程,每个 sleep 都是独立进程管道 sleep a | sleep b | sleep c:同时启动 3 个 sleep,并行运行,不是挨个等。。

  • PPID 全部是 3940927:这个是你当前 shell(bash),三个 sleep 全都是 shell 直接创建的子进程。
  • PGID 进程组 ID 统一等于第一个 sleep 的 PID (3969646)
  • shell 执行管道命令时,会把整条管道所有进程放到同一个进程组,管道第一个进程 PID 作为整个组的 PGID(组长)。
  • 写个代码验证一下:


    看指令:

    ps -eo pid,pgid,ppid,comm | grep process_task
    # -e 选项表⽰every的意思, 表⽰输出每⼀个进程信息
    # -o 选项以逗号操作符(,)作为定界符, 可以指定要输出的列

    用处:专门看某个进程的:自己 PID、进程组 ID、父进程 ID,快速判断谁是进程组组长(PID==PGID 就是组长)。

    2.组⻓进程

    每⼀个进程组都有⼀个组⻓进程。 组⻓进程的ID等于其进程ID。我们可以通过ps命令看到组⻓进程的现象:

    ps -o pid,pgid,ppid,comm | cat

  • 只输出 4 列:PID、PGID、PPID、程序名 comm;不加 -e,只显示当前终端的进程(不是系统全部进程)。
  • | cat 管道,把 ps 输出交给 cat;cat 就是原样打印输入。
  • 从结果上看ps进程的PID和PGID相同, 那也就是说明ps进程是该进程组的组⻓进程, 该进程组包括ps和cat两个进程。

    • 进程组组⻓的作⽤: 进程组组⻓可以创建⼀个进程组或者创建该组中的进程
    • 进程组的⽣命周期: 从进程组创建开始到其中最后⼀个进程离开为⽌。

    注意: 主要某个进程组中有⼀个进程存在, 则该进程组就存在, 这与其组⻓进程是否已经终⽌⽆关。


    二.会话

    1.什么是会话

    刚刚我们谈到了进程组的概念, 那么会话⼜是什么呢? 会话其实和进程组息息相关, 会话可以看成是⼀个或多个进程组的集合, ⼀个会话可以包含多个进程组。每⼀个会话也有⼀个会话ID(SID)

    通常我们都是使⽤管道将⼏个进程编成⼀个进程组。 如上图的进程组2和进程组3可能是由下列命令形成的:

    [node@localhost code]$ proc2 | proc3 &
    [node@localhost code]$ proc4 | proc5 | proc6 &
    # &表⽰将进程组放在后台执⾏

    举个例子看指令:

    # ⽤管道和sleep组成⼀个进程组放在后台运⾏
    [node@localhost code]$ sleep 100 | sleep 200 | sleep 300 &
    # 查看ps命令打出来的列描述信息
    [node@localhost code]$ ps axj | head -n1
    # 过滤sleep相关的进程信息
    [node@localhost code]$ ps axj | grep sleep | grep -v grep
    # a选项表⽰不仅列当前⽤⼾的进程,也列出所有其他⽤⼾的进程
    # x选项表⽰不仅列有控制终端的进程,也列出所有⽆控制终端的进程
    # j选项表⽰列出与作业控制相关的信息, 作业控制后续会讲
    # grep的-v选项表⽰反向过滤, 即不过滤带有grep字段相关的进程

    sleep 100 | sleep 200 | sleep 300 & 整条管道,sleep 100 是进程组组长,三个 sleep 属于同一个进程组,&放到后台。

    ps axj | head -n1 打印 ps axj 的表头,能看到 PID PGID SID这些列。

    ps axj | grep sleep | grep -v grep 过滤出 sleep 进程;grep -v grep把 grep 自身进程过滤掉,只看 sleep。

    看结果:

    三个进程 PGID 全部等于 sleep 100 的 PID (4002066),证明整条管道所有进程属于同一个进程组,组长是管道第一个命令


    2. 如何创建会话

    可以调⽤setseid函数来创建⼀个会话, 前提是调⽤进程不能是⼀个进程组的组⻓。

    #include <unistd.h>
    /*
    *功能:创建会话
    *返回值:创建成功返回SID, 失败返回-1
    */
    pid_t setsid(void);

    该接⼝调⽤之后会发⽣:

    •  调⽤进程会变成新会话的会话⾸进程。 此时, 新会话中只有唯⼀的⼀个进程
    •  调⽤进程会变成进程组组⻓。 新进程组ID就是当前调⽤进程ID
    •  该进程没有控制终端。 如果在调⽤setsid之前该进程存在控制终端, 则调⽤之后会切断联系

    需要注意的是: 这个接⼝如果调⽤进程原来是进程组组⻓, 则会报错, 为了避免这种情况, 我们通常的使⽤⽅法是先调⽤fork创建⼦进程, ⽗进程终⽌, ⼦进程继续执⾏, 因为⼦进程会继承⽗进程的进程组ID, ⽽进程ID则是新分配的, 就不会出现错误的情况。

    • PID = PGID = SID = 4004044 子进程调用setsid()之后,自身成为新会话的会话首进程,同时也是新进程组的组长进程。新会话内目前仅有这一个进程。
    • TTY 列为 ? 进程已经脱离原来的控制终端,不再和启动它的终端绑定。此时即使关闭原来的终端窗口,该子进程依然可以继续在后台运行。
    • PPID 为 1 父进程执行完毕退出,子进程被 systemd (init 进程) 收养。

    3.会话ID(SID)

    会话ID(Session ID)是一个用于标识进程所属会话的标识符。简单来说:

  • 会话首进程:每个会话都有一个领头进程,称为"会话首进程"(Session Leader)。它是一个普通进程,拥有唯一的进程ID(PID)。

  • 会话ID的定义:会话ID就是会话首进程的进程ID。因为会话首进程的PID在整个系统中是唯一的,所以用它来作为整个会话的标识符非常合适。

  • 与进程组ID的关系:在某些资料中,会话ID也被称为"会话首进程的进程组ID"。这是因为:

    • 会话首进程同时也是它所在进程组的组长(Process Group Leader)
    • 进程组组长的PID = 该进程组的PGID(进程组ID)
    • 因此,会话首进程的PID = 会话首进程的PGID = 会话ID
    • 所以两者在数值上是等价的

    速记:

    会话ID = 会话首进程的PID = 会话首进程的PGID


    三.控制终端

    1.什么是控制终端

    在 Unix/Linux 系统中,控制终端是与一个会话相关联的终端设备。当用户通过终端(物理终端、SSH 连接或伪终端)登录系统后,系统会为该登录会话分配一个终端设备,这个终端就成为该会话的控制终端。

    控制终端的信息存储在每个进程的 PCB(进程控制块,即内核中的 task_struct)中,具体位于 signal->tty 字段,指向对应的终端设备结构体。由于 fork() 会复制父进程的 PCB,因此由 Shell 启动的所有子进程都会继承相同的控制终端。

    控制终端的作用:

    • 标准 I/O 关联:默认情况下(没有重定向),进程的标准输入(stdin)、标准输出(stdout)和标准错误(stderr)都指向控制终端。这意味着程序从键盘读取输入,将输出显示在显示器上。
    • 进程组分类:在一个拥有控制终端的会话中,进程组被划分为:

            前台进程组(Foreground Process Group):唯一拥有终端控制权,可以读写终端;

            后台进程组(Background Process Group):不能读取终端(否则收到 SIGTTIN),写入时可能受影响。

    • 信号转发:

            按下 Ctrl+C(中断键)或 Ctrl+\\(退出键)时,终端驱动程序会向前台进程组中的所有进程发送 SIGINT 或 SIGQUIT 信号;

            当终端连接异常断开(如 SSH 断开、网络中断)时,内核会向控制进程(会话首进程)发送 SIGHUP 信号,该信号默认会终止进程,并进一步传递给整个会话。

    这些特性的关系如下图所⽰:


    四.作业控制

    1. 什么是作业(job)和作业控制(Job Control)?

    作业是针对用户来讲,⽤⼾完成某项任务⽽启动的进程,⼀个作业既可以只包含⼀个进程,也可以
    包含多个进程,进程之间互相协作完成任务, 通常是⼀个进程管道。

    Shell分前后台来控制的不是进程⽽是作业 或者进程组。⼀个前台作业可以由多个进程组成,⼀个
    后台作业也可以由多个进程组成,Shell可以同时运⾏⼀个前台作业和任意多个后台作业,这称为
    作业控制

    作业控制的实操:

    fg/bg 与信号控制(核心考点)

    操作

    信号

    效果

    作业状态

    能否恢复

    fg 作业号

    后台作业切到前台

    前台进程组

    Ctrl+C

    SIGINT

    终止整个前台进程组

    作业被删除

    不能恢复

    Ctrl+Z

    SIGTSTP

    暂停整个前台进程组

    保留,状态 Stopped

    ✅可 bg/fg 恢复

    bg 作业号

    SIGCONT

    暂停作业转到后台继续

    恢复 Running

    kill -SIGINT %作业号

    SIGINT

    复现 Ctrl+C 效果

    作业销毁

    不能恢复

    命令对照速查

    命令含义
    jobs 查看后台作业列表
    jobs -l 查看作业 + PID
    fg %2 作业 2 切前台
    bg %2 作业 2 放后台运行
    Ctrl+C 终止整个前台进程组
    Ctrl+Z 暂停整个前台进程组

    2. 作业号

    放在后台执⾏的程序或命令称为后台命令,可以在命令的后⾯加上 & 符号从⽽让Shell识别这是⼀
    个后台命令,后台命令不⽤等待该命令执⾏完成,就可⽴即接收新的命令,另外后台进程执⾏完后
    返回⼀个作业号以及⼀个进程号(PID)


    3.作业状态

    常⻅的作业状态如下表所⽰:


    4.作业的挂起与切回

    (1) 作业挂起

    我们在执⾏某个作业时,可以通过 Ctrl+Z 键将该作业挂起,然后Shell会显⽰相关的作业号、状
    态以及所执⾏的命令信息。
    例如我们运⾏⼀个死循环的程序, 通过 Ctrl+Z 将该作业挂起, 观察⼀下对应的作业状态:

    下⾯我运⾏这个程序, 通过 Ctrl+Z 将该作业挂起:

    可以发现通过 Ctrl+Z 将作业挂起, 该作业状态已经变为了停⽌状态。

    (2) 作业切回

    如果想将挂起的作业切回,可以通过 fg 命令, fg 后⾯可以跟 作业号 或 作业的命令名称 。如
    果参数缺省则会默认将作业号为1的作业切到前台来执⾏,若当前系统只有⼀个作业在后台进⾏,
    则可以直接使⽤fg命令不带参数直接切回。 具体的参数参考如下:

    如我们把刚刚挂起来的 ./process_task 作业切回到前台:

    运⾏结果为开始⽆限循环打印 hello , 可以发现该作业已经切换到前台了。
    注意:当通过 fg 命令切回作业时,若没有指定作业参数,此时会将默认作业切到前台执⾏,即带
    有“+”的作业号的作业


    5.查看后台执⾏或挂起的作业

    我们可以直接通过输⼊ jobs 命令查看本⽤⼾当前后台执⾏或挂起的作业

    •  参数 -l 则显⽰作业的详细信息
    •  参数 -p 则只显⽰作业的PID

    例如, 我们先在后台及前台运⾏两个作业, 并将前台作业挂起, 来⽤ jobs 命令查看作业相关的
    信息:

    # 在后台运⾏⼀个作业sleep
    [node@localhost code]$ sleep 300 &
    # 运⾏刚才的死循环可执⾏程序
    [node@localhost code]$ ./test
    # 键⼊Ctrl + Z 挂起作业
    # 使⽤jobs命令查看后台及挂起的作业
    [node@localhost code]$ jobs -l

    运⾏结果如下所⽰:

    6. 作业控制相关的信号

    ⾯我们提到了键⼊ Ctrl + Z 可以将前台作业挂起,实际上是将 STGTSTP 信号发送⾄前台进
    程组作业中的所有进程, 后台进程组中的作业不受影响。 在unix系统中, 存在3个特殊字符可以使得终端驱动程序产⽣信号, 并将信号发送⾄前台进程组作业, 它们分别是:

    • Ctrl + C : 中断字符, 会产⽣ SIGINT 信号
    •  Ctrl + \\ : 退出字符, 会产⽣ SIGQUIT 信号
    •  Ctrl + Z :挂起字符, 会产⽣ STGTSTP 信号

    终端的I/O(即标准输⼊和标准输出)和终端产⽣的信号总是从前台进程组作业连接打破实际终端。我
    们可以通过下体来看到作业控制的功能:

    这是 Linux 终端、会话、前台 / 后台进程组、信号的整体工作流程图。

  • 顶层:init系统进程 –> getty –> login –> 登录 shell (bash) 用户登录后,shell 就是这个会话的会话首进程,绑定控制终端。
  • shell 通过setpgid()创建两类进程组
    • 前台进程组:当前正在占用终端的一组进程
      • 键盘输入输出直接给到它
      • Ctrl+C / Ctrl+Z产生的信号,发给整个前台进程组
    • 后台进程组:后台作业(&启动)
      • 不能直接读终端输入,读终端会收到SIGTTIN被暂停;写终端会收到SIGTTOU。
  • 终端驱动是中间枢纽
    • tcsetpgrp():shell 调用这个系统调用,切换哪个进程组是前台(就是fg/bg命令底层做的事)。
    • 用户键盘操作交给终端驱动,再把信号转发给前台进程组全体进程。
    • 后台进程组想读终端,终端驱动抛出SIGTTIN,进程被暂停。
  • shell 监控子进程状态变化,维护 jobs 作业列表。
  • 总结登:录 shell 管理同一个终端下的前台进程组、后台进程组;终端的输入、键盘信号只作用于前台进程组;后台进程组访问终端会被信号暂停;fg/bg本质就是调用tcsetpgrp切换前台进程组


    五. 守护进程

    通俗讲:Linux 后台长期运行的服务进程,脱离终端,关闭 ssh / 登录窗口也不会死掉,开机可以自启。

    核心特点

  • 脱离控制终端:没有终端,ps看 TTY 那一列显示?,关闭终端、退出登录不会被杀死。
  • 后台静默运行,不和用户交互,专门做服务(比如 nginx、mysql、sshd 都是守护进程)。
  • 会话、进程组独立,不受 shell 作业控制影响,jobs命令看不到它。
  • 这里面的重点:setsid () 是核心,调用后进程变成新会话首进程,脱离终端,TTY 变成?。

    在前面我们也说过这个函数,话不多说来看到代码:

    Daemon.hpp

    #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,是否当前进程的CWD更改为/根目录
    if(ischdir)
    {
    chdir(root);
    }

    //5.已经变成守护进程,不需要用户的输入和输出,错误进行关联了
    if(isclose)
    {
    ::close(0);
    ::close(1);
    ::close(2);
    }
    else
    {
    //这里一般建议两种
    int fd = open(dev_null,O_RDWR);
    if(fd>0)
    {
    dup2(fd,0);
    dup2(fd,1);
    dup2(fd,2);
    close2(fd);
    }
    }

    }

    process_task.cc

    #include <iostream>
    #include <string>
    #include <unistd.h>
    #include <fcntl.h>
    #include <sys/stat.h>
    #include <sys/types.h>
    #include <signal.h>
    #include <cstdlib>
    #include"Daemon.hpp"

    int main()
    {
    Daemon(false, false);

    // 打开日志文件
    int log_fd = open("/tmp/mydaemon.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (log_fd < 0) {
    exit(1);
    }

    // 主循环:每10秒写一次日志
    while (true) {
    std::string msg = "进程 " + std::to_string(getpid()) + " 还活着\\n";
    write(log_fd, msg.c_str(), msg.size());
    sleep(10);
    }

    close(log_fd);
    return 0;
    }

    Daemon() 函数的目标是:将当前进程变成一个守护进程(Daemon)。

    第1步:忽略信号

    signal(SIGCHLD, SIG_IGN);
    signal(SIGPIPE, SIG_IGN);

    SIGCHLD 信号

    • 产生时机:当子进程终止、暂停或恢复时,内核会向父进程发送 SIGCHLD 信号。

    • 忽略的原因:守护进程通常会创建子进程来处理任务(比如 Web 服务器接收新连接后 fork 子进程处理)。如果不处理 SIGCHLD,子进程退出后会变成僵尸进程(Zombie),占用进程表项。

    • SIG_IGN 的作用:告诉内核"我对这个信号不感兴趣,子进程退出时自动回收资源,不需要通知我"。这是一种简化的处理方式,避免写复杂的信号处理函数。

    SIGPIPE 信号

    • 产生时机:当进程向一个已经关闭的 socket 管道写入数据时,内核会发送 SIGPIPE。

    • 忽略的原因:守护进程经常涉及网络通信(比如 HTTP 服务)。如果客户端突然断开连接,服务端继续写入就会触发 SIGPIPE,默认行为是终止整个进程。忽略后,写入操作会返回 -1 并设置 errno 为 EPIPE,由程序自己处理错误,而不是崩溃退出。

    为什么要在守护进程里忽略这两个信号?
    守护进程设计原则之一就是稳定可靠,不能因为网络异常或子进程退出而意外终止。


    第2步:创建子进程,父进程退出

    if (fork() > 0) {
    exit(0);
    }

    为什么要 fork 一次?

    在 Unix/Linux 中,进程组组长(Process Group Leader)不能调用 setsid() 创建新会话。如果直接让一个组长进程调用 setsid(),会返回 -1 失败。

    操作说明
    fork() 创建子进程,子进程的 PPID(父进程 ID)是原来的父进程
    父进程 exit(0) 父进程立即退出,子进程变成孤儿进程,被 init(PID=1)收养
    子进程继续执行 子进程的 PGID 和 SID 仍然是父进程的值,但它不再是组长,因为它是新 fork 出来的,PGID 不等于自己的 PID

    此时的状态:

    • 子进程 PID ≠ PGID(不是组长)

    • 子进程可以安全调用 setsid()


    第3步:创建新会话

    setsid();

    setsid() 做了什么?这是最核心的一步,调用后会发生三件事:

    效果说明
    创建新会话 当前进程成为该会话的会话首进程(Session Leader),会话 ID = 当前进程的 PID
    创建新进程组 当前进程成为该进程组的组长(Group Leader),进程组 ID = 当前进程的 PID
    脱离原控制终端 进程不再有任何控制终端,即使它之前有(比如继承自 Shell),也会被切断

    此时的状态

    PID = PGID = SID = 当前进程的PID
    当前进程既是进程组组长,也是会话首进程
    没有控制终端


    第4步:切换工作目录

    if (ischdir) {
    chdir(root); // root = "/"
    }

    切换到 / 根目录的好处

    根目录永远是稳定的,不会因为挂载/卸载而变化

    补充: 这是一个可选操作,不是所有守护进程都必须切到 /。有些守护进程需要特定工作目录,比如日志服务可能需要 /var/log。


    第5步:处理标准输入/输出/错误

    if (isclose) {
    ::close(0);
    ::close(1);
    ::close(2);
    } else {
    int fd = open(dev_null, O_RDWR);
    if (fd > 0) {
    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);
    close(fd);
    }
    }

    :: 前缀 = 我要用全局的那个函数,别用别的同名函数替代我
    相当于给编译器一个"强制指定",保证你用的是系统提供的 close 函数

    简单记忆:

    直接关闭 ,可能被复用 容易出 bug重定向到 /dev/null , 始终安全是标准做法。

    为什么要处理这三个文件描述符?

    一句话:守护进程在后台运行,没终端可交互。不管的话,万一 printf 或报错写到不存在的终端上,程序会崩。


    两种处理方式的核心区别

    方式本质最大风险
    直接关闭 close(0);close(1);close(2); 释放掉 0、1、2 后面 open() 或 socket() 会重复利用这三个号,本来以为是标准I/O的代码可能误操作到真正的文件/网络,造成混乱
    重定向到 /dev/null 0、1、2 始终有效,但指向"黑洞" 几乎没有风险,写入被丢弃,读取返回 EOF

    重定向 = 改变数据流向:本来数据要去 A 地方,现在让它去 B 地方。

    ┌─────────────────────────────────────────────────────────┐
    │ 原始进程(可能是 Shell 启动的,有控制终端) │
    └─────────────────────────────────────────────────────────┘


    ┌─────────────────────────────────────────────────────────┐
    │ 1. 忽略 SIGCHLD 和 SIGPIPE │
    │ 防止子进程变僵尸、防止管道异常终止进程 │
    └─────────────────────────────────────────────────────────┘


    ┌─────────────────────────────────────────────────────────┐
    │ 2. fork() 创建子进程,父进程退出 │
    │ 子进程不再是组长,可以调用 setsid() │
    └─────────────────────────────────────────────────────────┘


    ┌─────────────────────────────────────────────────────────┐
    │ 3. setsid() 创建新会话 │
    │ 成为会话首进程 │
    │ 成为进程组组长 │
    │ 脱离原控制终端 │
    └─────────────────────────────────────────────────────────┘


    ┌─────────────────────────────────────────────────────────┐
    │ 4. chdir("/") 切换工作目录 │
    │ 避免文件系统无法卸载、路径失效等问题 │
    └─────────────────────────────────────────────────────────┘


    ┌─────────────────────────────────────────────────────────┐
    │ 5. 关闭/重定向 标准输入/输出/错误 │
    │ 守护进程不需要终端交互 │
    └─────────────────────────────────────────────────────────┘


    成为合格的守护进程,开始执行主逻辑

    Daemon(true, false);
    // ↑ ↑
    // | └── 第2个参数:isclose = false(不关闭,重定向到 /dev/null)
    // └───────── 第1个参数:ischdir = true(切换到根目录 /)

    如果把这个改一下改为true呢:

    注意这里要重新杀一下进程


    六. 如何将服务守护进程化

    // ./server port

    int main(int argc, char *argv[])
    {
    // 1. 检查命令行参数
    if (argc != 2) {
    std::cout << "Usage : " << argv[0] << " port" << std::endl;
    return 0;
    }

    // 2. 解析端口号
    uint16_t localport = std::stoi(argv[1]);

    // 3. 变成守护进程(不切目录,重定向到 /dev/null)
    Daemon(false, false);

    // 4. 创建 TCP 服务器
    std::unique_ptr<TcpServer> svr(new TcpServer(localport, HandlerRequest));

    // 5. 启动服务器主循环(永久运行)
    svr->Loop();

    return 0;
    }

    变成守护进程

    Daemon(false, false);

    作用: 让程序变成后台守护进程。

    参数值含义
    ischdir false 不切换工作目录(保持在运行目录)
    isclose false 重定向到 /dev/null(标准输出被吞掉)

    为什么要变成守护进程?

    • 服务器需要长期运行(7×24小时)

    • 不能被终端关闭影响(SSH断开也不怕)

    • 不能在终端打印干扰用户

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux网络加餐(二)】《进程组,会话,控制终端,作业控制和守护进程》---详解
    分享到: 更多 (0)

    评论 抢沙发

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