一、进程组
1.1 什么是进程组
之前我们提到了进程的概念, 其实每一个进程除了有一个进程 ID(PID)之外 还属于一 个进程组。进程组是一个或者多个进程的集合, 一个进程组可以包含多个进程。 每一 个进程组也有一个唯一的进程组 ID(PGID), 并且这个 PGID 类似于进程 ID, 同样是 一个正整数, 可以存放在 pid_t 数据类型中。
C++
$ ps 查看进程的信息
# -e 选项表示 every 的意思, 表示输出每一个进程信息
# -o 选项以逗号操作符(,)作为定界符, 可以指定要输出的列
# -a 选项表示不仅列当前⽤户的进程,也列出所有其他⽤户的进程
# -x 选项表示不仅列有控制终端的进程,也列出所有⽆控制终端的进程
# -j 选项表示列出与作业控制相关的信息
int main()
{
while(true)
{
std::cout<<"I am a process pid:"<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
我们发现尽管时单进程,这个进程也拥有自己的进程组,而进程组id就是单进程的pid
int main()
{
pid_t id = fork();
if (id == 0)
{
while (true)
{
std::cout << "I am child process pid:" << getpid() << std::endl;
sleep(1);
}
}
std::cout << "I am father process pid:" << getpid() << std::endl;
sleep(100);
return 0;
}
接下来我们又创建了一个子进程,我们发现父子进程都是属于一个进程组的,并且进程组id是父进程的pid,因为父进程是这个进程组的第一个进程。在我们写的多进程程序中,都是以进程组的方式来执行任务的
1.2 组长进程
每一个进程组都有一个组长进程。 组长进程的 ID 等于其进程 ID。我们可以通过 ps 命 令看到组长进程的现象:
Shell
[node@localhost code]$ ps -o pid,pgid,ppid,comm | cat
# 输出结果
PID PGID PPID COMMAND
2806 2806 2805 bash
2880 2880 2806 ps
2881 2880 2806 cat
- 进程组组长的作用: 进程组组长可以创建一个进程组或者创建该组中的进程
- 进程组的生命周期: 从进程组创建开始到其中最后一个进程离开为止。
注意: 主要某个进程组中有一个进程存在, 则该进程组就存在, 这与其组长进程是否已经终止无关。
二、会话
2.1 什么是会话
会话其实和进程组息息相关, 会话可以看成是一个或多个进程组的集合, 一个会话可以包含多个进程组。每一个会 话也有一个会话 ID(SID),会话ID一般为会话中第一个进程组的第一个进程的id,
注意:会话 ID 在有些地方也被称为 会话首进程的进程组 ID, 因为会话首 进程总是一个进程组的组长进程, 所以两者是等价的
以我们登录Linux服务器为例,服务器内部一定是安装了系统的,当我们输入账号密码要登录的时候,系统首先会创建一个终端文件和一个与之关联的bash进程,而这个bash进程也是一个进程组,在Linux中终端文件一般保存在在 /dev/pts 目录下,我们可以将终端文件理解为我们输入命令的窗口(当前我一共打开了两个窗口),每打开一个窗口就会新创建一个文件
我们也可以查看一下系统中的bash进程,发现这两个bash进程属于不同的进程组和会话
2.2 如何创建会话
可以调用 setseid 函数来创建一个会话, 前提是调用进程不能是一个进程组的组长。
#include <unistd.h>
/*
*功能:创建会话
*返回值:创建成功返回 SID, 失败返回-1
*/
pid_t setsid(void);
该接口调用之后会发生:
- 调用进程会变成新会话的会话首进程。 此时, 新会话中只有唯一的一个进程
- 调用进程会变成进程组组长。 新进程组 ID 就是当前调用进程 ID
- 该进程没有控制终端。 如果在调用 setsid 之前该进程存在控制终端, 则调用之后会切断联系,因为处于不同的会话了
需要注意的是:
这个接口如果调用进程原来是进程组组长, 则会报错, 为了避免这 种情况, 我们通常的使用方法是先调用 fork 创建子进程, 父进程终止, 子进程继续 执行, 因为子进程会继承父进程的进程组 ID, 而进程 ID 则是新分配的, 就不会出现错误的情况。
三、控制终端
在 UNIX 系统中,用户通过终端登录系统后得到一个 Shell 进程,这个终端成为 Shell 进程的控制终端。控制终端是保存在 PCB 中的信息,我们知道 fork 进程会复制 PCB 中的信息,因此由 Shell 进程启动的其它进程的控制终端也是这个终端。默认情况下 没有重定向,每个进程的标准输入、标准输出和标准错误都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到 显示器上。另外会话、进程组以及控制终端还有一些其他的关系,我们在下边详细介绍一下:
- 一个会话可以有一个控制终端,通常会话首进程打开一个终端(终端设备或 伪终端设备)后,该终端就成为该会话的控制终端。
- 建立与控制终端连接的会话首进程被称为控制进程。
- 一个会话只能有一个前台进程组,但是可以有多个后台进程组
- 无论何时进入终端的中断键(ctrl+c)或退出键(ctrl+\),就会将中断信号 发送给前台进程组的所有进程。
- 前后台进程的显著区别是是否占用终端(或控制台)的输入输出资源。
证明:
默认情况下bash进程一般为前台进程组,假如我们执行了一个死循环打印的程序,这死循环中我们在终端中输入命令会发现bash进程并不会做出响应,这是因为当我们执行打印程序时,系统将我们的进程组设置为了前台进程组,bash进程暂时被设置为了后台进程组,而我们在终端输入的数据是给前台进程的所以bash就收不到我们的指令了,而当我们的程序终止时,bash进程会切换为前台进程,此时我们又可以正常执行指令了
四、作业控制
4-1 什么是作业(job)和作业控制(Job Control)?
作业是针对用户来讲,用户完成某项任务而启动的进程,一个作业既可以只包含 一个进程,也可以包含多个进程,进程之间互相协作完成任务
Shell 分前后台来控制的不是进程而是作业或者进程组。一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,Shell 可以同时运⾏一个前台作业和任意多个后台作业,这称为作业控制。
4.2 作业号
放在后台执⾏的程序或命令称为后台命令,可以在命令的后面加上&符号从而让 Shell 识别这是一个后台命令,后台命令不用等待该命令执行完成,就可立即接收 新的命令,另外后台进程执行完后会返回一个作业号以及一个进程号(PID)。
我们可以直接通过输入 jobs 命令查看本用户当前后台执行或挂起的作业
▪ 参数 -l 则显示作业的详细信息
▪ 参数 -p 则只显示作业的 PID
关于默认作业:对于一个用户来说,只能有一个默认作业(+),同时也只能有一 个即将成为默认作业的作业(-),当默认作业退出后,该作业会成为默认作业。
- + : 表示该作业号是默认作业
- -:表示该作业即将成为默认作业
- 无符号: 表示其他作业
4.3 作业控制
常见的作业状态如下表所示:
作业挂起
我们在执行某个作业时,可以通过 Ctrl+Z 键将该作业挂起,然后 Shell 会显示相 关的作业号、状态以及所执行的命令信息。
作业切回
如果想将挂起的作业切回,可以通过 fg(front ground 前台) 命令,fg 后面可以跟作业号或作业的命 令名称。如果参数缺省则会默认将作业号为 1 的作业切到前台来执行,若当前系统只有一个作业在后台进行,则可以直接使用 fg 命令不带参数直接切回。 具体的参数参考如下:
注意: 当通过 fg 命令切回作业时,若没有指定作业参数,此时会将默认作业切 到前台执行,即带有“+”的作业号的作业
五、守护进程
5.1 什么是守护进程
守护进程(Daemon Process)是在Unix和类Unix操作系统(如Linux)中运行的一种特殊类型的进程。它们通常在系统启动时自动启动,并在后台运行,执行一些诸如监控或管理其他进程、系统服务、网络服务等任务。
守护进程的主要特点包括:
-
后台运行:守护进程在后台运行,不占用终端。这意味着用户不能直接与守护进程交互,除非通过某种形式的进程间通信(IPC)机制,如管道、信号、消息队列等。
-
独立于终端:守护进程一旦启动,就与启动它的终端或会话无关。即使终端关闭或会话结束,守护进程也会继续运行。
-
生命周期:守护进程通常设计为在系统运行期间持续运行,除非遇到错误、系统重启或管理员明确停止。
-
日志记录:守护进程通常会记录其运行状态、错误和警告到系统日志中,以便于管理员监控和故障排查。
-
低优先级:守护进程通常具有较低的进程优先级,以避免它们占用过多系统资源,影响其他关键任务。
-
无控制终端:守护进程没有控制终端。这意味着它们不能直接从终端接收输入或输出到终端。
5.2 守护进程实现
- 创建子进程:父进程退出,子进程继续运行,避免该进程是一个进程组的组长,所以守护进程也是一种特殊的孤儿进程
- 创建新会话:子进程调用 setid() 创建新的会话,成为会话领头进程,脱离原来的控制终端。
- 改变工作目录:守护进程通常会改变其工作目录到根目录(
/
),以避免因非根目录不可访问而退出。 - 设置文件权限掩码:守护进程通常会调用umask设置合适的文件权限掩码,以避免创建的文件具有不恰当的权限。
- 关闭文件描述符:关闭文件描述符如标准输入、标准输出、标准错误,或者将他们重定向到 /dev/null 文件中,因为进程已经是守护进程了,不需要与用户关联了
- /dev/null文件是一个特殊的字符类文件,当从这个文件中读取数据时什么也读不到,而当向这个文件写数据时,系统会默认丢弃
这里我们的参数ischdir和isclose是为了将是否改变进程的工作目录和关闭文件描述符暴露出来,供用户自己选择
#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);
close(fd);
}
}
}
5.3 如何将服务守护进程化
// ./server port
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "Usage : " << argv[0] << " port" << std::endl;
return 0;
}
uint16_t localport = std::stoi(argv[1]);
Daemon(false, false);
std::unique_ptr<TcpServer> svr(new TcpServer(localport,
HandlerRequest));
svr->Loop();
return 0;
}
此时服务端已经在后台开始运行,我们发现客户端可以正常进行业务处理
当我们关闭服务端会话,服务端依旧在后端正常执行,客户端依旧可以正常执行