Linux - 进程 #概念 #操作 #进程状态 #进程优先级 #进程切换 #竞争、独立、并行、并发
目录
前言
一、概念
1、基本概念
2、描述进程-PCB
2.1、OS通用层面(PCB通常包含的内容)
2.2、Linux层面(task_struct结构体)
二、进程操作
1、查看进程信息
1.1 /proc 进程PID
1.2 ps 命令
1.3 top 命令
1.3.1、系统概要信息(前几行):
1.3.2、常用交互式快捷键(在 top 运行时按下)
1.3.3、命令行参数
2、操作
2.1 fork
三、进程状态
1、OS通用层面
1.1 运行状态
2.2 就绪状态
1.3 阻塞状态
1.4 新建状态
1.5 终止状态
1.6 挂起状态
2、Linux 内核
关于孤儿进程:
四、进程优先级
1、 为什么需要进程优先级?
2、什么是进程优先级
3、相关命令
3.1、ps -l :
3.2、top
4、PRI 与 NI
五、进程切换
1、什么是进程切换
2、Linux系统如何进行进程调度
关于优先级:
关于活动队列:
关于过期队列:
关于active 指针和expired 指针
六、关于竞争、独立、并行、并发
总结
前言
路漫漫其修远兮,吾将上下而求索;
注:下述内容中出现的源码若没有说明, 默认来自:Linux-2.6.18
一、概念
1、基本概念
- 课本概念:程序的一个执行实例,正在执行的程序……
- 内核概念:担当分配系统资源(CPU时间、内存……)的实体
在操作系统中,进程(Process) 是正在执行的程序的实例。它不仅仅是程序的代码(文本段),还包括当前的活动(如程序计数器、寄存器的值)、管理进程所需的数据结构以及程序运行时所使用的资源(如打开的文件、地址空间等)。简单来说,程序是静态的指令集合,而进程是动态执行的实体。
每个进程都在各自受保护的地址空间中运行,这是操作系统实现进程隔离、保证系统稳定性和安全性的关键;
2、描述进程-PCB
进程的所有信息都被操作系统维护在一个称为进程控制块(PCB, Process Control Block) 的数据结构中,可以理解为进程属性的集合;当进程被创建时,PCB也随之创建;进程终止时,PCB也被销毁。PCB是进程存在的唯一标志;
2.1、OS通用层面(PCB通常包含的内容)
进程标识符(PID): 唯一标识一个进程的数字ID
用户标识符(UID) 和 组标识符(GID): 决定进程的权限和所有权
状态(State): 进程的当前状态(如运行、就绪、阻塞等)
优先级(Priority): 决定进程被调度执行的顺序
程序计数器(PC): 指向下一条要执行的指令的地址
CPU寄存器: 当进程被切换时,需要保存所有寄存器的状态,以便后续恢复
CPU调度信息: 调度队列指针、调度参数等
内存管理信息: 基址/限址寄存器、页表或段表的指针等,用于描述进程的地址空间
记账信息: CPU使用时间、时间戳、作业/进程数量等
I/O状态信息: 分配给进程的I/O设备列表、打开的文件列表等
2.2、Linux层面(task_struct
结构体)
在Linux中,PCB的实现是一个名为 task_struct 的结构体。task_struct 中包含了OS层面PCB的所有通用信息,并有许多Linux特有的扩展;task_struct 是Linux 内核的一种数据结构,在加载进程的时候,操作系统会在内存(RAM)中创建一个task_struct的对象;
进程 = PCB(内核数据结构) + 代码和数据;
- 对于进程排队:是让该进程的PCB去排队,本质就是从一个数据结构,把节点拿走放入新的队列数据结构之中;
- 对于进程调度:存在一个调度队列runqueue来管理运行时的进程;而选一个进程放入CPU之中就可以理解为从调度队列中拿到对应进程的PCB信息,从PCB信息中找到对应可执行程序的代码和数据,再把其代码、数据“喂”CPU便可;其中“选”涉及进程优先级的问题;
CPU这个硬件在计算机中主要是用来进行计算的,但CPU很笨,只能执行被人给他的代码,程序计数器(PC指针)就是负责记录CPU所要执行的下一条指令的地址:
注:至于PCB中会保存EIP寄存器中的数据,是为了记录进程切换时执行到了哪一条语句,当该进程切换回来的时候,继续从该语句往后执行;
- 进程在启动的时候,可能会运行很长时间,所以进程在CPU运行时,一方面,CPU中会存在大量的寄存器,而寄存器只有一套,当进程运行时会在CPU中形成大量临时数据存放在寄存器之中(eg. 下一条指令的地址会存放在EIP寄存器中,正在执行指令的内容存放在IR寄存器中……),而进程并不会关心寄存器,进程关心的是寄存器里面的内容,因为寄存器中的内容是该进程自己的数据:进程的硬件上下文;进程在被执行的时候,是会被切换、调度的(体现出了进程的动态属性),那么当进程切换回来的时候,就需要恢复上一次运行时的硬件上下文数据,即将保存的上下文数据放回CPU的寄存器中,便就可以继续从曾今执行的位置接续执行;
注:在Linux-0.11 版本中,硬件上下文数据被保存在tss_struct 中;
二、进程操作
1、查看进程信息
1.1 /proc 进程PID
查看进程的信息可以通过 /proc 系统文件进行查看;
eg. 要获得PID为1的进程信息,只需要查看 /proc/1 这个文件夹;
/proc 中 进程PID对应的目录中存放的是该进程的相关属性:
所以说,程序利用 fopen 函数新建一个文件默认是在当前工作路径下新建就是因为创建文件时获取了 当前进程的cwd + 文件名,拼接成的绝对路径;
Q:修改当前进程的cwd ,新建文件的位置也会修改吗?
更改当前进程工作路径的系统调用:chdir
chdir(2) System Calls Manual chdir(2)NAMEchdir, fchdir - change working directoryLIBRARYStandard C library (libc, -lc)SYNOPSIS#include <unistd.h>int chdir(const char *path);
实践出真知:
#include<iostream>
#include<stdio.h>
#include<unistd.h>int main()
{chdir("/home/zjx/Test");FILE* fp = fopen("hello.txt" , "w");pid_t pid = getpid();while(true){std::cout << "i am a process: " << getpid() << std::endl;sleep(1); }return 0;
}
1.2 ps 命令
提供的是静态的、一次性的进程快照。你运行它,它给你那一刻的进程状态,然后就结束;
常用组合:ps axj
-
a
: (all)列出所有用户的进程(而不仅仅是当前终端的进程) -
x
: 列出包括不控制任何终端(TTY)的进程(通常是重要的系统守护进程)-
ax
组合在一起,意味着显示系统中的所有进程,等价于-e
(显示所有进程)
-
-
j
: 使用作业控制格式。这个选项是关键,它增加了关于进程父子关系、进程组和会话的信息;
可利用管道对特定名称的进程进行过滤, 如下:
ps axj | head -1 && ps axj | grep test
PPID | Parent Process ID | 当前进程父进程ID;这是理解进程层次关系最关键的一列。例如,test(PID 2604773) 的父进程PID 2601006 |
PID | Process ID | 当前进程自身的ID;进程的唯一标识符 |
PGID | Process Group ID | 进程组ID;一组相关进程的集合。通常,shell 会为它启动的每个管道或作业创建一个新的进程组 |
SID | Session ID | 会话ID;一个会话包含一个或多个进程组。通常,一个登录终端(如 sshd 或 getty )会创建一个新的会话 |
TTY | Controlling Terminal | 控制终端; ? 表示该进程没有控制终端(通常是守护进程) |
TPGID | Terminal Process Group ID | 终端前台进程组的ID;与当前终端相关的信息,对于后台进程显示为 -1 |
STAT | Process State | 进程状态码; 与 ps aux 中的相同,是进程当前状态的字母编码 |
UID | User ID | 进程所有者的用户ID |
TIME | CPU Time | 进程累计使用的CPU时间 |
COMMAND | Command | 启动进程的命令名称 |
其中,第二行:
- 在命令行中运行的指令是系统 /usr/bin 路径下所对应的二进制文件;而在执行这样的命令时,本质上是将该命令加载进内存变成进程执行该命令对应的功能;当然,grep 也是一个指令,执行 ps axj 命令让 grep 过滤时,grep 也是一个进程, 因 grep test 包含了关键词,所以grep 过滤的时候将自己也过滤了出来,所以我们观察进程test 的信息时,只需要看第一行的就行了;倘若,不想要看到过滤出来的grep ,可以在结尾加上 "grep -v grep" , -v 反向匹配,如下:
ps axj | head -1 && ps axj | grep test | grep -v grep
循环查询(间隔1秒):
while :; do ps axj | head -1 && ps axj | grep test;sleep 1;done
1.3 top 命令
top
提供了一个全屏的、交互式的实时系统监控视图。启动后,它会一直运行,直到你按下 q
键退出;
如下:
1.3.1、系统概要信息(前几行):
-
第一行: 系统当前时间、运行时间、登录用户数、系统平均负载(1分钟、5分钟、15分钟的平均值)
-
第二行: 任务(进程)总数,以及它们的状态(运行、睡眠、停止、僵尸)
-
第三行: CPU 使用率百分比。
us
(用户空间)、sy
(内核空间)、ni
(低优先级进程)、id
(空闲)、wa
(I/O等待)、hi
(硬中断)、si
(软中断)、st
(被虚拟机偷走的时间) -
第四行: 物理内存使用情况(总量、已用、空闲、缓冲)
-
第五行: 交换分区(Swap)使用情况
- 进程列表: 默认按 CPU 使用率降序排列,显示各个进程的详细信息,列的含义与
ps
类似。
1.3.2、常用交互式快捷键(在 top
运行时按下)
top
的强大在于其交互性:
-
q
: 退出top
-
k
: 杀死进程。输入 PID 和要发送的信号(默认是 SIGTERM,即15号信号) -
P
(大写): 按 CPU 使用率 排序(默认) -
M
(大写): 按 内存使用率(RES) 排序 -
T
: 按 CPU 时间(TIME+) 排序 -
N
: 按 PID 排序 -
r
: 重设进程的优先级(Nice值)。输入 PID 和新的 Nice 值(-20 到 19,值越小优先级越高) -
d
或s
: 改变刷新间隔(默认3秒) -
h
: 显示帮助。 -
1
(数字1): 如果有多核CPU,可以切换显示每个CPU核心的详细使用情况 -
Z
: 改变颜色方案 -
W
: 将当前配置(如排序方式、颜色)保存到~/.toprc
文件,下次启动会生效
1.3.3、命令行参数
-
top -d 5
: 启动top
并设置刷新间隔为 5 秒; -
top -p 1234,5678
: 只监控 PID 为 1234 和 5678 的特定进程; -
top -u john
: 只显示属于用户john
的进程;
2、操作
-
创建:
fork();
fork()
创建子进程(复制父进程地址空间),exec()
系列函数加载新程序到当前地址空间。 -
终止:
exit()
(由进程自身调用) -
等待/回收:
wait()
,waitpid()
, (由父进程调用,回收僵尸子进程)
2.1 fork
查看当前进程PID的系统调用:getpid() , 获取当前进程的父进程PID的系统调用:getppid();
- PID :进程id
- PPID:父进程id
getpid(2) System Calls Manual getpid(2)NAMEgetpid, getppid - get process identificationLIBRARYStandard C library (libc, -lc)SYNOPSIS#include <unistd.h>pid_t getpid(void);pid_t getppid(void);
Q: 系统调用 getpid 、getppid 从何处获得的数据?
- 从该进程的task_struct 中获得;进程task_struct 中保存着当前进程的PID以及其父进程的PPID;
#include<iostream>
#include<unistd.h>int main()
{pid_t pid = getpid();pid_t ppid = getppid();while(true){printf("当前进程pid:%d , 其父进程pid:%d\n" , pid , ppid);sleep(1);}//父进程return 0;
}
- 这是因为,在命令行上启动我们的程序时,其父进程均为bash,即我们在命令行上每启动一个进程均是bash 调用系统调用 fork 来创建的子进程,由bash 的子进程来执行我们的代码;
fork: 创建一个子进程
fork(2) System Calls Manual fork(2)NAMEfork - create a child processLIBRARYStandard C library (libc, -lc)SYNOPSIS#include <unistd.h>pid_t fork(void);RETURN VALUEOn success, the PID of the child process is returned in the parent, and 0 isreturned in the child. On failure, -1 is returned in the parent, no childprocess is created, and errno is set to indicate the error.
返回值:成功,对于父进程fork返回该成功创建子进程的pid ,对于子进程中fork 的返回值为0;失败,则返回-1 ,子进程创建失败;
Q:返回值为什么是这样的?创建子进程成功,既要给父进程返回所创建子进程的pid , 又要给子进程返回0?
- 之所以fork 需要给父进程返回所创建子进程的pid 是因为,一个父进程可可以创建多个子进程,而一个子进程只能有一个父进程,所以子进程获取其父进程的pid 是很容易的,但父进程面对自己众多子进程就难以知道创建的子进程为哪一个;故在调用fork 创建子进程成功之后需要向父进程返回所创建子继承的pid, 让父进程在自己的空间中将子进程的pid保存起来,将来便可以在此父进程中实现对子进程的控制;而至于给子进程返回0,可以以此来确认子进程自己是否被创建成功了;
Q:一个函数为什么存在两个返回值?
进程 = 内核数据结构(task_struct) + 代码和数据;
- 在Linux 中,创建一个新的子进程,子进程的task_struct 以其父进程为模板进行初始化,即默认共享父进程的代码和数据;而进程需要保持独立性,故当父、子进程其中有一方对数据进行修改就会引发写时拷贝;而return 的本质是写入,在fork return 之前子进程就已经创建好了,所以在return 时触发了写时拷贝,即调用fork 创建子进程成功后,对父进程返回所创建子进程的pid , 对子进程返回0;
利用fork 的特性,可以让父子进程执行不同的代码,从而实现让父子进程去执行不同的任务;
三、进程状态
Q:什么是进程状态(process status )?
- 状态决定了进程接下来所要做的动作;
进程状态本质上是一个数字,而所谓让进程的状态发生变化的本质就是OS去修改当前进程task_struct 中存放进程状态所对应的整数值;
1、OS通用层面
进程的状态在其生命周期中会动态变化;
-
运行状态: 进程正在CPU上执行指令。
-
就绪状态: 进程已经准备好了所有运行所需的资源(除了CPU),只要CPU空闲,随时可以投入运行;通常会有多个进程处于就绪态,它们排成一个“就绪队列”,等待调度器选择。
-
阻塞状态:
-
进程在等待某个事件的发生,这个事件可能是I/O操作完成(如读取磁盘、等待网络数据)、获取一个锁、或者等待某个信号等;
-
在等待期间,进程无法继续执行,即使CPU空闲也不行;
-
这些进程会根据等待的事件不同,排在多个“阻塞队列”中;
-
-
新建状态:进程正在被创建,但尚未进入就绪队列;操作系统正在为其分配PCB(进程控制块)、加载程序等;
-
终止状态:进程已结束运行,操作系统正在回收其占用的资源(内存、打开的文件等),但其PCB信息仍然保留,直到父进程读取其最终状态。
挂起状态:当OS内存资源匮乏时,会将暂时不能运行的进程(尤其是阻塞状态进程)的映像(代码、数据、堆栈等)从内存交换到磁盘上的交换区swap 上,从而释放其占用的内存空间给其他就绪进程使用。这个被换出的状态就称为挂起状态,挂起状态有两种:就绪挂起、阻塞挂起:
-
就绪挂起:
-
进程最初在就绪态,但系统内存不足。为了给更紧急的进程腾出空间,操作系统将该进程的 映像(代码、数据) 从内存换出到磁盘;
-
特点:进程的数据代码不在内存中,但一旦其数据、代码被换入内存,该进程立刻处于就绪态,可以参与CPU的竞争;
-
-
阻塞挂起:
-
进程最初在阻塞态,等待某个事件(如I/O);为了节省内存,操作系统将其换出到磁盘。
-
特点:进程的代码、数据不在内存中,并且它等待的事件尚未发生。即使事件发生了,因为进程的代码、数据不在内存,它也无法立即被调度;
-
1.1 运行状态
根据冯诺依曼体系结构,将计算机硬件划分为五大单元:输入设备、输出设备、运算器、控制器、内存;
进程竞争资源,本质竞争的是两类资源:
- CPU资源
- 外设资源
而我们所写的代码无非分为两类:
- 计算密集型 (eg. 算法、数据结构代码)
- IO密集型 (eg. 使用外设资源)
运行(Running): 进程正在CPU上执行指令;
2.2 就绪状态
所有进程未来要去竞争CPU资源,在大部分OS内部,会给每个CPU设置一个调度队列;显然若存在多个CPU ,那么OS内部就会存在多个队列,OS肯定是需要对这些队列进行管理的;未来CPU要调度并不是直接跑到管理全局进程的双链表中去获取,而是让进程PCB在CPU的调度队列中进行排队,CPU直接从自己的调度队列中获取进程便可了;进程已经准备好了所有运行所需的资源(除了CPU),只要CPU空闲,随时可以投入运行,此时进程所处的状态就称为就绪状态;
Q: 将进程PCB放入调度队列中需要进行断链吗?
- 不需要;
Linux 内核当中实现的双链表并非像我们所学习的双链表那样,而是如下:
双链表节点不包含data 并内置到task_struct 之中;
源码:
Q : 链表这样实现,如何访问task_struct 中的全部属性?
也就说,链表这样实现,如何知道一个结构体内部任意一个成员的起始地址以及类型?
- 对于数组来说,数组首元素地址 = 元素地址 - 元素偏移量; 对于结构体来说也是同理,对于一个int 类型的变量,该变量的地址为4个地址中最小一个,而类型4byte 来表明其占用的空间大小,我们访问这个变量只要知道这个变量的起始地址以及所占据的空间大小就可以拿到这个变量的数据,也就是说,类型也可以看作偏移量;
换句话说,a的地址 = c的地址 - 偏移量, 求c的偏移量:&((struct test*)0->c) , 那么 a的地址 = c的地址 - &((struct test*)0->c) --> struct test* start = &c - &((struct test*)0->c);
那么对于 struct task_struct 来说也是同理, struct task_struct* start = &node - &((struct task_struct*)0->node);
这样实现的双链表,就再也与类型无关了;
倘若在task_struct 中像node 这样的节点不止一个,如下源码:
那么,这样做就使得pcb 既属于链表,也可以同时属于其他数据结构;
对于CPU的调度队列:
在task_struct 之中新加一组node ,就可以保证在不将该进程从全局链表中断链的情况下照样也可以将数据节后添加到另外一个队列结构中,也就是说,未来一个进程的pcb 可以保证自己在全局链表中统一管理的同时,可以链入运行队列之中;
1.3 阻塞状态
操作系统是对软硬件资源进行管理的软件;而想要进行管理,就需要先描述,即OS中有描述硬件的结构体;
OS不断地通过驱动程序访问到硬件的状态,更新硬件的属性信息……那么在操作系统中对硬件地管理就转换成对硬件链表的增删查改;
eg.
int main()
{int a = 0;scanf("%d" , &a);printf("%d\n" , a);return 0;
}
上述代码中,当代码执行到scanf 的时候会检测键盘是否就绪,若数据未就绪就会“阻塞”住;
- 因为CPU调度进程的时候,只有进程在CPU的调度队列之中才有机会被调度,若键盘上没有数据,在OS层面上就不应该调度该进程,因为即使调度了这个进程也读取不到数据,所以我们只需要将当前进程从调度队列中断链,将该进程的PCB移动到键盘所对应结构体中的等待队列中等待键盘的资源,此时该进程就不会被CPU调度;即,进程在外设的等待队列中“排队”,等待外设资源就绪的过程,该进程所处的状态称为阻塞状态;即使此进程阻塞了也丝毫不会影响CPU去调度执行其他进程;
而在外设的等待队列中“排队”,本质是为了竞争外设资源;而因为OS是软硬件资源的“管理者”,当外设资源就绪的时候只有OS知道;当OS发现键盘中的数据准备好了,就会将在键盘等待队列中进程的状态设置未R状态……并将该进程的pcb链入CPU的调度队列中……
1.4 新建状态
一个进程正在被创建或者新建的进程会先放在在全局链表里,并未添加到CPU的调度队列之中,这样的状态称之为新建状态;
1.5 终止状态
进程已结束运行,操作系统正在回收其占用的资源(内存、打开的文件等),但其PCB信息仍然保留,直到父进程读取其最终状态。
1.6 挂起状态
若一个进程依靠于键盘资源,但键盘未就绪,就会将该进程的pcb 链入键盘的等待队列中,键盘没有准备好该进程就不会被调度,即不会访问该进程的代码和数据;倘若在该进程等待期间,内存资源严重不足,OS会进行内存管理;例如:阻塞的进程在等待键盘资源,其代码和数据仍然在内存之中,但该进程在键盘资源未就绪之前就不会执行,占着资源却不使用就是对内存资源的浪费,那么OS就会将该进程的代码、数据换入磁盘中,将对应的内存空间释放;当键盘上的数据准备好了,就将该进程从设备等待队列换到CPU的调度队列中,并重新加载曾经放入磁盘中的该进程的代码、数据加载到磁盘当中……
其中,当进程在内存当中只剩PCB,代码和数据被换出到磁盘时的状态称为挂起状态;
其中swap 分区一般等价于内存大小或者是1.5倍、2倍;不能太大,太大就会让OS依赖于swap分区而导致OS效率下降;不能太小,若太小,则达不到交换的功能;
另外,内存资源不足也会分为等级:
- 阻塞状态进程的代码、数据被换入swap分区当中:阻塞挂起状态
- 就绪状态进程的代码被换入swap分区当中:就绪挂起状态
2、Linux 内核
Linux内核在 task_struct
中通过 state
字段维护进程状态,其取值是预定义的宏;
-
R : running(可运行), 对应OS的运行和就绪状态。进程要么正在CPU上执行,要么在运行队列中等待执行。
-
S : sleeping(可中断睡眠): 对应OS的阻塞状态。进程正在等待某个条件或资源,比如等待用户输入、等待网络数据、等待子进程退出。在此状态下,进程可以被信号(Signal)唤醒,即接收、处理信号;
-
D: disk sleep(不可中断睡眠): 也是一种阻塞状态,特殊的阻塞状态,通常发生在等待底层硬件I/O时,如磁盘I/O。进程在此状态下不能被信号中断或杀死(即使是
SIGKILL
也不行)。这是为了防止在磁盘I/O进行到一半时,如果进程被杀死,可能导致文件系统处于不一致的状态。这是为了确保进程在完成关键操作前不被意外中断。使用ps
命令查看时显示为D
,是系统负载高的一种可能原因。 -
T : stopped(暂停状态): 进程的执行被暂停(Stopped);用户按下
Ctrl+Z
会向进程发送SIGTSTP
信号(19号信号),使其进入T状态,当收到SIGCONT
信号(18号信号)时,会继续执行。要么是用户主动发送19号信号,要么是该进程存在非法操作但无伤大雅,于是OS将该进程暂停; -
t : tracing stop (追踪状态) : 调试器(如gdb)在断点处停下来时,被调试的进程也处于t状态;
-
Z : zombie(僵尸状态):进程已经结束运行,其内存和资源已被释放,但它的内核数据结构task_struct 仍然保留,等待其父进程读取其退出状态码。如果父进程没有正确地通过
wait()
或waitpid()
系统调用来“收尸”,这个僵尸进程就会一直存在,进而导致内存泄漏; -
X :dead(死亡状态): 最终状态。进程完全死亡,即将被销毁。这个状态用户通常看不到,因为进程在此状态下存在的时间极短。父进程进行了
wait()
或waitpid()
调用后,进程的所有资源被系统回收,PCB被删除;
查看命令:在Linux终端中使用 ps aux / ps axj 或 top
命令,在 STAT
列可以看到进程的状态代码(如 R
, S
, D
, T
, Z
)。
include/linux/sched.h
头文件中,可以发现 state
字段维护进程状态,其取值是预定义的宏:
/** Task state bitmask. NOTE! These bits are also* encoded in fs/proc/array.c: get_task_state().** We have two separate sets of flags: task->state* is about runnability, while task->exit_state are* about the task exiting. Confusing, but this way* modifying one set can't modify the other one by* mistake.*/
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_STOPPED 4
#define TASK_TRACED 8
/* in tsk->exit_state */
#define EXIT_ZOMBIE 16
#define EXIT_DEAD 32
/* in tsk->state again */
#define TASK_NONINTERACTIVE 64
关于孤儿进程:
父进程如果提前退出,那么其子进程就会变为“孤儿进程” ;孤儿进程会被1号进程 init 领养,当子进程退出的时候,由1号进程进行回收;
四、进程优先级
1、 为什么需要进程优先级?
在一个多任务系统中,CPU资源是有限的。并非所有进程都"生而平等"。有些进程需要立即响应(如用户的键盘输入、视频播放),而有些进程则可以慢慢执行(如后台文件索引、数据备份)。
优先级机制的核心目的:确保高优先级的、交互性强的进程能够获得更及时的CPU响应,从而提升系统的整体响应速度和用户体验,同时又能让低优先级的任务在系统空闲时充分利用资源。
2、什么是进程优先级
进程优先级是进程得到某种资源的先后顺序;
优先级存放在进程的task_struct 之中;每个CPU均拥有调度队列runqueue, 将要执行的进程链入调度队列之中,根据其调度算法调度进程……而进程也会访问网卡、磁盘键盘等资源,这些资源只有一个,所以进程想要访问这些资源也需要进行排队,而排队的本质就是在确认优先级;
3、相关命令
3.1、ps -l :
- UID: 执行者身份
- PID:当前进程的ID
- PPID:当前进程父进程的ID
- PRI:当前进程的优先级,数值越小优先级越高即越早被执行
- NI:当前进程的nice值
3.2、top
更改已存在进程的nice 值:
使用top 命令进入top 后,按 'r' 输入进程PID,然后再输入nice值;
使用如下:
top -> r -> PID -> -10(nice):
4、PRI 与 NI
PRI (priority)为当前进程的优先级,数值越小优先级越高,越早被执行;固定值为80;
NI(nice)为当前进程的nice 值;
Q:什么是nice 值?
- nice 值表示进程可被执行的优先级的修正数值;PRI(new) = PRI(old) + nice
nice 的取值范围为 [-20,19] , 一共40个级别; 当nice 的值为负数的时候,该程序的PRI数值会变小,即优先级变高,也就是说会越早被执行;
需要注意区分PRI与NI并不是一个概念,PRI表示该进程的优先级,而NI会影响到进程优先级的变化;可以将nice 值理解为进程优先级的修正数据;
Q:为什么PRI的固定值为80?为什么不直接修改PRI,而是需要通过nice 值来修改PRI?
- 如果PRI不固定,将来想要想要调整一个进程的优先级所要做的第一件事就是查看进程的优先级,然后再修改;固定了以后,就不用关心该进程曾经的优先级,直接使用nice 值进行修改便可,固定PRI可以简化操作;而至于为什么不直接修改PRI,这是因为
nice
为上层应用提供了一个统一的表达方式,它允许进程友好地(nicely)声明自己希望获得更多或更少的 CPU 时间。这种相对性,配合严格的权限控制(普通用户只能降低优先级),有效防止了资源独占,保证了系统整体的公平性和稳定性。而在内核层面,不同的调度器可以根据当前系统的负载和调度策略,灵活地将这个固定的nice
值范围映射到动态变化的实际优先级上,这使得调度算法可以不断优化和演进,而无需改变用户的行为和已有的应用程序,从而兼顾了历史的兼容性与内核发展的灵活性。
Q:为什么nice 值得取值范围为[-20,19]?
- 首先nice 值有范围,是因为分时操作系统需要尽可能地公平公正,所以优先级地调整也需要在可控地范围内进行变化;故Linux 进程的优先级本身是有范围的 --> [60,99], 共40个级别;而至于nice 值的范围为 [-20,19] ,这个范围的设计是出于历史原因和实际考虑。在早期的Unix系统中,优先级级别是0到39,而nice值被定义为对这个基础优先级的调整。用户可以通过nice值来降低或提高优先级,但为了避免普通用户过多地提高优先级(即降低nice值),系统设计为只有root用户可以将nice值设置为负数(提高优先级),而普通用户只能设置正数(降低优先级)。此外,这个范围提供了足够的粒度来调整优先级,同时不会让优先级级别过多以至于难以管理。另外,在Linux中,内部表示的优先级(PRI)和nice值之间的关系是:PRI = PRI(初始) + nice,但注意初始优先级通常为80(在ps命令中看到的是80),所以PRI的范围是80-20=60到80+19=99。但是,实际上,Linux的实时优先级范围是0到99,而普通进程的优先级在100到139之间(对应nice值-20到19)。而实际在内核中,普通进程的优先级是100+nice(因为100+(-20)=80, 100+19=119,但ps命令显示的PRI是内部优先级减去100,所以显示为-20到19)。这种设计使得实时进程(优先级0-99)总是比普通进程(优先级100-139)有更高的优先级。故,nice值的范围是-20到19,对应普通进程的内部优先级100到139,共40个级别。
我们可以验证一下nice值得取值范围:
五、进程切换
1、什么是进程切换
进程切换(Process Switching或Context Switching)简单来说是指操作系统暂停当前运行的进程,保存其状态,并恢复另一个进程的状态,使其继续执行的过程。
其中“保存其状态”,是指保存CPU寄存器中的数据到当前进程自己的栈中,而“恢复另一个进程的状态”是指当前进程将寄存器中的数据入栈之后会将下一个所要运行进程的当前状况从该进程的栈中重新放入CPU的寄存器;
进程切换使得单个CPU能够通过时间分片技术"同时"运行多个进程;
需要注意的是,CPU中的寄存器只有一套,寄存器是存储设备,但是上下文可以有多份,分别对应不同的进程;CPU寄存器中保存的是当前所运行的这个进程的上下文数据;
- 如上图,进程A暂时被切下来的时候,需要进程A顺便带走并保存自己的上下文数据,目的就是为了下次回来的时候能恢复,这样就可以按照之前执行的逻辑继续往后执行;
Q:函数的返回值,本质就是函数内部的变量,而函数内部的变量,是具有临时性的,即只在其所在函数内部有效,那么返回值又是如何被外部函数获得的呢?
- 返回结果会写入寄存器中;
eg. int result = func(); 函数调用完全会将结果写在寄存器当中,而"result = " 会被翻译为 "将寄存器的值写回内存当中",即将寄存器中的值写入rusult 所处的物理空间,此时临时变量result 便拿到了函数 func 的返回值;进程在运行的时候,CPU中的寄存器会保存当前进程运行时产生的临时数据,而函数返回值就是这些临时数据的一种;
上文讲过,CPU寄存器中的上下文数据保存在当前进程的栈区,如果随便存放复原的时候必定“摸不着头脑”,上下文数据保存在tss_struct(任务状态段)中(注:tss 英文为 task state segment)
源码(LInux - 0.11 版本代码)如下:
struct tss_struct 源码如下:
struct tss_struct {long back_link; /* 16 high bits zero */long esp0;long ss0; /* 16 high bits zero */long esp1;long ss1; /* 16 high bits zero */long esp2;long ss2; /* 16 high bits zero */long cr3;long eip;long eflags;long eax,ecx,edx,ebx;long esp;long ebp;long esi;long edi;long es; /* 16 high bits zero */long cs; /* 16 high bits zero */long ss; /* 16 high bits zero */long ds; /* 16 high bits zero */long fs; /* 16 high bits zero */long gs; /* 16 high bits zero */long ldt; /* 16 high bits zero */long trace_bitmap; /* bits: trace 0, bitmap 16-31 */struct i387_struct i387;
};
2、Linux系统如何进行进程调度
以Linux-2.6.18内核版本为例:
注:一个CPU只有一个runqueue,如果有多个CPU就需要考虑负载均衡问题;
关于优先级:
- 普通优先级:在queue[140] 中的下标范围为:[100,139] 我们所创建的进程的优先级为普通优先级
- 实时优先级:在queue[140] 中的下标范围为:[0,99];现阶段暂时不管这个实时优先级;
PRI(new) = PRI(old) + nice , nice 值的取值范围为[-20,19] 一共40个级别;
注:PRI:priority 优先级; nice 值并不只进程优先级,它会影响进程优先级的变化;
联想:nice 取值范围为[-20,19]即40个级别 , 进程优先级 [60,99] + 40 --> 普通优先级在queue[140] 中下标范围:[100,139] ;
将来任何一个进程创建出来都是有优先级的,有优先级则在runqueue 中找到queue[140] , 根据优先级将该进程插入到指定的位置中,将来CPU在调度的时候,以queue[140] 的下标100 开始,检查当前位置中的指针是否为空,不为空就拿到队列中的进程进行切换,为空就继续向后遍历……但是若所有进程的优先级为99,也就意味着queue[140] 中下标为100~139 均为空,只有下标139 中有进程,而CPU需遍历整个数组才能找到所要执行的进程,这样做的效率还是比较低下,所以runqueue 还提供了 bitmap[5]位图来支持快速查找,其中bitmap[5] 中比特位的位置表示,在数组中的第几个队列,而比特位中的内容表明该队列是否为空;
联系之前所学习的知识点,我们大致可以了解:创建一个新进程,首先会创建pcb ,那么该进程也有了优先级,入队列:将当前进程的pcb 通过优先级映射放入runqueue 中的queue[140] 所对应的下标,再将该进程链入指定下标的尾部;
Q: 为什么runqueue 中存在2个nr_active、bitmap[5]、queue[140] ?
关于活动队列:
- 时间片还没有结束的所有进程都按照优先级放在该队列
- nr_active: 记录处于运行状态进程的个数
- queue[140]: 其中的一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以数组下标就是优先级;
- bitmap[5]: 一共140个优先级,即140个进程队列,为了提高查找非空队列的效率,可以使用这5*32 bits 来表示所对应的队列是否为空;
关于过期队列:
- 过期队列和活动队列的结构一摸一样
- 过期队列上放置的进程,都是时间片耗尽的进程
- 当活动队列上的进程均被处理完毕之后,会对过期队列中进程的时间片重新计算
关于active 指针和expired 指针
- active 指针永远指向活动队列
- expired 指针永远指向过期队列
回答上述问题,为什么runqueue 中存在2个nr_active、bitmap[5]、queue[140] ?也就是说为什么需要active 指向活动队列,expired 指向过期队列?
- CPU在调度的时候,直接根据active 指针找到queue[140] , 查找bitmap[5] ,挑选了一个进程到CPU上执行,当时间片到了之后,该进程不能放回到active 所指向的queue[140] 上,而是应该链入过期队列中,即expired指针所指向的queue[140] 之中;简单来说就是,新进程、时间片到了的进程,被从CPU上剥离下来只能重新入过期队列……在一个大的调度周期中,CPU会把当前队列的所有进程全部调用完,即活跃队列中的进程越来越少,重新按照优先级迁移到了过期队列中,当active 指向的queue[140] 为空的时候,只需要与expired 交换便可,然后重复上述过程;
源码:
Q1:为什么nice 值得取值范围为 [-20,19] ?
- 因为Linux 内核中进程得优先级设定为40个等级(从queue[140] 下标[100,139] 40个等级),而默认优先级从80开始,且进程优先级得取值范围为[60,99], 所以nice 值得取值范围为 [-20,19];
Q2: 调整了当前进程得优先级还会做什么?
- 除了会修改进程pcb , 还得将此进程从当前的调度队列中迁移到对应queue[140]下标的调度队列之中,这样操作比较麻烦,实际上会这么做:修改进程pcb 中的nice 值,本轮调用中该进程的优先级为80,正常调用,当时间片到了之后,会将其nice 值加入PIRO(old) 中重新计算该进程的优先级并重新插入到过期队列中对应的队列中,下一次调用便是按照新优先级调用该进程,这样操作只需要调整一次便可;
Q3:在当前这种调度算法中会存在饥饿问题吗?
- 不会,因为这种调度算法在局部上总会将所有活跃的进程全部调度完才会进入下一轮调度……局部上有优先级,整体上就不会造成饥饿问题,这也是为什么需要使用两个queue[140] 的原因;(相当于只有一个queue[140] 调度完才会区调度下一个queue[140])
Q4: 进程的新建状态是什么?
- OS中的新建状态与Linux 中的新建状态稍有不同;OS中进程的新建状态;新建出来的进程是在过期队列中的,当active、expired 进程切换,就可以调度该新建进程了;但在Linux 中不做区分,只要在runqueue 中的进程均叫做R(运行)状态;
Q5:queue[140] 中 下标 0~99是给谁用的?
- 操作系统不仅在互联网领域被使用,OS本身也可能在工业领域中被使用;eg.汽车的车载系统,汽车的传感器采集到前方有障碍物,汽车的辅助驾驶功能需要接管汽车进行刹车,于是创建了一个进程让CPU去执行刹车所对应的代码,但介于Linux 公平公正的调度算法,刹车这个进程只能去排队……本应该直接执行刹车进程进行刹车,若让刹车进程去排队,这是绝对不允许的;所以OS在功能上分为两类:1、分时操作系统 2、实时操作系统;实时操作系统就是使用queue[140] 中[0,99] 来支持实时操作系统的功能;
六、关于竞争、独立、并行、并发
竞争性:系统进程数目众多,而且CPU资源只有少量,甚至1个,所以进程之间具有竞争性。为了高效完成任务,且更加合理地竞争相关资源,于是便有了进程优先级;
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰,进程和进程之间是相互隔离的;一个进程在运行期间,其启动、崩溃均不会影响到其他进程;
并行:多个进程在多个CPU下分别、同时进行运行;
并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进;
联系生活,我们在笔记本电脑中起的任务越来越多的时候,电脑会变得卡顿,除了资源不足之外,还有可能体现为CPU调度一个进程(从一个进程切换到另外一个进程的周期变长了),故而让我们感受到了“卡”;
进程独立性的价值:
- 进程在系统中被调度的时候,执行的是该进程自己的代码和数据,在变成进程之前,它执行加载的是我们所写的代码、数据,进程本身具有独立性,即使一个进程崩溃了也不会影响其他进程,如果一个进程所执行的代码为OS的代码和数据呢?基于进程的独立性所对应的虚拟机:如果该进程执行的是某一种解释器的代码,例如:Java解释器的代码,那么该进程运行起来便有了Java 虚拟机,而正是因为进程与进程相互隔离,那么资源也是隔离的,所以这也是大部分虚拟机技术底层的实现;
总结
自行回顾:
1. 进程定义与PCB结构:进程是程序的执行实例,由内核数据结构task_struct(PCB)管理,包含PID、状态、优先级等关键信息;
2. 进程状态:运行(R)、就绪、阻塞(S/D)、暂停(T)、僵尸(Z)等状态及其转换条件;
3. 进程调度:Linux 2.6.18的O(1)调度算法,包括140级优先级队列、时间片轮转和活动/过期队列切换机制;
4. 进程操作:fork创建、进程查看(ps/top)和优先级调整(nice值)等核心操作;
5. 进程特性:竞争性、独立性、并行与并发