【Linux】进程间关系与守护进程

🌎进程间关系与守护进程

文章目录:

进程间关系与守护进程

进程组

会话

认识会话

会话ID

创建会话

控制终端

作业控制

[作业(job)和作业控制(Job Control)](#作业(job)和作业控制(Job Control))

作业号及作业过程

守护进程


🚀进程组

之前我们提到了进程的概念, 其实每一个进程除了有一个进程 ID(PID)之外 还属于一个进程组。进程组是一个或者多个进程的集合, 一个进程组可以包含多个进程。 每一个进程组也有一个唯一的进程组 ID(PGID), 并且这个 PGID 类似于进程 ID, 同样是一个正整数, 可以存放在 pid_t 数据类型中。我们使用如下命令可以查看进程组:

cpp 复制代码
ps-eopid,pgid,ppid,comm|greptest
  • -e 选项 : 表示every的意思,表示输出每一个进程信息
  • -o选项 : 以逗号操作符(,)作为定界符,可以指定要输出的列

我们使用sleep命令通过管道创建了 3个进程,虽然这并没有什么意义,我们发现,这三个进程的 ppid 是相同的,也就是说,这三个进程是兄弟进程,那么他们的父进程是谁呢?就是我们常说的 bash 进程。

如果你仔细观察上图,会发现有一列名为 PGID 的数值,它们三个进程都是一样的,而 PGID 表示的就是进程组id ,如果你再仔细观察,Sleep 10000 进程的进程id实际上就是他们三个的进程组id,这就表示,-每一个进程组都有一个组长进程。组长进程的ID等于其进程ID

如果只有一个进程会是什么情况呢?还会有进程组出现吗?我们不妨做个简单的实验,实验代码如下所示:

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>

int main()
{
    while(true)
    {
        std::cout << "I'm a process, pid is: " << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

从结果我们可以得出结论:无论进程是一个还是多个,都会有自己的进程组,如果是多个进程,会以第一个创建的进程的pid为进程组id,如果为单个进程,自己的pid就是进程组id。还有一种情况,我们没考虑到,如果是父子进程之间呢?会有什么样的关系?我们继续做实验,下面是实验代码:

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
    while(true)
    {
        std::cout << "I'm a sub process, pid is: " << getpid() << std::endl;
        sleep(1);
    }
    }
    sleep(3);
    std::cout << "I'm a father process, pid is: " << getpid() << std::endl;
    sleep(100);

    return 0;
}

我们发现,进程组的组id就是父进程的进程pid,其实也不难解释,因为需要先创建父进程才会创建子进程,而父子进程工作实际上就是在同一个进程组当中执行任务。

  • 进程组组长的作用进程组组长可以创建一个进程组或者创建该组中的进程
  • 进程组的生命周期从进程组创建开始到其中最后一个进程离开为止

注意主要某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否已经终止无关


🚀 会话

✈️认识会话

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

当我们在使用远程登录Xshell的时候,远端服务器会给我们做鉴权,我们登录成功之后系统会分配给我们一个终端文件,如下所示:

最开始我们只连接了一个客户端,此时在 /dev/pts 目录下就是我们的终端文件,当时只有0号文件这一个文件,但是当我们开启第二个终端,我们再次查看这个目录,就会发现终端文件就会多出一号。

除了会给每个用户分配一个终端文件以外,每个用户也会启动自己的bash进程,每当有新的用户连接时,机会分配一个新的bash:

我们前面说了,一个会话是包含多个进程组的,为了验证这个事实,我们创建两个进程组,一个是 sleep 10000 | sleep 20000 | sleep 30000 一个是我们前面写的程序,同时我们使用如下命令对他们进行监测:

bash 复制代码
ps ajx | head -1 && ps ajx | grep -E 'sleep|process_task' # -E选项表示''中的内容只要有匹配的就显示出来

可以看到,系统中存在五个进程,并且这些进程被分为了两组,但是我们观察SID这一栏,我们不难发现,它们的SID都是相同的,尽管是两个不同的进程组。而前面我们说了,SID就是会话ID,这也就证明了,一个会话中可以存在多个进程组。


✈️会话ID

上边我们提到了会话 ID, 那么会话 ID 是什么呢? 我们可以先说一下会话首进程, 会话首进程是具有唯一进程 ID 的单个进程, 那么我们可以将会话首进程的进程 ID 当做是会话 ID

注意会话 ID 在有些地方也被称为 会话首进程的进程组 ID, 因为会话首进程总是一个进程组的组长进程, 所以两者是等价的

在上面的例子中,我们除了看到这两个进程组是属于同一个会话以外,如果你仔细观察,会发现他们5个进程的ppid都是bash,而bash又作为第一个终端下启动的进程,所以正常情况下一个会话的会话id实际上就是bash进程的pid。


✈️创建会话

我们知道什么是会话了以后,我们该如何手动创建一个会话呢?实际上OS给我们提供系统调用的接口 setsid() ,可以调用 setseid 函数来创建一个会话, 前提是 调用进程不能是一个进程组的组长

  • 返回值成功返回创建会话的sid,失败返回-1

该接口调用之后会发生

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

注意

这个接口如果调用进程原来是进程组组长, 则会报错, 为了避免这种情况, 我们通常的使用方法是先调用 fork 创建子进程父进程终止, 子进程继续执行, 因为子进程会继承父进程的进程组 ID, 而进程 ID 则是新分配的, 就不会出现错误的情况


🚀控制终端

在上面的例子中,如果你仔细观察,当我在测试不同进程组属于同一个会话的时候,我们把 sleep 10000 | sleep 20000 | sleep 30000 & 变成了后台进程,这是因为 在同一个会话中,可以运行同时存在的多个进程,但是在任何时刻,只允许有一个前台进程(进程组),可以允许有多个后台进程并且只有前台进程才能获取从键盘得到的数据以及指令 。这也就是为什么我们无法使用 Ctrl C 来杀死后台进程。

在 UNIX 系统中,用户通过终端登录系统后得到一个 Shell 进程,这个终端成为 Shell进程的控制终端。控制终端是保存在 PCB 中的信息,我们知道 fork 进程会复制 PCB中的信息,因此由 Shell 进程启动的其它进程的控制终端也是这个终端。默认情况下没有重定向,每个进程的标准输入、标准输出和标准错误都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到显示器上。另外会话、进程组以及控制终端还有一些其他的关系,我们在下边详细介绍一下:

  • 一个会话可以有一个控制终端,通 常会话首进程打开一个终端(终端设备或伪终端设备)后,该终端就成为该会话的控制终端
  • 建立与控制终端连接的会话首进程被称为 控制进程
  • 一个会话中的 几个进程组可被分成一个前台进程组以及一个或者多个后台进程组
  • 如果一个会话有一个控制终端,则它有一个前台进程组,会话中的其他进程组则为后台进程组
  • 无论何时进入终端的中断键(ctrl+c)或退出键(ctrl+\),就会将中断信号发送给前台进程组的所有进程
  • 如果终端接口检测到调制解调器(或网络)已经断开,则将挂断信号发送给控制进程(会话首进程)。

它们的关系如下图所示:

那么当用户退出的时候,会话中的进程组虽然不一定都终止(不同OS处理方式不同),但是这些进程组一定会受到影响。


🚀作业控制

✈️作业(job)和作业控制(Job Control)

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

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

例如下列命令就是一个作业,它包括两个命令,在执⾏时 Shell 将在前台启动由两个进程组成的作业:

bash 复制代码
cat process.cc | head -n 5

✈️作业号及作业过程

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

例如下面的命令在后台启动了一个作业, 该作业由三个进程组成, 三个进程都在后台运⾏:

我们将 sleep 1000 | sleep 2000 | sleep 3000 & 三个进程放到了后台运行,执行的那一刻,我们就得到了作业号,并且 该作业号通常是进程组当中最进入的进程pid

当然,我们有更简单的命令来查看后台启动的作业,jobs 命令:

如果我们想要把一个后台作业放到前台去运行,我们可以使用 fg 作业序号


如果这个时候,你需要将放置到前台的作业再次放回到后台去运行,那么首先你需要先将作业暂停,使用 Ctrl Z 快捷键进行暂停作业,而暂停的进程就会自动的变成后台进程,这时候我们使用 bg 作业序号 就可以继续运行任务了:

我们仔细观察这些后台进程,在序号后面会跟着加号或者减号,这些是什么东西?实际上这与默认作业是有关系的。关于默认作业对于一个用户来说,只能有一个默认作业(+),同时也只能有一个即将成为默认作业的作业(-),当默认作业退出后,该作业会成为默认作业。而作业也会有自己的状态,一般分为以下几种:


🚀守护进程

假设一个会话中存在4个进程组,并且第四个进程组只有一个进程,如果我们将第四个进程(进程组)独立出来,形成一个新的会话,这个进程就叫做 守护进程

不同的程序员对创建守护进程的方式不同,这里我们用上面提到的 setsid() 来创建一个新的会话,这样就可以形成一个守护进程了,但是调用该接口的不能是进程组的组长,所以我们可以通过fork创建子进程,并将父进程退出,让子进程执行该接口即可。而父进程退出之后子进程就变成了孤儿进程,所以守护进程是孤儿进程的一种特殊情况。

如果我们直接调用setsid()是行不通的,必须得首先创建子进程,并且退出父进程,这样很费力,所以Linux给我们提供了一个一劳永逸的接口,不需要你创建子进程,因为其函数内部就已经做了处理 Daemon()

  • nochdir 参数是否更改当前进程的工作目录。如果更改,守护进程的目录就会切换为根目录,如果不更改,则在启动时的路径下
  • nocliose参数是否需要进行输入输出的处理

Linux每个终端下都会存在一个null文件:/dev/null如果去读取这个文件,文件内是没有任何内容的,如果对该文件进行写,同样也不会保存任何信息,而是立刻丢弃。我们知道,当我们创建了守护进程,也就意味着脱离了原本的会话,所以也就没有原本的终端文件了,守护进程内可能存在大量的IO操作,为了避免因为没有对应的终端文件进行IO而出错,我们可以将 0,1,2三个文件描述符全部重定向到 /dev/null 当中。

cpp 复制代码
#include <iostream>
#include <unistd.h>

int main()
{
    std::cout << "Pid is: " << getpid() << std::endl;
    sleep(1);

    daemon(0, 0);

    while(true)
    {
        std::cout << "hello test" << std::endl;
        sleep(1);
    }
    
    return 0;
}

以上是一个简单的测试样例,daemon内部会自动的fork并且退出父进程:

经过测试我们可以看到,hello.exe 的TTY,也就是终端文件变成了 "?", 也就表示已经不属于当前的会话了,而SID同样与当前进程的SID不同,并且SID为守护进程的pid。如果我们查看守护进程的工作目录:

可以看到,守护进程当前工作目录实际上就是在根目录,如果我们同时查看该守护进程的文件fd就会发现:

由此可见,daemon接口的两个参数实际上是bool值类型的,第一个参数表示是否更改工作目录,第二个参数表示是否更改重定向,如果我们把daemon参数设置为daemon(0, 0):

将daemon参数设置为(1, 1)就会导致我们输出的内容还是在上一个会话下,并且Ctrl C 也无法终止进程(可使用 kill -9 process_pid 杀死进程),当我们查询进程工作目录时,也能发现其在当前的工作目录下,而fd也指向了第一个终端文件。

那么这里,我们来模拟一下daemon接口的行为:

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cstdlib>

const std::string defaultpath = "/";
const std::string defaultdev = "/dev/null";

void Daemon(bool ischdir, bool isclose)
{
    //1. 忽略掉不要的信号
    signal(SIGCHLD, SIG_IGN);
    signal(SIGPIPE, SIG_IGN);

    // fork
    if(fork() > 0) exit(0);

    //setsid
    setsid();

    // 确认是否要更改工作目录
    if(ischdir)
        chdir(defaultpath.c_str());

    // 对012进行重定向
    if(!isclose)
    {
        ::close(0);
        ::close(1);
        ::close(2);
    }
    else
    {
        int fd = open(defaultdev.c_str(), O_RDWR);
        if(fd > 0)
        {
            dup2(fd, 0);
            dup2(fd, 1);
            dup2(fd, 2);
            ::close(fd);
        }
    }
}

这样我们的daemon接口就模拟完成了,接着在main函数中我们就可以调用该接口实现会话分离:

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include "Daemon.hpp"

int main()
{
    Daemon(false, false);
    while(true)
    {
        sleep(1);// 模拟任务
    }
    return 0;
}

该进程就变成了守护进程,可以看到ppid变为了1,pid与pgid, sid都相同也验证了我们所说的部分,并且TTY,我们找不到对应的终端文件了,更可以证明这个进程已经是一个守护进程了。要杀死守护进程也很简单,使用kill命令即可。


相关推荐
马立杰1 小时前
H3CNE-33-BGP
运维·网络·h3cne
云空2 小时前
《DeepSeek 网页/API 性能异常(DeepSeek Web/API Degraded Performance):网络安全日志》
运维·人工智能·web安全·网络安全·开源·网络攻击模型·安全威胁分析
深度Linux2 小时前
Linux网络编程中的零拷贝:提升性能的秘密武器
linux·linux内核·零拷贝技术
没有名字的小羊3 小时前
Cyber Security 101-Build Your Cyber Security Career-Security Principles(安全原则)
运维·网络·安全
m0_465215793 小时前
TCP & UDP Service Model
服务器·网络·tcp/ip
千夜啊3 小时前
Nginx 运维开发高频面试题详解
运维·nginx·运维开发
存储服务专家StorageExpert4 小时前
答疑解惑:如何监控EMC unity存储系统磁盘重构rebuild进度
运维·unity·存储维护·emc存储
chian-ocean6 小时前
从理论到实践:Linux 进程替换与 exec 系列函数
linux·运维·服务器
拎得清n6 小时前
UDP编程
linux
敖行客 Allthinker6 小时前
从 UTC 日期时间字符串获取 Unix 时间戳:C 和 C++ 中的挑战与解决方案
linux·运维·服务器·c++