🌎进程间关系与守护进程
文章目录:
进程间关系与守护进程
进程组
会话
认识会话
会话ID
创建会话
控制终端
作业控制
作业(job)和作业控制(Job Control)
作业号及作业过程
守护进程
🚀进程组
之前我们提到了进程的概念, 其实每一个进程除了有一个进程 ID(PID)之外 还属于一个进程组。进程组是一个或者多个进程的集合, 一个进程组可以包含多个进程。 每一个进程组也有一个唯一的进程组 ID(PGID), 并且这个 PGID 类似于进程 ID, 同样是一个正整数, 可以存放在 pid_t 数据类型中。我们使用如下命令可以查看进程组:
ps-eopid,pgid,ppid,comm|greptest
- -e 选项: 表示every的意思,表示输出每一个进程信息
- -o选项: 以逗号操作符(,)作为定界符,可以指定要输出的列
我们使用sleep命令通过管道创建了 3个进程,虽然这并没有什么意义,我们发现,这三个进程的 ppid 是相同的,也就是说,这三个进程是兄弟进程,那么他们的父进程是谁呢?就是我们常说的 bash 进程。
如果你仔细观察上图,会发现有一列名为 PGID 的数值,它们三个进程都是一样的,而 PGID 表示的就是进程组id,如果你再仔细观察,Sleep 10000 进程的进程id实际上就是他们三个的进程组id,这就表示,-每一个进程组都有一个组长进程。组长进程的ID等于其进程ID。
如果只有一个进程会是什么情况呢?还会有进程组出现吗?我们不妨做个简单的实验,实验代码如下所示:
#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。还有一种情况,我们没考虑到,如果是父子进程之间呢?会有什么样的关系?我们继续做实验,下面是实验代码:
#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
一个是我们前面写的程序,同时我们使用如下命令对他们进行监测:
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 将在前台启动由两个进程组成的作业:
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 当中。
#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接口的行为:
#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);// forkif(fork() > 0) exit(0);//setsidsetsid();// 确认是否要更改工作目录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函数中我们就可以调用该接口实现会话分离:
#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命令即可。