进程
什么是进程
进程是正在运行的程序的实例。是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程与内核的关系
- 可以用一个程序来创建多个进程,进程是由内核定义的抽象实体,并为该实体分配用以执行程序的各项系统资源。
- 从内核的角度看,进程由用户内存空间和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。
- 记录在内核数据结构中的信息包括许多与进程相关的标 识号(IDs)、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进 程资源使用及限制、当前工作目录和大量的其他信息。
进程控制块(PCB)
为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。内核为每个进程分配一个 PCB(Processing Control Block)进程控制块,维护进程相关的信息 Linux 内核的进程控制块是 task_struct
结构体。
在以下文件中可以查看 task_struct
结构体定义:
/usr/src/linux-headers-xxx/include/linux/sched.h
需要了解的 task_struct
结构体内容
-
进程id:系统中每个进程有唯一的 id,用 pid t类型表示,其实就是一个非负整数
-
进程的状态:有就绪、运行、挂起、停止等状态
-
进程切换时需要保存和恢复的一些CPU寄存器
-
描述虚拟地址空间的信息
-
描述控制终端的信息
-
当前工作目录(Current Working Directory)
-
umask 掩码
-
文件描述符表,包含很多指向 file 结构体的指针
-
和信号相关的信息
-
用户 id 和组 id
-
会话 (Session) 和进程组
-
进程可以使用的资源上限(Resource Limit)
查看资源上限的命令
ulimit -a
进程状态转换
进程的状态
进程状态反映进程执行过程的变化。这些状态随着进程的执行和外界条件的变化而转换。
在三态模型中进程状态分为三个基本状态:
-
运行态:进程占有处理器正在运行。
-
就绪态:进程具备运行条件,等待系统分配处理器以便运行。当进程已分配到除CPU以外的所有必要资源后,只要再获得CPU,便可立即执行。在一个系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列。
-
阻塞态:又称为等待(wait)态或睡眠(sleep)态,指进程不具备运行条件,正在等待某个事件的完成。
在五态模型中,进程状态在三个基本状态上添加了新建态、终止态
-
新建态:进程刚被创建时的状态,尚未进入就绪队列。
-
终止态:进程完成任务到达正常结束点,或出现无法克服的错误而异常终止,或被操作系统及有终止权的进程所终止时所处的状态。进入终止态的进程以后不再执行,但依然保留在操作系统中等待善后。一旦其他进程完成了对终止态进程的信息抽取之后,操作系统将删除该进程。
进程相关命令
查看进程
a
- 显示终端上的所有进程,包括其他用户的进程
u
- 显示进程的详细信息
x
- 显示没有控制终端的进程
j
- 列出与作业控制相关的信息
ps aux
ps ajx
STAT参数含义
实时显示进程动态
top
可以在使用 top
命令时加上 -d n
来指定显示信息更新的时间间隔
top -d 5
更新排序
进入top后界面键入以下键盘按键可按规则显示
M
:根据内存使用量排序P
:根据 CPU 占有率排序T
:根据进程运行时间长短排序U
:根据用户名来筛选进程K
:输入指定的PID杀死进程Q
:退出top界面
杀死进程
语法:kill 信号选项 pid
列出所有信号
kill -l
# 强制终止进程的命令
kill -9 pid #kill -SIGKILL pid
# 建议先向进程发送一个终止请求,允许进程执行清理工作并正常退出(15是kill默认信号)。别直接一来就9,除非出现严重问题。
kill -15 pid #kill -SIGTERM pid
Linux kill 、kill -15、kill -9 的区别-CSDN博客
根据进程名杀死进程
killall name
进程号和相关函数
进程号
每个进程都由 进程号(PID) 来标识,其类型为 pid_t
(整型)进程号的范围: 0~32767。进程号是唯一的,但可以重复使用。当一个进程终止后,其进程号就可以再次使用。
父进程号
任何进程(除 init 进程)都是由另一个进程创建,该进程称为被创建进程的父进程对应的进程号称为 父进程号 (PPID) 。
进程组号
进程组是一个或多个进程的集合。他们之间相互关联,进程组可以接收同一终端的各种信号,关联的进程有一个 进程组号 (PGID) 。默认情况下,当前的进程号会当做当前的进程组号。
相关函数
pid_t getpid(void);
- 作用:用于获取调用进程的进程号
- 返回值:如果成功子进程中返回 0,父进程中返回子进程 ID调用进程的进程号;如果失败返回-1并设置errno
pid_t getppid(void);
- 作用:用于获取调用进程的父进程号
- 返回值:如果成功子进程中返回 0调用进程的父进程号;如果失败返回-1并设置errno
pid_t getpgid(void);
- 作用:用于获取调用进程的进程组号
- 返回值:如果成功子进程中返回进程组号;如果失败返回-1并设置errno
进程创建
系统允许一个进程创建新进程,新进程即为子进程,子进程还可以创建新的子进程,形成进程树结构模型。
fork 函数
pid_t fork(void);
- 作用:用于创建一个新的进程(子进程)
- 返回值:如果成功子进程中返回 0,父进程中返回子进程 ID;如果失败返回-1并设置
errno
失败的两个主要原因
- 当前系统的进程数已经达到了系统规定的上限,这时
errno
的值被设置为EAGAIN
- 系统内存不足,这时
errno
的值被设置为ENOMEM
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
// 创建子进程
pid_t pid = fork();
// 判断是父进程还是子进程
if (pid > 0)
{
printf("pid : %d\n", pid);
// 如果大于0,返回的是创建的子进程的进程号,当前是父进程
printf("i am parent process, pid : %d, ppid : %d\n", getpid(), getppid());
}
else if (pid == 0)
{
// 当前是子进程
printf("i am child process, pid : %d, ppid : %d\n", getpid(), getppid());
}
for (int i = 0; i < 5; i++)
{
printf("i : %d, pid : %d\n", i, getpid());
sleep(2);
}
return 0;
}
fork中的printf问题
父子进程虚拟地址空间情况
父子进程运行区别
- 父进程执行到
fork()
时,创建一个子进程 - 子进程创建完后
fork()
会给父进程返回子进程的 PID ,会给子进程返回 0 - 系统会为父进程的复制资源生成一段新的虚拟地址空间供子进程使用
- 父进程执行条件判断,子进程执行条件判断(子进程只执行
fork()
之后的代码) - 通过循环和
sleep()
显式体现cpu交替处理父子进程
父子进程虚拟地址空间
补充上一部分。
调用fork()
后,子进程的用户区数据与父进程相同但是 fork()
返回值不同,核区数据也会拷贝过来但是 pid 不同。
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
int num = 10;
// 输出num原定义值
printf("original num: %d\n", num);
// 输出num原地址
printf("Address of original num: %p\n", &num);
// 创建子进程
pid_t pid = fork();
// 判断是父进程还是子进程
if (pid > 0)
{
// printf("pid : %d\n", pid);
// 如果大于0,返回的是创建的子进程的进程号,当前是父进程
printf("i am parent process, pid : %d, ppid : %d\n", getpid(), getppid());
printf("parent num: %d\n", num);
num += 10;
printf("parent num += 10: %d\n", num);
// 输出父进程中num的地址
printf("Address of num in parent precess: %p\n", &num);
}
else if (pid == 0)
{
// 当前是子进程
printf("i am child process, pid : %d, ppid : %d\n", getpid(), getppid());
printf("child num: %d\n", num);
num += 100;
printf("child num += 100: %d\n", num);
// 输出子进程中num的地址
printf("Address of num in child precess: %p\n", &num);
}
// for (int i = 0; i < 5; i++)
// {
// printf("i : %d, pid : %d\n", i, getpid());
// sleep(2);
// }
return 0;
}
最终打印结果发现两地址进程相同,这是因为我们所理解的是虚拟内存地址,虚拟内存地址每个进程都是共享的,而mmu
所映射的物理内存地址是不一样的,通过写操作会拷贝数据到新的物理内存地址。
从操作系统来理解,每个进程有自己的页表,父进程fork
出新的子进程时,子进程拷贝一份父进程的页表,且父子进程将页表状态修改为写保护。当父进程或子进程发生写操作时将会发生缺页异常,缺页异常处理函数将会为子进程分配新的物理地址。由于不同的进程的页表不同,因此访问同样的逻辑地址对应的物理地址才不同。
写时拷贝技术(Copy-On-Write)
当一个进程或线程想要修改共享数据时,会先创建该数据的副本,然后再进行修改。
原始数据保持不变,其他进程或线程仍可读取原始数据的副本。这个策略避免了多个进程同时修改数据时的竞争条件,从而提高了并发性能。
上面的fork()
的实现就是读时共享,写时拷贝,当资源读取的时候内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只有在需要写入的时候才会复制到新的地址空间,从而使各个进行拥有各自的地址空间。
STL标准模板库中的
string
类,就是一个具有写时拷贝技术的类。
通常string
类中必有一个私有成员,其是一个char*
,用户记录从堆上分配内存的地址,其在构造时分配内存,在析构时释放内存。
因为是从堆上分配内存,所以string
类在维护这块内存上是格外小心的,string
类在返回这块内存地址时,只返回const char*
,也就是只读的,如果需要写,只能通过string
提供的方法进行数据的改写。
注意:
- fork之后父子进程共享文件,产生的子进程与父进程相同的文件文件描述符指向相同的文件表,增加 引用计数,共享 文件偏移指针。
- 不同的gcc编译器对共享内存有不同的处理策略。有的环境可能直接是深拷贝,有的环境是共享内容。
父子进程关系及GDB多进程调试
父子进程关系
区别
-
fork()
的返回值不同- 父进程中:>0 返回子进程的ID
- 子进程中:=0
-
PCB 中的一些数据
- 当前的进程的id:pid
- 当前的进程的父进程的id:ppid
- 信号集
共同
当子进程刚被创建出来,还没有执行任何的写数据的操作,以下对象父子进程共享
- 用户区的数据
- 文件描述符表
父子进程对变量是不是共享的?
刚开始是共享的,一旦修改数据共享不了。读时共享,写时拷贝。
exec函数族
什么是exec函数族
在调用进程内部执行一个可执行文件。
exec 函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容。
exec 函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段、据段和堆栈等都已经被新的内容取代,只留下进程ID 等一些表面上的信息仍保持原样;只有调用失败了才会返回 -1并从原程序的调用点接着往下执行。
调用exec 函数族并不是新建一个进程而是只替换用户区的数据
exec 函数族
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
含义区分(一般规律)
- 带
l(list)
:调用程序每个命令行参数需单独写成列表形式并以空指针结束 - 带
p(path)
:如果函数参数file
含/
就视为路径名,否则按 PATH 环境变量指定的目录搜索可执行文件 - 带
v(vector)
:需先构造一个命令行参数的指针数组,然后将数组地址作为调用程序参数 - 带
e(environment)
:需先构造环境字符串指针数组,然后将数组地址传给函数使用新的环境变量代替调用进程的环境变量
事实上,只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execve在man手册第2节,其它函数在man手册第3节。
参数
path
:可执行文件的路径名字file
:按 PATH 环境变量指定的目录搜索可执行文件arg
:可执行程序所带的命令行参数,第一个参数为可执行文件的名字(没什么用,一般写这个),从第二个参数开始就是程序所需参数列表,最后必须以NULL结束argv[]
:命令行参数的指针数组envp[]
:环境字符串指针数组
返回值
执行成功后不会返回,因为调用进程的实体,包括代码段、据段和堆栈等都已经被新的内容取代,只留下进程ID 等一些表面上的信息仍保持原样;只有调用失败了才会返回 -1并设置erron从原程序的调用点接着往下执行。所以通常我们直接在exec函数调用后直接调用perror()和exit(),无需if判断。
进程控制
进程退出
status
:是进程退出时的一个状态信息。父进程回收子进程资源的时候可以获取到。
- 标准 C 库函数
exit()
- 标准 Linux 系统库函数
_exit()
注意:exit()
在调用 _exit()
之前会进行刷新I/O缓冲,由于当 std::endl
或者 \n
被输出时,缓冲区会被刷新,所以数据会被立即显示在屏幕上,而直接调用 _exit()
不会进行刷新I/O缓冲,所以当 std::endl
或者 \n
未被输出时数据不会被显示在屏幕上。
孤儿进程
父进程运行结束,但子进程还在运行(未运行结束),这样的子进程就称为孤儿进程(Orphan Process) 。
孤儿进程没有什么危害,已领养孤儿进程的父进程(内核的 init 进程)会循环地 wait()
已经退出的子进程,最终会处理子进程直到其结束生命周期。
僵尸进程
每个进程结束之后,都会释放自己地址空间中的用户区数据, 内核区的 PCB没有办法自己释放掉,需要父进程去释放。进程终止时,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸进程(Zombie Process)。
僵尸进程不能被 kill -9
杀死(一般调试使用 Ctrl + C
),这样就会导致一个问题,如果父进程不调用 wait()
或 waitpid()
的话,那么保留的那段信息就不会释放,其进程号就会一直被占用, 但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害,应当避免。
进程回收
在每个进程退出的时候,内核释放该进程所有的资源、包括打开的文件、占用的内存等。但是仍然为其保留一定的信息,这些信息主要主要指进程控制块PCB的信息(包括进程号、退出状态、运行时间等)。
父进程可以通过调用 wait()
或 waitpid()
得到它的退出状态同时彻底清除掉这个进程。
wait 函数
pid_t wait(int *wstatus);
-
作用:等待任意一个子进程结束,如果任意一个子进程结束了,则此函数会回收子进程的资源。
-
参数:
wstatus
:退出时的状态信息,传入的是一个int类型的地址,传出参数 -
返回值:如果成功返回被回收的子进程的id;如果失败返回-1(所有的子进程都结束,调用函数失败)
调用 wait()
的进程会被挂起(阻塞),直到它的一个子进程退出或者收到一个不能被忽略的信号才被唤醒(相当于继续往下执行)。如果没有子进程或子进程都结束了,会立即返回 -1 。
退出信息相关宏函数
退出
-
WIFEXITED(status)
:非0,进程正常退出 -
WEXITSTATUS (status)
:如果上面宏为真,获取进程退出的状态(exit()
的参数)
终止
-
WIFSIGNALED(status)
:非0,进程异常终止 -
WTERMSIG(status)
:如果上面宏为真,获取使进程终止的信号编号
暂停
IFSTOPPED (status)
:非0,进程处于暂停状态WSTOPSIG(status)
:如果上面宏为真,获取使进程暂停的信号的编号WIFCONTINUED (status)
:非0,进程暂停后已经继续运行
waitpid函数
pid_t waitpid(pid_t pid, int *wstatus, int options);
- 作用:回收一个指定进程号的子进程,可以设置是否阻塞。
- 参数:
pid
:pid > 0
:某个子进程的pidpid = 0
:回收当前进程组的任意子进程pid = -1
:回收所有的子进程,相当于wait()pid < -1
:某个进程组的组id的绝对值,回收指定进程组中的子进程
wstatus
:退出时的状态信息,传入的是一个int类型的地址,传出参数options
:设置阻塞或者非阻塞0
:阻塞WHOHANG
:非阻塞
- 返回值:
>0
:返回子进程的id=0
:options
=WNOHANG
,表示还有子进程活着= -1
:错误,或者没有子进程了
守护进程
什么是控制终端
在 UNIX 系统中,用户通过终端登录系统后得到一个 shell 进程,这个终端成为 shell 进程的控制终端(controlling Terminal),进程中,控制终端是保存在 PCB 中的信息,而
fork()
会复制 PCB 中的信息,因此由 shell 进程启动的其它进程的控制终端也是这个终端。
默认情况下(没有重定向),每个进程的标准输入、标准输出和标准错误输出都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到显示器上。
在控制终端输入一些特殊的控制键可以给前台进程发信号,例如
Ctrl +C
会产生 SIGINT 信号,Ctrl +\
会产生 SIGQUIT 信号。
什么是进程组
进程组由一个或多个共享同一进程组标识符(PGID)的进程组成。一个进程组拥有一个进程组首进程,该进程是创建该组的进程,其进程ID为该进程组的 ID,新进程会继承其父进程所属的进程组ID。
进程组拥有一个生命周期,其开始时间为首进程创建组的时刻,结束时间为最后一个成员进程退出组的时刻。一个进程可能会因为终止而退出进程组,也可能会因为加入了另外一个进程组而退出进程组。进程组首进程无需是最后一个离开进程组的成员。
进程组和会话在进程之间形成了一种两级层次关系:
- 进程组是一组相关进程的集合。
- 会话是一组相关进程组的集合。
进程组和会话是为支持 shell 作业控制而定义的抽象概念,用户通过 shell 能够交互式地在前台或后台运行命令。
什么是会话
会话是一组相关进程组的集合。会话首进程是创建该新会话的进程,其进程 ID 会成为会话 ID。新进程会继承其父进程的会话 ID。
一个会话中的所有进程共享单个控制终端。控制终端会在会话首进程首次打开一个终端设备时被建立。一个终端最多可能会成为一个会话的控制终端。
在任一时刻,会话中的其中一个进程组会成为终端的前台进程组,其他进程组会成为后台进程组。只有前台进程组中的进程才能从控制终端中读取输入。当用户在控制终端中输入终端字符生成信号后,该信号会被发送到前台进程组中的所有成员。当控制终端的连接建立起来之后,会话首进程会成为该终端的控制进程。
进程组、会话、控制终端之间的关系
关系示例
以下命令的作用是在根目录下搜索所有文件和目录,将标准错误输出(STDERR)重定向到 /dev/null
忽略任何错误消息,然后通过管道将搜索结果的行数(即文件和目录的数量)计数,并在后台运行这个任务,期间允许继续使用终端。
find / 2> / dev /null | wc -l &
以下命令的作用是将 longlist
中的内容按照字母顺序排序,然后统计每个唯一项出现的次数,并以 次数 唯一项
的格式输出。
sort < longlist | uniq -c
以上两组命令关系图
终端显示:
ps:执行完第一组命令后输入 fg
,命令将回到前台运行,并且可以看到它的输出,也可以在需要时终止它。
进程组、会话操作函数
// 获取调用进程的进程组ID
pid_t getpgrp (void) ;
// 获取指定进程的进程组ID
pid_t getpgid(pid_t pid) ;
// 设置指定进程的进程组ID
int setpgid(pid_t pid, pid_t pgid) ;
// 获取指定进程的会话ID
pid_t getsid(pid_t pid) ;
// 创建一个新的会话,并返回其会话ID
pid_t setsid (void) ;
什么是守护进程
守护进程(Daemon Process) ,也就是通常说的 Daemon进程(精灵进程),是Linux中的后台服务进程。它是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件,虽然产生条件和孤儿进程类似但并不是孤儿进程。
守护进程一般采用以 d 结尾的名字。
Linux的大多数服务器就是用守护进程实现的。比如, Internet服务器 inetd,web服务器 httpd等。
守护进程特征
-
生命周期很长,守护进程会在系统启动的时候被创建并一直运行直至系统被关闭。
-
它在后台运行并且不拥有控制终端。没有控制终端确保了内核永远不会为守护进程自动生成任何控制信号以及终端相关的信号(如SIGINT、SIGQUIT)。
-
后台服务程序 – 系统服务,周期性执行某种任务或等待处理某 些发生的事件。
守护进程的创建步骤
- 执行一个
fork()
,之后父进程退出,子进程继续执行。 - 子进程调用
setsid()
开启一个新会话。 - 清除进程的
umask
以确保当守护进程创建文件和目录时拥有所需的权限。 - 修改进程的当前工作目录,通常会改为根目录
/
。 - 关闭守护进程从其父进程继承而来的所有打开着的文件描述符。
- 在关闭了文件描述符0、1、2之后,守护进程通常会打开
/dev /null
并使用dup2()
使所有这些描述符指向这个设备。 - 核心业务逻辑。
代码示例
写一个守护进程,每隔2s获取一下系统时间,将这个时间写入到磁盘文件中。
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/time.h>
#include <signal.h>
#include <time.h>
#include <string.h>
// 写一个守护进程,每隔2s获取一下系统时间,将这个时间写入到磁盘文件中。
void work(int num)
{
// 捕捉到信号之后,获取系统时间,写入磁盘文件
time_t tm = time(NULL);
struct tm *loc = localtime(&tm);
// char buf[1024];
// sprintf(buf, "%d-%d-%d %d: %d : %d\n", loc->tm_year, loc->tm_mon, loc->tm_mday, loc->tm_hour, loc->tm_min, loc->tm_sec);
// print("%s\n", buf);
char* str = asctime(loc);
int fd = open("time.txt", O_RDWR | O_CREAT | O_APPEND, 0664);
write(fd, str, strlen(str));
close(fd);
}
int main(){
// 1.创建子进程,退出父进程
pid_t pid = fork();
if(pid > 0){
exit(0);
}
// 2.将子进程重新创建一个会话
setsid();
// 3.设置编码
umask(022);
// 4.更改工作目录
chdir("/home/zxz/");
// 5.关闭、重定向文件描述符
int fd = open("/dev/null", O_RDWR);
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
// 6.业务逻辑
// 6.1.捕捉定时信号
struct sigaction act;
act.sa_flags = 0;
act.sa_handler = work;
sigemptyset(&act.sa_mask);
sigaction(SIGALRM, &act, NULL);
struct itimerval val;
val.it_value.tv_sec = 2;
val.it_value.tv_usec = 0;
val.it_interval.tv_sec = 2;
val.it_interval.tv_usec = 0;
// 6.2.创建定时器
setitimer(ITIMER_REAL, &val, NULL);
// 6.3.不让进程结束
while (1)
{
sleep(10);
}
return 0;
}
运行后可发现创建time.txt文件,vim进入time文件,键入:e
重新加载文件可看到不断更新的时间。