深入解剖Linux进程:从诞生到调度的核心机制

目录

一、冯诺依曼体系结构

1、程序运行之前在哪里?

2、软件运行时,为什么必须先加载到内存?

3、设备的"拷贝"效率是影响体系结构的效率的关键因素之一?

4、既然"拷贝"影响效率为什么不去掉存储器,让外设直接连接CPU?

5、理解数据的流动过程

二、操作系统

1、概念

2、设计OS的目的

3、核心功能

4、系统调用和库函数概念

三、进程

1、进程基本概念

2、描述进程---PCB

3、task_struct

4、系统调用

[(1)通过系统调用获取进程标识符 --- getpid() / getppid()](#(1)通过系统调用获取进程标识符 — getpid() / getppid())

(2)通过系统调用创建进程---fork()

5、查看进程信息

[(1) ps / top 用户级工具](#(1) ps / top 用户级工具)

[(2) /proc系统文件夹](#(2) /proc系统文件夹)

四、进程状态

[1、运行 / 阻塞 / 挂起 的理解](#1、运行 / 阻塞 / 挂起 的理解)

2、Linux的进程状态

3、查看进程状态

4、Z(zombie)僵尸进程

5、僵尸进程的危害

6、孤儿进程

五、进程优先级

1、基本概念

2、查看进程优先级

[3、PRI && NI](#3、PRI && NI)

4、调整进程优先值

(1)用top命令更改已存在进程的nice

[(2)其他调整优先级命令--->nice , renice(Shell工具)](#(2)其他调整优先级命令—>nice , renice(Shell工具))

(3)系统调用

(4)竞争、独立、并行、并发

六、进程切换

1、死循环进程如何运行?

[2、CPU && 寄存器](#2、CPU && 寄存器)

3、切换

七、Linux2.6内核进程O(1)调度队列

1、一个CPU一个运行队列

2、优先级划分

3、活动队列

4、过期队列

5、active指针和expired指针

[6、Linux O(1)调度流程](#6、Linux O(1)调度流程)

7、总结


一、冯诺依曼体系结构

截止目前,我们所认识的计算机,都是由一个个的硬件组件组成:

(外设)输入设备:键盘、鼠标、话筒、摄像头、网卡、磁盘(外存)等

(外设)输出设备:显示器、键盘、网卡、打印机等

中央处理器(CPU):含有运算器和控制器等

存储器:内存
注意:

● 不考虑缓存情况,这里的CPU只能对内存进行读写,不能访问外设(输入或输出设备)

● 外设(输入或输出设备)要输入或输出数据,也只能写入内存或者从内存中读取。

● 总之一句话,所有设备都只能直接和内存打交道。

1、程序运行之前在哪里?

程序运行之前存储在外设(磁盘,SSD等),而文件是程序在磁盘中的存储形式。 而程序运行时要加载到内存中,由CPU执行。

2、软件运行时,为什么必须先加载到内存?

软件运行实质上是CPU执行代码并访问数据。而根据冯诺依曼体系结构可知,CPU获取、写入数据和指令只能从内存中进行!因此软件运行时必须先加载到内存。

3、设备的"拷贝"效率是影响体系结构的效率的关键因素之一?

上面所说的加载操作【input】是数据从一个设备"拷贝"到另一个设备,所以设备的"拷贝"效率是影响体系结构的效率的关键因素之一。

4、既然"拷贝"影响效率为什么不去掉存储器,让外设直接连接CPU?

存储器分级结构设计原理:

在任何一种计算机中,第二重要的主要部件都是存储器。理想情况下,存储器应该极为迅速(快于执行一条指令,这样CPU不会收到存储器的限制),充分大,并且非常便宜。但是目前的技术无法同时满足这三个目标(木桶原理)。于是出现了不同的处理方式。存储器采用一种分层次的结构(如图)。顶层的存储器速度高,容量小,与底层的存储器相比每位成本较高,其差别往往是十亿数量级。

存储器系统的顶端是CPU中的寄存器,和CPU一样快,但存储容量小(小于1KB),价格昂贵。

下面是高速缓存,当某个程序需要读取一个存储字时,高速缓存硬件检查所需的高速缓存行是否在高速缓存中,如果是,成为高速缓存命中。但高速缓存未命中就必须访问内存这要付出大量的时间代价,由于高速缓存的价格昂贵,所以其大小有限。有些机器具有两级甚至三级高速缓存,每一级高速缓存都比前一级慢且容量大。

总结来说,内存(RAM)的访问速度远远快于外设。CPU的运算速度极快,如果直接从磁盘读取数据,会导致严重的性能瓶颈。存储器(如寄存器、高速缓存、内存)作为CPU和外设之间的桥梁,可以缓解速度差异带来的问题。存储器层次结构的设计目的是在速度、容量和成本之间找到平衡。

5、理解数据的流动过程

(1)QQ上与朋友聊天:

用户通过自己设备的冯诺依曼体系结构实现将消息发给异地的朋友:

① 发送端:用户通过键盘将输入消息转化成了二进制数据,再经过QQ软件读取二进制数据,并对其编码、封装形成数据包,并通过网卡将数据包转化成电信号/光信号,通过网络传出到互联网。

② 接收端:朋友的网卡接收到互联网传输的数据包,并将其转化成二进制数据,再通过QQ将二进制数据进行解码、解封装,还原成成原始消息,显示在朋友的显示器上。

(2) QQ上发送文件: 简单说,与聊天功能类似都是依靠冯诺依曼体系结构实现数据的流动、

  • 发送端:文件从磁盘加载到内存,经过QQ软件处理后通过网络传输。

  • 接收端:文件数据通过网络传输到内存,经过QQ软件处理后保存到磁盘。

二、操作系统

1、概念

任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。操作系统是一款进行软硬件管理的软件,笼统理解,操作系统包括:

● 内核(进程/任务/线程管理,内容管理,文件管理,驱动管理)

● 其他程序(系统实用程序,应用程序,shell以及公用函数库等)

安卓是在Linux内核(狭义)之上构建的完整操作系统框架,提供了更高层次的功能和服务。

2、设计OS的目的

● 对下,与硬件交互,管理所有的软硬件资源(手段)

在机器语言一级上,多数计算机的体系结构(指令集,存储组织,I/O和总线结构)是很原始的,而且编程是很困难的。显然没有理智的程序员愿意在硬件层面上与硬盘打交道,他们使用一些叫做硬盘驱动的软件来和硬件交互。这类软件提供了读写硬盘快的接口,而不用深入细节。

● 对上,为用户程序(应用程序)提供一个良好的执行环境

操作系统的实际客户还是应用程序,他们通过系统调用接口与操作系统打交道。相反,最终用户通过用户接口(命令行shell或者是图形接口)间接使用操作系统的功能。
软硬件体系结构层状结构

**●**总结:

(1)软硬件体系结构是层状结构 -> "高内聚,低耦合"

(2)访问操作系统必须要使用系统调用(由系统提供的函数)

(3)自己的程序只要能判断出来它访问了硬件,那么它一定贯穿整个软硬件体系结构

(4)库可能在底层封装了系统调用(上下层)

3、核心功能

在整个计算机软硬件架构中,操作系统的定位是:一款纯正的"搞管理"的软件。

如何理解管理?

管理者管理被管理者可以不用见面,从中间层获取"数据",管理者通过"数据"进行管理。

总结:计算机管理硬件

(1)先描述,用struct结构体(存储属性)

(2)再组织,用链表或其他高效的数据结构

4、系统调用和库函数概念

在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发应用,这部分由操作系统提供接口,叫做系统调用。系统调用接口是由C语言定义的(一定有输入参数与返回值),因此,系统调用的本质就是用户与操作系统之间的交互。

● 系统调用在使用上,功能比较基础,对用户的要就相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就会有利于上层用户或开发者进行二次开发。

三、进程

1、进程基本概念

一个进程就是一个正在执行程序的实例(课本)。担当分配系统资源(CPU时间,内存)的实体(内核观点)。

进程 = 内核数据结构+自己的代码和数据

= PCB(task_struct) + 代码和数据

2、描述进程---PCB

进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,称之为PCB (process control block),Linux操作系统下的PCB是:task_struct

● 在Linux中描述进程的结构体是task_struct。

● task_struct时Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程信息。

任何一个进程被加载到内存里时,不止把自己的代码和数据加载到内存里,操作系统还要在自己的内部为该代码和数据创建对应的task_struct结构体,这个结构体可以找到所对应的代码和数据。并且所有的task_struct在操作系统内以链表的形式把所有的PCB管理起来。所以说操作系统对进程的管理变成了对进程列表(PCB)的增删查改!

3、task_struct

● 标识符:描述本进程的唯一标识符,用来区别其他进程。
● 优先级: 相对于其他进程的优先级。
● 程序计数器: 程序中即将被执行的下一条指令的地址。
● 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
● 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
● I ∕ O状态信息: 包括显示的I/O请求,分配给进程的I ∕ O设备和被进程使用的文件列表。
● 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
● 其他信息

组织进程:可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核里。

4、系统调用

以前我们执行地所有指令、工具、程序,运行起来其实都是进程。我们来实现一个简单的进程。

cpp 复制代码
 1: myprocess.c
  1 #include<stdio.h>
  2 #include<unistd.h>
  3 
  4 int main()
  5 {
  6     while(1)
  7     {
  8         sleep(1);
  9         printf("这是一个进程\n");
 10     }
 11      return 0;
 12  }
bash 复制代码
[zyt@iZ2vcf9wvlgcetfeub9f11Z code_25_3_10]$ ll
total 20
-rw-rw-r-- 1 zyt zyt   51 Mar 10 19:30 Makefile
-rwxrwxr-x 1 zyt zyt 8536 Mar 10 19:32 myprocess
-rw-rw-r-- 1 zyt zyt  151 Mar 10 19:32 myprocess.c
[zyt@iZ2vcf9wvlgcetfeub9f11Z code_25_3_10]$ ./myprocess
这是一个进程
这是一个进程
这是一个进程
^C

(1)通过系统调用获取进程标识符 --- getpid() / getppid()

① getpid() 获得自己的标识符,返回值就是调用该函数的进程的进程ID。getpid()是一个系统调用(在2号手册)

cpp 复制代码
myprocess.c
  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<sys/types.h>
  4 
  5 int main()
  6 {
  7     while(1)
  8     {
  9         sleep(1);
 10         printf("这是一个进程,我的pid:%d\n", getpid());
 11     } 
 12     return 0;
 13 }
bash 复制代码
[zyt@iZ2vcf9wvlgcetfeub9f11Z code_25_3_10]$ ./myprocess
这是一个进程,我的pid:21423
这是一个进程,我的pid:21423
这是一个进程,我的pid:21423
这是一个进程,我的pid:21423
^C

② getppid() 获得父进程的PID。

当我们频繁的执行和终止代码会发现进程的PID一直都在变化,而它的父进程PID却不变。差遭到的父进程是bash(命令行解释器)!即命令行解释器本质也是一个进程。

知识点:OS会给每一个登录用户分配一个bash。所有进程的父进程都是bash.

(2)通过系统调用创建进程---fork()

由fork创建的新进程被称为子进程。子进程和父进程继续执行fork后的指令。子进程是父进程的副本。例如:子进程获得父进程的数据空间、堆和栈的副本。注意:这是子进程拥有的副本,父进程和子进程并不共享这些存储空间部分,而是共享正文部分。

fork函数被调用一次,但返回两次,子进程的返回值是0,而父进程返回值是新建子进程的进程ID。

示例:

cpp 复制代码
myprocess.c 
  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<sys/types.h>
  4 #include <unistd.h>
  5 int main()
  6 {
  7     printf("父进程开始运行, pid:%d\n", getppid());
  8     pid_t id = fork();
  9     if(id < 0)
 10     {
 11         perror("fork fail");
 12     }
 13     else if(id == 0)
 14     {
 15         // child
 16         while(1)
 17         {
 18             sleep(1);
 19             printf("我是一个子进程, 我的pid: %d, 我父进程的pid:%d\n", getpid(), getppid());
 20         }
 21     }
 22     else
 23     {
 24         // father 
 25         while(1)
 26         {  
 27             sleep(1);
 28             printf("我是一个父进程, 我的pid: %d, 我父进程的pid:%d\n", getpid(), getppid());
 29         }
 30     }
 31     printf("进程开始运行, pid:%d\n", getpid());
 32     return 0;
 33 }

所以子进程会进入id == 0的执行流,父进程会进入id > 0的执行流。就可以实现父子执行不同的代码块。

1、为什么fork给父子进程返回不同的值?

系统里,任何一个父进程可以有多个孩子,所以需要给父进程返回子进程的PID来区分不同的子进程;而一个子进程只会有一个父进程,所以子进程总是可以调用getppid()以获得父进程的PID。
2、为什么一个函数会返回两次?

在fork函数内会申请新的pcb,操作系统复制父进程的地址空间,创建一个新的子进程,把子进程的pcb放进进程列表中,甚至放入了调度度列,然后父进程和子进程分别从 fork() 调用处继续执行,所以fork()在父进程和子进程中各返回一次。
3、为什么一个变量既【==0】又【>0】,使 if else同时成立?

进程具有独立性。 之前我们学习到子进程共享了父进程的内存,但如果有一方想修改部分内存时,就必须通过写时拷贝来实现,则这块内存首先被明确复制,以确保修改发生在私有内存区域。

我们设置一个全局变量g_val来验证这一点:

cpp 复制代码
myprocess.c
  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<sys/types.h>
  4 #include<unistd.h>
  5 
  6 int g_val = 100;
  7 
  8 int main()
  9 {
 10     printf("父进程开始运行, pid:%d\n", getppid());
 11     pid_t id = fork();
 12     if(id < 0)
 13     {
 14         perror("fork fail");
 15         return 1;
 16     }
 17     else if(id == 0)
 18     {
 19         printf("我是一个子进程, 我的pid:%d, 我的父进程pid:%d, g_val:%d\n", getpid(), getppid(), g_val);
 20         sleep(5);
 21 
 22         // child
 23         while(1)
 24         {
 25             sleep(1);                                                                                  
 26             printf("子进程改变量:%d->%d\n", g_val, g_val+10);
 27             g_val += 10;
 28             printf("我是一个子进程, 我的pid: %d, 我父进程的pid:%d\n", getpid(), getppid());
 29         }
 30     }
 31     else
 32     {
 33         // father 
 34         while(1)
 35         {
 36             sleep(1);
 37             printf("我是一个父进程, 我的pid: %d, 我父进程的pid:%d, gval:%d\n", getpid(), getppid(), g_val);
 38         }
 39     }
 40     printf("进程开始运行, pid:%d\n", getpid());
 41     return 0;
 42    }

从结果来看,在子进程区域块对全局变量的修改并没有影响到父进程。这也证明了,子进程区域块中修改的是写时拷贝后的全区变量。

5、查看进程信息

(1) ps / top 用户级工具

ps 】用于显示当前系统中的进程状态,ps axj 是静态的,只显示运行命令时的进程状态。

top】 是一个动态的进程查看工具,可以实时显示系统的进程状态和资源使用情况,【q】选项退出top。
在myprocess进程在【执行时】查看该进程的进程信息:

ps axj | grep myprocess

ps axj | head -1;ps axj | grep myprocess 】的效果就是在之前的基础上加上表头。

注意:整个命令行从左向右查的时候,grep本身也成为了一个进程,它自己的过滤关键字本来也包含了myprocess,所以最终也显示出来了grep命令的进程信息。

bash 复制代码
$ ps axj | grep myprocess  # 只显示myprocess进程的信息
20802 21423 21423 20784 pts/0    21423 S+    1001   0:00 ./myprocess
21396 21430 21429 21378 pts/1    21429 S+    1001   0:00 grep --color=auto myprocess

$ ps axj | head -1;ps axj | grep myprocess  # 显示表头
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
20802 21423 21423 20784 pts/0    21423 S+    1001   0:00 ./myprocess
21396 21434 21433 21378 pts/1    21433 S+    1001   0:00 grep --color=auto myprocess

$ ps axj | head -1 && ps axj | grep myprocess  # 与上一命令行作用相同
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
20802 21423 21423 20784 pts/0    21423 S+    1001   0:00 ./myprocess
21396 21434 21433 21378 pts/1    21433 S+    1001   0:00 grep --color=auto myprocess

补充:对于一个正在执行的进程,杀死他的方法除了**【Ctrl+c】** ,还有**【kill -9 目标进程的PID】**

(2) /proc系统文件夹

/proc 目录里记录的是当前系统里所有进程信息,而该目录下文件中的数字目录代表的就是特定进程的pid,每一个数字目录里的内容包含的是该进程运行时的动态属性,一旦进程退出该目录会被系统自动移除。

补充知识1:【exe】

我们打开PID为2396的进程运行的动态属性,发现有一个【exe】可执行文件,如果我们将他删除会不会影响进程呢?

执行删除操作后,我们发现进程并不会终止依旧在运行,这是因为我们删除的是磁盘上的可执行文件,而该可执行文件在运行时已经被加载到内存并在操作系统中产生指向对应内存的进程了,所以进程没有终止。但是再次打开2396的动态属性时,警告可执行文件已经被删除了。要想恢复正常只能重新编译原文件使之形成可执行文件。

补充知识2:【cwd】

打开PID为2396的进程运行的动态属性,我们还发现了【cwd】(current work dir)当前工作路径,所以说进程会记录当前的工作路径。以前我们学习文件操作时的 fopen("data.txt","w") 此时要打开的data.txt,并没有显示目录却还能打开,就是因为data.txt是一个相对路径,只需要在进程记录好的当前路径下查找data.txt,如果查找不到data.txt,就会以进程记录的路径与文件名做拼接,在当前路径下形成一个新路径,并构造出了新文件。

进程调用 chdirfchdir函数可以更改当前的工作目录

cpp 复制代码
 1: myprocess.c
  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<sys/types.h>
  4 
  5 int main()
  6 {
  7     chdir("/home/zyt");
  8     fopen("hello.txt","a"); 
  9     while(1)
 10     {
 11         sleep(1);
 12         printf("这是一个进程,我的pid:%d\n", getpid());
 13     }
 14     return 0;
 15 }

四、进程状态

1、运行 / 阻塞 / 挂起 的理解

理解:进程状态变化的表现之一就是要在不同的队列里面流动,本质都是数据结构的增删查改。

进程位于运行队列中时,就是运行状态(R状态 );但当进程因等待 I/O 操作(如键盘写入)而阻塞时(S状态 ),其实是进程进入了操作系统的等待队列 ,等待键盘写入;但可能同时存在多个进程等待键盘输入,进入等待队列中,当物理内存不足时,内核会将长时间未活动的进程的内存页 (代码/数据)换入至磁盘的 Swap 分区 ,但 PCB 始终保留在内存,此时被留在内存的PCB就处于阻塞挂起(还是S状态)。键盘输入到达后,唤醒 S 状态进程,重新载入其内存页(若被换出)。(D 状态进程通常不换出(因涉及硬件操作,换出可能导致崩溃))。

一个PCB可以同时隶属于多种数据结构!

2、Linux的进程状态

在Linux内核中,进程有时候也叫做任务。

cpp 复制代码
static const char *const task_state_array[] = {
    "R (running)", /*0 */
    "S (sleeping)", /*1 */
    "D (disk sleep)", /*2 */
    "T (stopped)", /*4 */      // 用户暂停的
    "t (tracing stop)", /*8 */ // debug, 断点:进程暂停
    "X (dead)", /*16 */
    "Z (zombie)", /*32 */
};
  1. R 运行状态 (Running)

    • 表示进程正在运行或位于运行队列中等待调度。(一个CPU一个调度队列)

    • 注意:处于运行队列的进程可能因 CPU 资源竞争而尚未实际执行。

  2. S 睡眠状态 (Sleeping, 可中断睡眠)

    • 进程因等待上层事件(如 I/O 完成、键盘输入、信号量释放等)而进入休眠。

    • 特点:可被信号或事件唤醒,响应中断请求。

  3. D 磁盘休眠状态 (Disk Sleep, 不可中断睡眠)

    • 进程因等待不可中断的 I/O 操作(如磁盘写入)而阻塞。

    • 特点:无法通过信号唤醒,必须等待操作完成,常见于关键硬件交互场景。

  4. T 停止状态 (Stopped)

    • 进程被信号(如 SIGSTOP)强制暂停执行。

    • 恢复方式:通过 SIGCONT 信号继续运行。

  5. X 死亡状态 (Dead)

    • 表示进程已终止,是内核回收资源前的短暂状态。

    • 用户空间工具(如 ps)通常不会显示此状态。

3、查看进程状态

bash 复制代码
ps aux / ps axj #命令

• a:显示一个终端所有的进程,包括其他用户的进程。

• x:显示没有控制终端的进程,例如后台运行的守护进程。

• j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息。

• u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等。

4、Z(zombie)僵尸进程

• 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用,后面讲)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程

僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码(保存在task_struct)。

• 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态

模拟Z状态:

cpp 复制代码
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>

int main()
{
    int id = fork();
    if(id == 0)
    {
        int count = 5;
        while(count)
        {
            printf("我是一个子进程, count:%d\n", count);
            sleep(1);
            count--;
        }
    }
    else
    {
        while(1)
        {
            printf("我是一个父进程\n");
            sleep(1);
        }
    }

    return 0;
}

在另一个终端下监控进程状态:得到的结果是在子进程执行完后,子进程进入僵尸状态(Z)。

5、僵尸进程的危害

● 如果父进程一直不回收,不获取子进程的退出信息,那么子进程会一直存在!就会造成内存资源的浪费,因为数据结构对象本身就是要占用内存。

● 维护退出状态本身就是需要数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB就一直要维护。

● 这些特性可能导致内存泄漏的风险。

知识点:① 如果进程退出了,内存泄露问题直接就没了,进程申请出的空间会被操作系统自动释放!但常驻内存进程发生内存泄漏后,就非常麻烦。

② 关于内核结构的申请:slab技术,操作系统内有对数据结构对象的缓存。分配进程时,直接从 Slab 缓存中获取一个空闲的 task_struct 对象,无需动态分配内存。释放进程时,进程退出后,其 task_struct 不会被彻底释放,而是标记为空闲并放回 Slab 缓存。僵尸进程的 task_struct 也会被保留在缓存中,直到父进程调用 wait() 后才会真正回收。

6、孤儿进程

● 如果父进程提前退出,子进程就称为"孤儿进程",子进程要被1号进程init领养,也由init进程回收。

● 孤儿进程被领养后会自动变成后台进程,【Ctrl+c】就不能杀死进程了,可以用【kill -9 pid】。

**●**如果1号进程不领养,那么子进程一直处于孤儿状态,就可能导致内存泄漏。

cpp 复制代码
#include<stdio.h>
#include<unistd.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        // child
        while(1)
        {
            printf("我是一个子进程,pid: %d, ppid:%d\n", getpid(), getppid());
            sleep(1);
        }
    }
    else
    {
        // father
        int cnt = 3;
        while(cnt--)
        {    
            printf("我是一个父进程,pid: %d, ppid:%d\n", getpid(), getppid());
            sleep(1);
        }
    }


    return 0;
}

while :; do ps axj | head -1 && ps axj | grep myprocess ; sleep 1; done

每隔一秒查看进程状态变化

ps axj | head -1 && ps axj | grep systemd

查看1号进程

五、进程优先级

1、基本概念

• CPU资源分配的先后顺序 ,就是指进程的优先权(priority)。

• 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的Linux很有用,可以改善系统性能。

• 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大改善系统整体性能。

• 当代计算机普遍是基于时间片的分时操作系统,会考虑公平性,优先级可能变化,但变化幅度不会太大。

2、查看进程优先级

ps -l 命令查看(当前终端)系统进程

在其他终端查看我们自己运行的myprocess,上一小节代码的父子进程都不退出。

ps -al | head -1 && ps -al | grep myprocess

①UID(USER ID):代表执行者的身份,查看UID:【ls -ln】

小知识:系统怎么知道我们访问文件时,时拥有者、所属组还是other? 其实就是将进程的UID与文件的UID进行比对!=》Linux系统中访问任何资源,都是进程访问,进程就代表用户。

②PID:代表这个进程的代号

③PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号

④PRI:代表这个进程可被执行的优先级,其值越小越早被执行,默认是80

⑤ NI:代表这个进程的nice值,进程优先级的修正数据。默认为0

⑥ 进程的真实优先值 = PRI(默认) + NI

3、PRI && NI

• PRI即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高

• nice值表示进程可被执行的优先级的修正数值

• PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(默认)+nice

• 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行

• 所以,调整进程优先级,在Linux下,就是调整进程nice值
• nice其取值范围是【-20,19】,一共40个级别;所以优先级值的范围是【60,99】。

• 优先级设立不合理,会导致优先级低的进程长时间得不到CPU资源,进而导致进程饥饿

思考问题:为什么不能直接修改优先级值,而是要通过nice? 在大O(1)调度队列会解释!

4、调整进程优先值

(1)用top命令更改已存在进程的nice

进入top后,按 "r" ---> 输入进程PID ---> 输入nice值

(2)其他调整优先级命令--->nice , renice(Shell工具)

nice -n <增量> <命令>

eg:

nice -n -5 ./myprocess # 以较高优先级(nice=-5)启动进程

nice -n 10 ./myprocess # 以较低优先级(nice=10)启动进程

注意:

• 普通用户只能降低优先级(nice > 0),root 用户可以提高优先级(nice < 0)。

• 未指定 -n 时,默认 nice=10(降低优先级)。

renice -n <优先级增量> -p <PID>

eg:

renice -n -5 -p 1234 # 提高进程 1234 的优先级(nice=-5)

renice -n 10 -p 1234 # 降低进程 1234 的优先级(nice=10)

renice -n 5 -u username # 调整某用户所有进程的优先级

注意:

• 普通用户只能降低优先级(nice > 0),root 用户可以提高优先级(nice < 0)。

• 未指定 -n 时,默认 nice=10(降低优先级)。

(3)系统调用

#include<unistd.h>

int nice(int incr);

返回值:若成功,返回新的nice值;若出错,返回-1

incr > 0:降低优先级(nice 值增加);incr < 0:提高优先级(需 root 权限)。

相关的系统调用还有**getpriority(),stepriority()**等。

(4)竞争、独立、并行、并发

竞争性 : 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。

独立性 : 多进程运行,需要独享各种资源,多进程运行期间互不干扰。

并行 : 多个进程在多个CPU下分别,同时进行运行,这称之为并行。

并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。

六、进程切换

1、死循环进程如何运行?

(1)一个进程一旦占有CPU,会把自己的代码完成吗?

不会,因为现代操作系统采用分时调度策略,通过时间片限制每个进程的连续执行时间,时间片耗尽后会被强制切换;同时,更高优先级进程的抢占、进程主动因I/O或等待资源而阻塞、以及硬件中断或异常等事件都会导致CPU切换。只有在单任务系统或特定实时任务中,进程才可能独占CPU直至完成。

(2)所以死循环进程不会卡死进程,不会一直占有CPU!

2、CPU && 寄存器

CPU内有多个寄存器,寄存器就是CPU内部的临时空间

3、切换

CPU上下文切换 :其实际含义是任务切换,或者CPU寄存器切换。当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是CPU寄存器中的全部内容 。这些内容被保存在任务自己的堆栈中,入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器,并开始下一个任务的运行,这一过程就是 context switch

进程切换最核心的:就是保护和恢复当前进程的硬件上下文的数据,即CPU内寄存器的内容。

想象寄存器是一个工作台,而进程是不同厨师:工作台只有一张(寄存器唯一),但每个厨师有自己的菜谱(上下文)。当切换厨师时,当前菜谱收进抽屉(保存到内存),再从抽屉取出另一个菜谱铺在工作台上(恢复上下文)。

注意混淆:寄存器 != 寄存器里面的数据

  1. 寄存器是物理硬件,唯一且共享
  • CPU内部的寄存器(如EAX、ESP等)是物理存在的电子元件,同一时刻只能存储一组数据(即一个进程的上下文)。

  • 所有进程必须共享这组寄存器,这是硬件层面的限制。

  1. "上下文多份"指的是数据的保存与恢复
  • 虽然寄存器只有一份,但操作系统会在进程切换时:

    (1)保存:将当前进程的寄存器数据(上下文)存入其私有内存(如进程控制块PCB--->TSS任务状态段)(2)恢复:将下一个进程的上下文从内存加载到寄存器。对于全新的进程,不需要恢复上下文,进程通过task_struct里面的标记位isrunning,来区分全新进程和已经调度过的进程。

  • 因此,每个进程的上下文数据在内存中有独立副本,但运行时只有一份在寄存器中生效。

  1. 图中的表述为何强调"只有一份"?
  • 是为了说明 寄存器本身是竞争性资源,进程不能独占,必须通过操作系统调度切换。

  • 例如:

    • 进程A运行时,寄存器存的是A的上下文。

    • 切换到进程B时,A的上下文被保存到内存,寄存器被B的上下文覆盖。

简单介绍:
时间片:当代计算机都是分时操作系统,没有进程都有它合适的时间片(其实就是一个计数
器)。时间片到达,进程就被操作系统从CPU中剥离下来。
分时操作系统:通过时间片轮转的方式让多个用户或任务共享CPU资源,它将处理器时间划分成很短的时间片段(比如几十毫秒),每个任务轮流执行一个时间片,用完就被暂停并切换到下一个任务。这种设计让所有任务都能分到计算资源,虽然单个任务执行会被频繁打断,但由于切换速度极快,用户感觉像是同时在运行多个程序。
实时操作系统:是一种专门设计用来快速响应外部事件的系统,它最核心的特点是必须保证任务在规定时间内完成,绝对不能超时。

七、Linux2.6内核进程O(1)调度队列

1、一个CPU一个运行队列

如果有多个CPU就要考虑进程个数的负载均衡问题。

优先级数组(prio_array):包含两个子队列(active 和 expired),每个队列通过 位图(bitmap[5]) 和 队列数组(queue[140]指针数组) 管理进程。

队列管理:
active 队列:存放待执行的进程。
expired 队列:存放时间片用完的进程

通过 swap(&active, &expired) 在时间片耗尽后交换两个队列,实现轮转调度。

2、优先级划分

0-99:实时进程优先级(FIFO/RR)

100-139:普通进程优先级(分时/NORMAL)

数字越小优先级越高(0是最高实时优先级),实时进程完全不参与时间片轮转,不会被放入expired队列,一旦就绪就会立即抢占所有普通进程,所以我们不考虑

3、活动队列

● 时间片还没有结束的所有进程都按照优先级放在该队列

nr_active: 总共有多少个运行状态的进程

queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO(先进先出)规则进行排队调度,因此数组下标就是优先级!

●从该结构中,选择一个最合适的进程,过程如下

  1. 从0下标开始遍历queue[140]

  2. 找到第一个非空队列,该队列必定为优先级最高的队列

  3. 拿到选中队列的第一个进程,开始运行,调度完成!

  4. 遍历queue[140]的时间复杂度是常数,但依然不够高效!

● bitmap[5]的作用:让调度器快速的挑选一个进程

• 一共140个优先级,对应140个进程队列。为了提高查找非空队列的效率,使用5×32=160个比特位(实际只用140位)表示每个队列是否为空(1表示非空,0表示空)。

• 通过位操作(如ffs指令)快速定位最高优先级的非空队列,大幅提升调度效率!

4、过期队列

● 过期队列与活动队列的结构一模一样

● 过期队列上放置的进程,都是时间片耗尽的进程

● 当活动队列上的进程都被处理完毕后,对过期队列的进程进行时间片重新计算。

5、active指针和expired指针

active指针永远指向活动队列

expired指针永远指向过期队列

● 活动队列上的进程会随着时间片耗尽逐渐减少,过期队列则不断累积到期进程。但通过交换active和expired指针的内容,系统能立即将过期队列转为新的活动队列,无需迁移数据,实现零成本队列切换。

6、Linux O(1)调度流程

  1. 初始状态:
  • 系统维护两个主要队列:active队列和expired队列

  • 每个队列都包含140个优先级子队列(0-139),0是最高优先级

  • 每个子队列都对应一个task_struct链表

  • 每个队列还有一个5×32=160位的位图(bitmap),用于快速查找非空队列

  1. 调度选择过程:

    当CPU需要选择新进程运行时:

    a) 调度器首先检查active队列的位图

    b) 使用位操作指令(如ffs)找到第一个置1的位,这表示该优先级队列有可运行进程

    c) 直接访问对应的queue[priority]链表,取出第一个task_struct执行

  2. 时间片处理:

    当正在运行的进程:

    a) 每次时钟中断会减少它的时间片计数器

    b) 当时间片减到0时:

    • 重新计算该进程的新时间片(基于静态优先级)

    • 根据进程类型决定放入哪个队列:

      • 交互式进程:可能放回active队列继续执行(满足用户交互的及时响应)

      • CPU密集型进程:放入expired队列

    • 更新进程的优先级(可能根据行为动态调整)

  3. 队列切换机制:

    当调度器发现active队列为空时:

    a) 执行swap(&active, &expired)操作

    b) 这实际上只是交换两个队列的指针,非常高效

    c) 现在原来的expired队列变成新的active队列

    d) 原来的active队列(现在是空的)变成新的expired队列

  4. 实时进程处理:

    对于实时进程(RT优先级):

    a) 它们总是位于0-99的高优先级范围

    b) 一旦就绪就会立即抢占普通进程

    c) 不会被放入expired队列,而是执行完后再决定

    d) 使用不同的调度策略(FIFO或RR)

  5. 负载均衡:

    在多核系统中:

    a) 每个CPU有自己的运行队列

    b) migration_thread会定期检查各CPU负载

    c) 把进程从繁忙CPU迁移到空闲CPU

    d) 使用migration_queue来传递迁移任务

  6. 特殊处理:

  • 新fork的进程会进入active队列

  • 被唤醒的I/O密集型进程可能会获得优先级提升

  • 内核通过task_struct中的调度相关字段跟踪每个进程的状态

7、总结

这也解释了我们之前的疑问,为什么不能直接修改优先级值,而是要通过nice?

避免优先级反转与饥饿
● 直接修改的风险:

若允许任意进程直接设为最高优先级,可能导致低优先级进程永远无法执行(饥饿)。

● nice的渐进式调整:

nice值的范围(-20到+19)限定了优先级调整幅度,确保:

• 即使进程降低nice值,也不会完全垄断CPU(CFS调度器仍保证公平性)。

• 高nice值进程仍能获得最小时间片(如1ms)。

新创建的进程会被加入到active队列还是expired队列?

  • 设计目标

    让新进程立即参与调度竞争 ,避免因放入expired队列而延迟执行(需等待队列切换)。

  • 具体行为

    • 新进程(如通过fork()创建)初始化后,时间片被设置为默认值(如100ms)。

    • 调度器将其直接插入active队列中对应优先级的子队列 (通过bitmap标记非空)。

    • 下次调度时,该进程可能被选中运行(取决于优先级)。

  • 例外 :时间片耗尽的CPU密集型进程 → expired队列

总结:在系统当中查找一个最合适调度的进程得时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法。

相关推荐
小安运维日记2 小时前
CKS认证 | Day3 K8s容器运行环境安全加固
运维·网络·安全·云原生·kubernetes·云计算
我是唐青枫3 小时前
Linux ar 命令使用详解
linux·运维·服务器
mljy.3 小时前
Linux《进程概念(上)》
linux
余华余华3 小时前
计算机等级考试数据库三级(笔记3)
服务器·数据库·oracle
IEVEl3 小时前
Centos7 开放端口号
linux·网络·centos
今夜有雨.3 小时前
HTTP---基础知识
服务器·网络·后端·网络协议·学习·tcp/ip·http
我要升天!4 小时前
Linux中《环境变量》详细介绍
linux·运维·chrome
MobiCetus4 小时前
有关pip与conda的介绍
linux·windows·python·ubuntu·金融·conda·pip
Wnq100725 小时前
DEEPSEEK创业项目推荐:
运维·计算机视觉·智能硬件·ai创业·deepseek
weixin_428498495 小时前
Linux系统perf命令使用介绍,如何用此命令进行程序热点诊断和性能优化
linux·运维·性能优化