1. 进程组
1.1 进程组概念
每个进程不仅具有一个进程 ID,还归属于一个进程组。进程组是一个或多个进程的集合,通常与同一作业相关联,可接收来自同一终端的各类信号。每个进程组拥有唯一的进程组 ID(PGID),PGID 与进程 ID 类似,也是一个正整数,可存放在 pid_t
数据类型中。一个进程组可以包含多个进程。
1.2 进程组组长
每个进程组可以有一个组长进程,其标识为进程组 ID 等于自身进程 ID。组长进程能够创建一个进程组以及组中的进程,随后可能终止。
值得注意的是,只要某个进程组中存在一个进程,该进程组就依然存在,与组长进程是否终止并无关联。
- 进程组组长的作用:可以创建一个进程组或者创建该组中的进程。
- 进程组的生命周期:从进程组创建开始到其中最后一个进程离开为止。只要某个进程组中有一个进程存在,该进程组就存在,不受组长进程是否已经终止的影响。
2. 会话
2.1 会话的概念
会话与进程组息息相关,可以看成是一个或多个进程组的集合。一个会话可以包含多个进程组。 每一个会话有一个会话 ID(SID)。一个会话可以有一个控制终端,通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(在网络登录情况下)。建立与控制终端连接的会话首进程被称为控制进程。
一个会话中的几个进程组可分为一个前台进程组以及一个或多个后台进程组。所以一个会话中,应该包括控制进程(会话首进程),一个前台进程组和任意多个后台进程组。
注意:会话 ID 在有些地方也被称为会话首进程的进程组 ID, 因为会话首进程总是一个进程组的组长进程, 所以两者是等价的。
通常我们都是使用管道将几个进程编成一个进程组,比如说指令 sleep100 | sleep200 | sleep300 &
,&
表示将进程组放在后台执行。
从上述结果来看3个进程对应的PGID相同,即属于同一个进程组。
2.2 创建会话
我们使用可以调用 setseid
函数来创建一个会话。
- 函数原型:pid_t setsid(void);
- 返回值:
- 如果成功,返回新的会话 ID(即调用进程的进程组 ID)。
- 如果失败,返回 -1,并设置适当的错误码,比如当调用进程已经是一个进程组的组长进程时,调用会失败。
setsid
函数在调用之后会产生以下情况:
- 调用进程会变成新会话的会话首进程,此时新会话中只有唯一的一个进程。
- 调用进程会变成进程组组长,新进程组 ID 就是当前调用进程 ID。
- 该进程没有控制终端。如果在调用
setsid
之前该进程存在控制终端,调用之后会切断联系。
需要注意的是,如果调用进程原来是进程组组长,则调用setsid
会报错。为避免这种情况,通常的使用方法是先调用fork
创建子进程,父进程终止,子进程继续执行。因为子进程会继承父进程的进程组 ID,而进程 ID 则是新分配的,这样就不会出现错误的情况。
2.3 控制终端
在 UNIX 系统中,用户通过终端登录系统后得到一个 Shell
进程,这个终端就成为 Shell
进程的控制终端。控制终端的信息保存在进程控制块(PCB)中,由于 fork
进程会复制 PCB
中的信息,所以由 Shell
进程启动的其他进程的控制终端也是这个终端。默认情况下没有重定向时,每个进程的标准输入、标准输出和标准错误都指向控制终端,进程从标准输入读即读取用户的键盘输入,往标准输出或标准错误输出写即输出到显示器上。
另外,会话、进程组以及控制终端还有以下关系:
- 一个会话可以有一个控制终端,通常会话首进程打开一个终端(终端设备或伪终端设备)后,该终端就成为该会话的控制终端。
- 建立与控制终端连接的会话首进程被称为控制进程。
- 一个会话中的几个进程组可被分成一个前台进程组以及一个或多个后台进程组。如果一个会话有一个控制终端,则它有一个前台进程组,会话中的其他进程组为后台进程组。
- 无论何时进入终端的中断键(ctrl+c)或退出键(ctrl+\),就会将中断信号发送给前台进程组的所有进程。
- 如果终端接口检测到调制解调器(或网络)已经断开,则将挂断信号发送给 控制进程(会话首进程)
这些特性的关系如下图所示:
3. 作业控制
3.1 作业的概念
作业是从用户角度出发,为完成某项任务而启动的进程集合。一个作业既可以只包含一个进程,也可以由多个进程组成,这些进程之间互相协作完成任务,通常表现为一个进程管道。
需要注意的是,Shell 分前后台来控制的不是单个进程,而是作业或者进程组。一个前台作业可以由多个进程组成,一个后台作业同样可以由多个进程组成。Shell 可以同时运行一个前台作业和任意多个后台作业,这种方式被称为作业控制。
例如下列命令就是一个作业,它包括两个命令,在执⾏时 Shell 将在前台启动由两 个进程组成的作业。
3.2 作业号
放在后台执行的程序或命令称为后台命令。可以在命令的后面加上 "&" 符号,以便让 Shell 识别这是一个后台命令。
后台命令具有以下特点:
- 无需等待该命令执行完成,Shell 就可立即接收新的命令,提高了命令执行的效率和交互性。
- 后台进程执行完后会返回一个作业号以及一个进程号(PID),方便用户对后台任务进行跟踪和管理。
以
第 1 行显示作业号和进程 ID 的信息,例如作业号为 1,进程 ID 是 25489。第 3 - 5 行表示该程序的运行结果,具体为过滤/etc/filesystems
中有关ext
的内容。第 7 行分别表示作业号、默认作业、作业状态以及所执行的命令。
关于默认作业:对于一个用户来说,只能有一个默认作业(用"+"表示),同时也只能有一个即将成为默认作业的作业(用"-"表示),当默认作业退出后,该即将成为默认作业的作业会成为新的默认作业。无特定符号表示其他作业。
3.3 作业状态
常见的作业状态如下表所示:
作业状态 | 含义 |
---|---|
正在运行【Running】 | 后台作业(&),表示正在执行 |
完成【Done】 | 作业已完成,返回的状态码为 0 |
完成并退出【Done(code)】 | 作业已完成并退出,返回的状态码为非 0 |
已停止【Stopped】 | 前台作业,当前被 Ctrl+Z 挂起 |
已终止【Terminated】 | 作业被终止 |
3.4 作业挂起与切回
在执行作业时,我们可以利用快捷键 Ctrl+Z
来获取作业的相关信息,包括作业号、状态以及所执行的命令信息。例如,当我们运行一个死循环的程序后,通过按下 Ctrl+Z
可以将该作业挂起。
cpp
#include<iostream>
#include<unistd.h>
int main()
{
while(true)
{
std::cout << "hello world" <<std::endl;
sleep(1);
}
return 0;
}
如果想将挂起的作业切回,可以通过 fg
命令,fg
后面可以跟作业号或作业的命令名称。如果参数缺省则会默认将作业号为 1 的作业切到前台来执⾏,若当前系统只有一个作业在后台进⾏,则可以直接使用 fg
命令不带参数直接切回。 具体的参数参考如下:
参数 | 含义 |
---|---|
%n(n 为正整数) | 表示作业号 |
%string | 以字符串开头的命令所对应的作业 |
%?string | 包含字符串的命令所对应的作业 |
%+或%% | 最近提交的一个作业 |
%- | 倒数第二个提交的作业 |
3.5 查看后台进程
我们能够直接输入命令来查看本用户当前后台执行或挂起的作业。
参数
-l
可显示作业的详细信息,参数
-p
则只显示作业的 PID。
例如,我们先在后台及前台分别运行两个作业,并将前台作业挂起,此时可以使用jobs
命令来查看作业相关的信息。
3.6 相关信号
在系统中有特定操作可使终端驱动程序产生信号并发送至前台进程组作业。其中,键入Ctrl + Z
可将前台作业挂起,实际是将STGTSTP
信号发送至前台进程组作业中的所有进程,后台进程组中的作业不受影响。此外,系统中存在三个特殊字符可实现此功能,具体如下:
操作 | 说明 | 产生信号 |
---|---|---|
Ctrl + C |
中断字符 | SIGINT |
Ctrl + \ |
退出字符 | SIGQUIT |
Ctrl + Z |
挂起字符 | STGTSTP |
4. 守护进程
4.1 守护进程的概念
守护进程,也称精灵进程(Daemon),是运行在后台的特殊进程,独立于控制终端,周期性执行任务或等待处理特定事件。Linux
的大多数服务器如 inetd、httpd 等由守护进程实现,同时它也完成许多系统任务如 crond 等。Linux
系统启动时会启动众多系统服务进程,这些进程无控制终端,不能与用户直接交互,且不受用户登录注销影响,一直在运行,这类进程即守护进程。
凡是 TPGID
一栏写着-1的都是没有控制终端的进程,也就是守护进程。守护进程通常采用以 d
结尾的名字,表示Daemon。
4.2 守护进程的创建
我们可以使用 daemon
接口创建出一个守护进程:
- 函数原型:int daemon(int nochdir, int noclose);
- 参数
nochdir
:如果该参数为 0,守护进程会将工作目录更改为根目录(/
);如果为非零值,则不改变工作目录。noclose
:如果该参数为 0,守护进程会关闭标准输入、标准输出和标准错误流(通常是文件描述符 0、1 和 2);如果为非零值,则不关闭这些流。
- 返回值:如果
daemon()
函数执行成功,它将返回 0。如果出现错误,它将返回 -1,并设置errno
以指示错误类型。
cpp
#include<iostream>
#include<unistd.h>
int main()
{
daemon(0,0);//变为守护进程
while(true)
{
std::cout << "hello world" <<std::endl;
sleep(1);
}
return 0;
}
4.3 模拟实现 daemon
以下是关于守护进程概念的介绍:
- 忽略其他异常信号,防止出现僵尸进程,异常退出或随意停止。
- 设置文件掩码为 0,确保守护进程后续创建文件时权限符合预期。
- 创建新会话(setsid)使当前进程自成会话。因调用 setsid 要求进程不能是进程组组长,故先 fork 创建子进程,由子进程调用 setsid 并继续执行后续代码,父进程退出。
- 设置工作目录, 通常将守护进程工作目录设为根目录,便于以绝对路径访问资源,非必须操作。
- 重定向输入输出,守护进程与终端无关联,一般将其标准输入、标准输出和标准错误重定向到
/dev/null
,用于屏蔽/丢弃输入输出信息。
cpp
const std::string nullfile = "/dev/null";
void Deamon(const std::string &cwd = "")
{
// 1. 忽略其他异常信号
// 忽略子进程结束信号,防止产生僵尸进程
signal(SIGCLD, SIG_IGN);
// 忽略管道破裂信号,防止程序因向已关闭的管道写入而异常退出
signal(SIGPIPE, SIG_IGN);
// 忽略停止信号,守护进程通常不应被外部信号随意停止
signal(SIGSTOP, SIG_IGN);
// 将默认掩码设置为0
umask(0);
// 创建子进程,父进程退出,这样可以使子进程脱离控制终端
if (fork() > 0)
{
exit(0);
}
// 创建新的会话,让子进程成为新会话的首进程,脱离原会话和控制终端的控制
setsid();
if (!cwd.empty())
{
// 如果传入了工作目录,则切换到该目录
chdir(cwd.c_str());
}
int fd = open(nullfile.c_str(), O_RDWR);
if (fd > 0)
{
// 将标准输入重定向到 /dev/null
dup2(fd, 0);
// 将标准输出重定向到 /dev/null
dup2(fd, 1);
// 将标准错误输出重定向到 /dev/null
dup2(fd, 2);
close(fd);
}
}