🌈之前在这篇文章揭秘计算机内部奥秘:从CPU到操作系统,深入探索进程与线程的工作原理中就已经简述了 进程的部分相关内容,下面我们来进一步深入了解进程的内容。
🔥进程的基本概念
🍅 我们在编写完代码并运行起来时,在我们的磁盘中会形成一个可执行文件,当我们双击这个可执行文件时(程序时),这个程序会加载到内存中,而这个时候我们不能把它叫做程序了,应该叫做进程。 🍅 所以说,只要把程序(运行起来)加载到内存中,就称之为进程。 🍅 进程的概念:程序的一个执行实例,正在执行的程序等。 🍅 如果站在内核的角度来看:进程是分配系统资源的单位。
🌈前面说了一个抽象的概念需要一个具体的结构体来进行描述的。进程中的信息就被放在了一个叫做进程控制块(PCB)的结构体中。
在不同的操作系统下进程控制块的名称不同(比如:不同地方的人称呼某一个东西有不同的叫法)
🧩当一个程序被加载到内存中要开始执行的时候,操作系统同时会给该进程分配一个PCB,在Linux中就是task_struct这里面包含了所有关于进程的数据信息。所以CPU对task_struct进行管理就相当于对进程进行管理。
🍊 task_struct 是Linux内核的一种数据结构,它会被装载到 RAM(内存) 里并且包含着进程的信息,在进程执行时,任意时间内,进程对应的 PCB 都要包含以下内容:
🌸 进程标识符:描述本进程的唯一标示符,用来区别其他进程
也就是进程的PID,【PID】是操作系统中唯一标识的进程号
有两个获得【进程PID】的方式:
可以使用ps aux查看进程的信息。
可以使用系统接口得到进程PID和父进程的PPID
#include <stdio.h>
#include <unistd.h>
int main() {
printf("pid=%d, ppid=%d\n", getpid(), getppid()); // 进程号和父进程号
return 0;
}
🍉PPID 的解释:
🌸 进程状态:
进程有很多的不同的状态,在kernel源代码中是这样定义的
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
(1)R- -可执行状态(运行状态)
(2)S- -可中断睡眠状态(sleeping)
(3)D- -不可中断睡眠(disk sleep)
(4)T- -暂停状态
(5)Z- -僵尸状态
(6)X- -死亡状态或退出状态(dead)
🌸 进程优先级:
🌸 程序计数器:程序中即将被执行的下一条指令的地址
🌿CPU有三个工作:取指令,分析指令和执行指令。CPU中的指令寄存器每一次都会保存下一条指令的地址,以此来进行指令判断。
🌸 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
🌸上下文数据
通常操作系统内核使用一种叫做**「上下文切换」**的方式来实现控制流。
实行这种机制是因为CPU只有一套寄存器,所有只能有将一个进程的存储数据放入寄存器中计算,从而形成了上下文数据。但是同时有多个进程的时候,操作系统为了使得CPU的利用率最高,所以会让进程之间来回的切换,一般进程切换有两种情况:
以上两种情况,都会使得进程意外的退出CPU的执行,但是下次CPU还想接着上一次执行的地方继续执行那个意外退出的进程,所以就需要在进程退出之前,在task_struct
中保留下上一次执行的数据,方便下一次再被执行。
🌸IO状态信息: 包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表
查看进程有三种方式:
🍊 在 /proc
这个目录下保存着所有进程的信息。
⚡ 注意:/proc不是磁盘级别的文件
ps aux # 查看系统中所有的进程信息
ps axj # 可以查看进程的父进程号
# 一般的合并操作如下
ps ajx | head -1 && ps ajx | grep 可执行程序/pid
top命令是一个可以动态查看进程信息的命令(很像windows中的任务管理器)
top # 动态的查看进程的信息,其中的信息默认3秒回更新,按 q 退出
🍎进程的创建一般有两种方式:
当时用 fork() 函数之后,就在原来的进程中创建了一个子进程,在 fork() 之前的代码只被父进程执行,在 fork() 之后的代码有父子进程一起执行。
创建的子进程和父进程几乎一模一样,子进程和父进程的共享地址空间,子进程可以或者父进程中所有的文件,只有PID是父子进程最大的不同(注:PID是进程标识符,在上面描述进程里有说)
先来个最简单的案例1:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("main 函数的 pid: %d, ppid: %d\n",getpid(), getppid());
pid_t id = fork();
if(id < 0){
printf("Error\n");
}
else if(id == 0){
printf("我是子进程, pid: %d, ppid: %d\n",getpid(), getppid();
}
else{
printf("我是父进程, pid: %d, ppid: %d\n",getpid(), getppid());
}
}
结果及分析
对于 fork 函数的进一步理解:
案例2 如下:
#include <stdio.h>
#include <unistd.h>
int gval = 0;
int main()
{
printf("I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if(id > 0){
while(1){ // 父进程只读 gval,不做修改
printf("我是父进程, pid: %d, ppid: %d, gval:%d\n", getpid(), getppid(), gval);
sleep(2);
}
}
else if(id == 0){
while(1){ //子进程对 gval 读 + 修改
printf("我是子进程, pid: %d, ppid: %d, gval: %d\n", getpid(), getppid(), gval);
gval++;
sleep(2);
}
}
return 0;
}
案例3:
#include <stdio.h>
#include <unistd.h>
int main()
{
fork();
fork();
printf("hello\n");
return 0;
}
上面应该是输出了4个 hello,下面这张图就可以解释
💖结论:一个父进程可以创建多个子进程,因此我们可以知道 Linux 进程 整体就是一种 树形结构
这里面有很多有意思的点:
fork函数调用一次,返回两次
并发执行
相同但是独立的地址空间
共享文件
🎯 子进程继承了父进程所有打开的文件,
#include <stdio.h>
int main()
{
while (1)
{
printf("hello world\n");
sleep(100); // 睡眠100秒
}
return 0;
}
所以父进程调用fork的时候,stdout文件呢是打开的,所以子进程中执行的内容也可以输出到屏幕上
小知识:
1. kill 指令
2. shell 脚本监控
# 每隔一秒就查看进程的信息
while :; do ps ajx | head -1 && ps ajx | grep a.out | grep -v grep; sleep 1; done
我们在上面描述进程中已经看过了部分知识,下面是对之前的一个补充
(1)R运行状态,,S运行状态
sleep()
系统调用接口使得一个进程睡眠案例:
#include <stdio.h>
#include <unistd.h>
int main()
{
int cnt = 0;
while(1)
{
scanf("%d", &cnt);
printf("hello world, cnt: %d \n", cnt++);
}
return 11;
}
为啥带了printf之后,查出的状态就是S状态?
(2)D磁盘休眠状态 这种状态是一种深度休眠的状态,在这种状态下即使是操作系统发送信号也不可以杀死进程,只能等待进程自动唤醒才可以。
模拟实现:
这种情况没法模拟,一般都是一个进程正在对IO这样的外设写入或者读取的时候,为了防止操作系统不小心杀掉这个进程,所以特地创建出一个状态保护这种进程。
案例:
(3)T停止状态
可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
模拟实现:
可以使用信号
kill -SIGSTOP PID // 停止进程, 可以用 kill -19 来替代
kill -SIGSONT PID // 继续进程, 可以用 kill -18 来替代
案例:
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
分析及理解:
补充一个知识:
我们可以发现在前台任务执行时,输入其他指令也不会产生别的影响,而在后台任务中,我们输入的每个指令都会有相对应的输出,因此我们可以知道:
(4)X死亡状态
可以使用kill -9 PID即可杀死一个进程,上面我们也用到了 kill -9 .
(5)Z僵尸状态
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
案例:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("父进程运行:pid: %d ,ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if(id == 0)
{
// 子进程
int cnt = 10;
while(1)
{
printf("我是子进程,pid: %d ,ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
cnt--;
}
}
else
{
// 父进程
while(1)
{
printf("我是父进程,pid: %d ,ppid: %d\n", getpid(), getppid());
sleep(1);
}
}
return 0;
}
🌈如果父进程比子进程先退出,那么此时子进程就叫做孤儿进程。而操作系统不会让这个子进程孤苦伶仃的运行在操作系统中,所以此时孤儿进程会被init
进程(也就是1号进程,即所有进程的祖先)领养,从此以后孤儿进程的状态和最后的PCB空间释放都是由init
进程负责了。
a. 为什么会出现僵尸进程?
b. 僵尸进程的概念
c. 僵尸进程的危害
d. 如何消灭僵尸进程?
具体演示在上面已经写过,大家可以在上面自行查看。
之所以会存在进程优先级,是因为cpu本身的资源分配是有限的,一个cpu一次只能run一个进程,但是一个操作系统中可能会有成千上百的进程,所以需要存在进程优先级来确定每一个进程获得cpu资源分配的顺序。
在linux或者unix系统中,用ps –al或者ps -l命令则会类似输出以下几个内容:
注:ps -l 查看 进程优先级 信息,ps -al 查看整个系统的 优先级信息
其中我们来了解几组关于进程优先级的相关信息:
PRI和NI是一组对应的概念。NI的取值会影响到PRI的最终值。
PRI代表进程被CPU执行的先后顺序,并且 PRI越小进程的优先级越高。NI代表nice值,表示进程的优先级的修改数值。所以两者之间有一个计算的公式:(new)PRI = (old)PRI + NI。
注意:
2. NI 的取值范围为 [-20,19],一共40个级别
相异之处:
(1)通过top命令更改nice值
top命令 上面有说明,这里我们就直接使用了。
进入top后按“r”–>输入进程PID–>输入nice值
(2)通过renice命令更改nice值)
语法格式:renice nice值 进程PID
该问题即 为啥Linux的优先级调整会被限制?
🥑由于我们现在用的系统都是分时操作系统,进程调度需要保证尽量公平,而只有在可控范围内才不会影响到我们的公平调度。如果不受限制,自己可以将自己的进程的优先级设置非常高,而系统的,或者别人的非常低,优先级较高的进程获得资源,后续还有很多今后曾源源不断产生,会导致常规进程享受不到资源。造成进程饥饿问题。
前面一直在介绍单个进程的概念,下面我们稍微了解一下多个进程之间的关系概念。
CPU
下同时运行,称之为并行。补充:
等待的本质
阻塞:每一个 CPU 都会给 操作系统 提供一个叫作 运行队列的东西。只要进程在运行队列中,该进程就叫作 运行状态,表示我已经准备好了,可以被 CPU 随时调度。
了解完阻塞之后,我们就可以知道卡顿的本质:是CPU 不调动 它了,有以下两种原因:
在讲解具体知识前,我们先来了解相关知识:
时间片
Linux / windows 民用级别的操作系统,分时操作系统 --> 调度任务追求公平
🎯 进程在运行的时候,放在CPU上,并不是需要将该进程的代码全部执行完才会被拿下CPU,现代操作系统,都是基于时间片进行轮转执行,一个进程在CPU上有一个执行最大时间,即时间片,在CPU上执行了该时间,就会被拿下
进程切换(process switch)是操作系统的核心任务之一,用于在不同进程之间进行 CPU 时间的共享和分配。当一个进程在运行时,它占用了 CPU,并占用了其他诸如内存等资源。当操作系统需要执行另一个进程时,就需要进行进程切换。进程切换涉及到保存当前进程的上下文信息,包括 CPU 寄存器、程序计数器、栈指针等,以及恢复调度执行下一个进程所需的上下文信息。
在 Linux 操作系统中,进程切换的实现源码可以分为两个部分:进程调度 和 上下文切换。
进程调度的代码主要位于kernel/sched/ 目录下,包括了进程调度算法以及实现。而进程切换则需要涉及到进程的 PCB(进程控制块)和线程的 TCB(线程控制块),以及 CPU 的寄存器状态和内核栈等上下文数据。在 x86 架构的处理器上,进程切换的具体实现涉及到 task_switch 函数、 switch_to 宏以及 switch_to_asm 汇编函数等。在 AArch64 等不同架构的处理器上,对应的汇编代码可能有所不同,但目的是一致的。
CPU里面有大量的寄存器,比如:eax,ebx,ecx,edc,eds/ecs,eip… 等等,当一个进程在CPU 上被运行的时候,这些寄存器会围绕这个进程进行展开运算,保存相关的在执行该进程代码中的信息,临时数据,比如变量,函数等等。
总结:进程在被执行的过程中,一定会存在大量的临时数据,会暂存在 CPU 内的寄存器中。
我们把进程在运行中产生的各种寄存器数据,我们叫进程的硬件上下文数据。
调度器根据保存的进程上下文,就可以实现进程切换
进程调度的核心代码实现参考kernel/sched 目录文件,主要包含以下几个部分:
1. 从0下表开始遍历queue[140] 2. 找到第一个非空队列,该队列必定为优先级最高的队列 3. 拿到选中队列的第一个进程,开始运行,调度完成! 4. 遍历queue[140]时间复杂度是常数!但还是太低效了,因此我们就用到了下面的位图判断。
🔥 位图判断
🍊 bitmap数组,类型为int,这个数组用来干嘛呢?只能存储5个整形变量。数组的名字叫做bitmap已经很明显了,就是位图,5个整形元素有 32 * 5 = 160 个比特位,比特位的位置,表示哪一个队列。比特位的内容,表示该队列为不为空。
比如:0000 … 0000 ,如果最左侧0对应queue[100]的位置,那么如果该比特位为0表示在该下标映射的优先级下该队列为空,否则不为空。
那么为什么要用位图?
在蓝色框内还有一个元素:nr_active ,在 Linux 中,nr_active 是运行队列中用于表示活跃进程数量的计数器。nr_active 的值可以告诉内核有多少进程正在等待执行,从而帮助内核进行进程调度和资源分配。
🔥 过期队列
🍅在红色框中的三项属性与蓝色框中的三项属性完全相同,也就是另外一个队列,被称为——过期队列。
活跃队列表示当前CPU正在执行的运行队列,而 正在执行的运行队列(也就是活跃队列)是不可以增加新的进程的。
所以操作系统设置了一个 和活跃队列相同属性的过期队列,当活跃队列正在执行时如果有进程需要添加进运行队列,那么就会添加至过期队列当中,也就是说 活跃队列的进程一直在减少,而过期队列中的进程一直在增多!
当活跃队列的进程执行完毕后,就会和过期队列进行交换,它们交换的方式是通过两个结构体指针:
总结:
补充:
active 指针和 expired 指针
上下文切换的核心代码实现参考arch/x86/kernel/process.c或者 arch/arm64/kernel/process.c等架构相关的文件,主要包含以下几个部分:
需要注意的是,不同架构的处理器可能会有不同的实现,但其核心原理相同。
以上就把进程概念、描述、查看、状态、优先级以及调度切换讲完了,后面我会更新 关于 命令行参数以及环境变量 的博客,让我们一起努力学习,一起加油吧!!!
💖💞💖【*★,°*:.☆( ̄▽ ̄)/$:*.°★* 】那么本篇到此就结束啦,如果我的这篇博客可以给你提供有益的参考和启示,可以三连支持一下 !!