文章目录
🍅任务管理
进程组概念
进程组是一组相关联的进程集合,这些进程可以共同执行某个任务,并且可以通过信号进行通信和管理。
特征
- 唯一标识:每个进程组都有一个唯一的组标识符(GID),用于区分不同的进程组。
- 信号传递:信号可以发送到整个进程组,而不仅仅是单个进程。这使得对一组进程的管理更为高效,例如,可以通过发送信号来终止或暂停整个组内的所有进程。
- 作业控制:进程组通常用于作业控制。用户可以将一组进程视为一个整体来进行管理,例如前台和后台作业。
- 管理特性:当一个进程组中的某个进程终止时,其他进程通常会收到通知。进程组中的进程可以共享某些资源,如标准输入输出。
- 组长进程:每个进程组都可以有一个组长进程。组长进程的进程ID等于进程组ID。
需要注意的是,只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。
作业概念
Shell分前后台来控制的不是进程而是作业(Job)或者进程组(Process Group)。
作业是用户通过操作系统提交的一组相关任务和请求,通常包括程序的执行、所需的数据和资源的使用。
特征
- 任务集合:作业可以包括一个或多个进程。这些进程共同完成特定的计算或数据处理任务。
- 资源需求:每个作业通常需要特定的系统资源,如CPU时间、内存、输入输出设备等。
- 状态管理:作业会经历不同的状态,如就绪、运行、等待等。操作系统负责管理这些状态,以确保作业能够顺利执行。
- 调度:操作系统根据某种调度算法决定作业的执行顺序,以优化资源利用率和系统响应时间。
一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,Shell可以运行一个前台作业和任意多个后台作业,这称为作业控制。
进程组和作业之间的关系:每个作业都对应一个进程组,但不是每个进程组都是一个作业。只有由shell启动的进程组才是作业,而其他程序启动的进程组则不是。例如,系统启动时创建的init进程和它的子进程就不属于任何作业。另外,如果作业中的某个进程又创建了子进程,则子进程不属于原来的作业,而是属于另一个进程组。
注意:
当作业(进程组)中的某个进程创建了一个新的子进程时,这个子进程实际上是属于它的父进程的进程组,而不是原作业的进程组。也就是说,尽管子进程是由作业中的进程创建的,但它不被视为原作业的一部分。
若作业中(进程组)的某个进程创建了一个新的子进程,当作业运行结束以后,shell会将它自己作为前台作业,如果原先的前台进程依然存在,即这个新的子进程还未终止,那么它将会变成后台进程组。这意味着子进程不会再属于原来的作业,而是独立于作业之外,成为一个后台进程。
会话概念
会话(Session)是一个或多个进程组的集合。
一个会话可以有一个控制终端,这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(在网络登录情况下)。建立与控制终端连接的会话首进程被称为控制进程。一个会话中的几个进程组可被分为一个前台进程组以及一个或多个后台进程组。所以一个会话中,应该包括控制进程(会话首进程),一个前台进程组和任意多个后台进程组。
会话的创建和终止由终端设备控制。当一个用户登录到一个终端(计算机)时,登录进程就会为这个用户创建一个新的会话,该会话的首个进程是登录shell,登录shell被作为“会话首进程”。登录shell可以启动其他的进程或进程组,从而形成该会话的作业。会话首进程的PID就被作为会话ID。会话是一个或多个进程组的集合。会话囊括了登录用户的所有活动,并且分配给用户一个控制终端(controling terminal)。控制终端是用于处理用户I/O的特定tty设备。因此,会话的功能和shell差不多。实际上,没有谁刻意去区分它们。
当用户退出终端时,会向前端进程组中的所有进程发送SIGQUIT信号。当终端发现网络中断的情况时,会向前端进程组中的所有进程发送SIGHUP信号。当用户敲入了终止键(一般是Ctrl+C
),会向前端进程组中的所有进程发送SIGINT信号。因此,会话使得shell可以更容易管理终端以及登录行为。
不同的会话之间可以通过管道或其他方式进行通信,但是每个会话都有自己独立的命名空间,不能直接访问其他会话的进程组或作业。每个会话都有自己的前台作业和后台作业,它们可以通过shell命令(如fg、bg、jobs等)进行切换和管理。
用计算机的“登录”和“注销”操作来解释会话、进程组以及作业:
- 当在计算机上登录时,就开始了一个新的会话,输入的用户名和密码会被验证,然后会进入一个shell环境,这个shell是会话首进程,也是控制终端的控制进程,它也是自己的进程组的唯一成员。
- 当在shell中输入一个命令或者通过管道连接的一组命令时,shell就会创建一个或多个新的进程,并把它们放在一个新的进程组中,这个进程组就是一个作业。如果在命令后面加上&符号,就表示让这个作业在后台运行,否则就让它在前台运行。可以用shell提供的一些命令来管理作业。
- 当在计算机上注销时,就结束了当前的会话,所有属于这个会话的进程组和作业都会被终止,控制终端也会被释放。如果还有其他的会话在运行(比如通过远程登录或者打开多个终端窗口),它们不会受到影响。
测试
下面我们用同一个死循环代码生成了5个可执行程序。
我们将test1和test2放到后台运行,将test3、test4和test5放到前台运行。
其中test1与test2属于同一个后台进程组,test3、test4和test5属于同一个前台进程组,而Shell本身属于一个单独的进程组。
这些进程组的控制终端相同,它们同属于一个会话,当用户在控制终端输入特殊的控制键(如Ctrl+C
产生SIGINT,Ctrl+\
产生SIGQUIT,Ctrl+Z
产生SIGTSTP),内核就会发送相应的信号给前台进程组中的所有进程。
相关操作
前台进程&后台进程
直接运行某一可执行程序,例如./可执行程序
,此时默认将程序放到前台运行,在前台运行的进程的状态后有一个+
号,例如R+
。
运行可执行程序时在后面加上&,可以指定将程序放到后台运行,例如./可执行程序 &
,在后台运行的进程的状态后没有+
号。
我们将程序放到后台运行时会发现多了一行提示信息,例如上述的:
[1] 4023
其中[1]是作业的编号,如果同时运行多个作业可以用这个编号进行区分,4023是该作业中某个进程的id(一个作业可以由多个进程组成)。
我们可以用该可执行程序同时创建四个进程放到后台运行:
此时我们就可以将它们分别叫做当前终端下的1号作业、2号作业、3号作业和4号作业。
jobs
使用jobs
命令,可以查看当前会话当中有哪些作业。
fg
使用fg
命令(foreground),可以将某个作业提至前台运行,如果该作业正在后台运行则直接提至前台运行,如果该作业处于停止状态,则给进程组的每个进程发SIGCONT信号使它继续运行并提至前台。
例如,使用fg 1
命令将1号作业提到前台运行。
由于1号作业被提至前台运行,所以其运行状态也由R
变成了R+
。
注意:前台进程只能有一个,当一个进程变成前台进程后,bash会自动变为后台进程,此时bash就无法进行命令行解释了。
例如,我们将1号作业提至前台运行后,bash进程的状态后面的+
号就没有了,也就意味着bash自动由前台进程变为了后台进程。
bg
将一个前台进程放到后台运行可以先使用Ctrl+Z
将进程停止,使用Ctrl+Z
后该进程就会处于停止状态(Stopped)。
使用bg
命令,可以让某个停止的作业在后台继续运行(Running),本质就是给该作业的进程组的每个进程发SIGCONT信号。
例如,使用bg 1
命令让1号作业在后台继续运行。
ps命令查看指定的选项
使用ps
命令时携带-o
选项,可以查看指定的信息。
每次“登录”的操作就是创建bash进程的操作,即新建一个会话。所有的命令行启动的任务都是在对应的会话内运行的。同一个会话中的所有进程的SESS是相同的。例如我在另一个shell中输入同样的指令:
注:ps命令是一个系统级的命令,该命令能查看所有进程的信息,例如ps axj
,只不过-o
选项只查看当前会话的进程信息。
🫒守护进程
守护进程的概念
守护进程也称精灵进程(Daemon),是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
守护进程的特点:
- 它们由系统启动时或其他进程创建,而不是由用户登录时创建。
- 它们没有控制终端,也不会接收终端上的信号。
- 它们通常有一个特殊的父进程(init或launchd),当它们的创建者退出时,不会成为孤儿进程。
- 它们通常以root用户或者其他特殊的用户(例如apache和postfix)运行,并处理一些系统级的任务。
- 习惯上守护进程的名字通常以d结尾,例如httpd(Web服务器的守护进程)、sshd(SSH服务器的守护进程)、crond(作业规划进程)等等。
作用
守护进程的作用是在后台运行不受终端控制的进程,这样就能在系统运行期间在后台执行某些特定任务,通常是为了提供系统服务,例如Web服务器、邮件服务器、数据库服务器等等,一般的网络服务都是以守护进程的方式运行的。它们可以在系统启动时启动,并在整个系统运行期间一直存在,以便在需要时随时提供服务。
系统服务进程会启动很多系统服务进程和守护进程,例如网络服务、文件系统服务、日志服务、安全服务等等。这些服务进程和守护进程提供的功能是Linux系统正常运行所必需的。因此,守护进程是Linux系统中非常重要的一部分,它们保证了系统的可靠性和稳定性。
守护进程可以突破终端的限制,即使关闭终端,守护进程也不会被关闭,而是一直运行到系统关机或者被kill命令终止。
守护进程的查看
最常用的方式是使用ps
指令,当使用ps
命令时,可以使用各种选项来控制显示的进程信息。下面是一些常用的选项:
a
:显示所有进程,包括其他用户的进程。u
:显示进程的详细信息,例如用户、CPU使用率、内存使用情况等。x
:显示没有控制终端的进程(通常是后台进程)。j
:显示与作业控制相关的信息。f
:以树形结构显示进程,其中父进程和子进程之间通过缩进来表示。e
:显示所有进程,包括没有控制终端的进程。r
:显示当前正在运行的进程。o
:自定义输出格式,可以指定要显示的字段。t
:指定要显示的进程类型,例如TTY进程、批处理进程或用户进程。p
:指定要显示的进程ID。
可以使用ps axj
查看系统中的进程:
其中,TPGID为-1表示没有控制终端的进程,即守护进程。
除此之外,在COMMAND一列用[ ]括起来的名字表示内核线程,这些线程在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常采用以k开头的名字,表示Kernel。
补充说明:
- udevd负责维护/dev目录下的设备文件。
- acpid负责电源管理。
- syslogd负责维护/var/log下的日志文件。
可以看出,守护进程通常采用以d结尾的名字,表示Daemon。
守护进程的创建
原生创建守护进程
守护进程的创建步骤如下:
- 忽略可能引起程序异常的信号(如SIGCHLD、SIGPIPE等)。
- 调用fork(),创建子进程,并终止父进程,该子进程将会成为守护进程。
- 子进程创建新会话。
- 将子进程的CWD更改为根目录(
\
)。 - 将标准输入、标准输出和标准错误重定向到
/dev/null
。
相关说明:
- 忽略信号
在创建守护进程时,通常会忽略可能引起程序异常的信号,例如
SIGCHLD
和SIGPIPE
。这样可以避免在进程运行时由于这些信号导致的意外中断。
- 调用fork创建子进程
通过调用fork创建一个子进程,并终止父进程。子进程将会成为守护进程。
- 子进程创建新会话
为了与终端脱离关系,确保守护进程不再与用户交互,子进程需要创建新会话。而调用setsid创建新会话时,要求调用进程不能是进程组组长,但是当我们在命令行上启动多个进程协同完成某种任务时,其中第一个被创建出来的进程就是组长进程,因此我们需要fork创建子进程,让子进程调用setsid创建新会话并继续执行后续代码,而父进程我们直接让其退出即可。
- 更改当前工作目录
将子进程的当前工作目录更改为根目录(
/
)。这样可以使守护进程以绝对路径的形式访问所需资源,避免在挂载点或文件系统变更时出现路径问题。虽然这不是必须的,但通常是一个良好的实践。
- 重定向标准输入、输出和错误
将守护进程的标准输入、标准输出和标准错误重定向到
/dev/null
。/dev/null
是一个特殊的字符设备,通常用于丢弃不需要的输入输出信息。这确保了守护进程不会尝试与终端进行交互,也避免了因缺少终端而导致的潜在错误。
- 补充
- 由于守护进程不能直接与用户交互,因此在创建守护进程时,可以采用防御性编程的方法,通过再次fork创建另一个子进程。这一操作确保了新的子进程不是会话首进程,从而防止它打开新的终端。
- 守护进程的设计旨在在后台运行,通常用于处理系统任务、服务和定时任务等。
测试
代码如下:
#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";
int main()
{
// 1. 忽略可能引起程序异常退出的信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
// 2. 让自己不要成为组长
if (fork() > 0)
exit(0);
// 3. 设置让自己成为一个新的会话, 后面的代码其实是子进程在走
setsid();
// 4. 再次fork,终止父进程,保持子进程不是会话首进程,
// 从而保证后续不会再和其他终端相关联(不是必须的,防御性编程)
if (fork() > 0)
{
// father
exit(0);
}
// 5. 每一个进程都有自己的CWD,将当前进程的CWD更改成为 / 根目录(可选)
chdir(root);
// 6. 已经变成守护进程啦,不需要和用户的输入、输出和错误进行关联了(可选)
int fd = open(dev_null, O_RDWR);
if (fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
// 7.执行守护进程的任务
while (1)
;
return 0;
}
运行代码,用ps命令查看该进程,会发现该进程的TPGID为-1
,TTY显示的是?
,也就意味着该进程已经与终端去关联了。
其次,我们还可以看到该进程的PID与其PGID和SID是不同的,也就是说该进程既不是组长进程也不是会话首进程。
此外,我们还可以看到该进程的SID与bash进程的SID是不同的,即它们不属于同一个会话。
通过ls /proc/进程id -al
命令,可以看到该进程的工作目录已经成功改为了根目录。
通过ls /proc/进程id/fd -al
命令,可以看到该进程的标准输入、标准输出以及标准错误也成功重定向到了/dev/null
。
调用daemon函数创建守护进程
实际当我们创建守护进程时可以直接调用daemon接口进行创建,daemon函数的函数原型如下:
int daemon(int nochdir, int noclose);
参数说明:
- 如果参数nochdir为0,则将守护进程的工作目录该为根目录,否则不做处理。
- 如果参数noclose为0,则将守护进程的标准输入、标准输出以及标准错误重定向到
/dev/null
,否则不做处理。
调用示例:
#include <unistd.h>
int main()
{
daemon(0, 0);
while (1)
;
return 0;
}
调用daemon函数创建的守护进程与我们原生创建的守护进程差距不大,唯一区别就是daemon函数创建出来的守护进程,既是组长进程也是会话首进程,也就是说系统实现的daemon函数没有防止守护进程打开终端。
模拟实现daemon函数
有了上述创建守护进程的代码,要模拟实现daemon函数就很容易了,我们只需要设置两个参数nochdir和noclose,当所给nochdir为0时,我们将守护进程的工作目录该为根目录,当所给noclose为0时,我们则将守护进程的标准输入、标准输出以及标准错误重定向到/dev/null
即可。(这里为了和操作系统中的daemon函数保持一致,我们删去了防御性编程代码)
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(int ischdir, int isclose)
{
// 1. 忽略可能引起程序异常退出的信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
// 2. 让自己不要成为组长
if (fork() > 0)
exit(0);
// 3. 设置让自己成为一个新的会话, 后面的代码其实是子进程在走
setsid();
// 4. 每一个进程都有自己的CWD,是否将当前进程的CWD更改成为 / 根目录
if (ischdir == 0)
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);
}
}
}