【Linux 进程概念】
冯诺依曼体系结构
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。
截至目前,我们所认识的计算机,都是有一个个的硬件组件组成:
- 输入单元:包括键盘, 鼠标,扫描仪, 写板等
- 中央处理器(CPU):含有运算器和控制器等
- 输出单元:显示器,打印机等
冯诺依曼结构简要解释:
我们要知道计算机的发明是为了帮助人类更高效率的工作,所以我们应当了解其内部构成的原理:
计算机一般的信息需要人来输入,并且结果要呈现出来,所以就有了输入设备和输出设备,输入进来的数据经过运算器的运算,再在控制器的控制下进行数据的迁移。
但是CPU的运行速率是远远大于输入设备和输出设备的,根据木桶原理可知:要想整体提高效率,则需要提高前二者的效率,故引入内存,事先我们可以先将数据从输入设备读入到内存中,让CPU和内存进行读写操作以提高效率。
关于冯诺依曼,必须强调几点:
- 这里的存储器指的是内存
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)
- 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
- 一句话,所有设备都只能直接和内存打交道。
你用QQ和朋友聊天时数据的流动过程
要使用QQ,首先要确保联网。当你向朋友发送消息时,你的电脑将键盘作为输入设备,显示器和网卡作为输出设备。而你的朋友的电脑则将网卡作为输入设备,显示器作为输出设备。
一开始,你通过键盘输入消息,该消息被加载到内存中。随后,你的显示器可以从内存中获取消息并显示在你的电脑上,让你能够看到发送的消息。
接着,CPU从内存中获取消息并对其进行封装,随后将其写回内存。此时,你的网卡可以从内存中获取已经封装好的消息,经过一系列处理后(网络处理细节略),消息通过网络传输。在此过程中,你的朋友的网卡从网络中接收到消息,并将其加载到内存中。
随后,你的朋友的CPU从内存中获取消息并对其进行解包操作,然后将解包后的消息写回内存。最后,你的朋友的显示器从内存中获取消息并显示在他的电脑上。
操作系统(OperatorSystem)
概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。
设计OS的目的
- 与硬件进行交互,管理所有的软硬件资源。
- 为用户程序(应用程序)提供一个良好的执行环境。
定位
简单来说,操作系统就是一款进行软硬件资源管理的软件
操作系统的上下层都分别是什么
我们通过对上述的冯诺依曼结构的学习知道了计算机的最基本的组成部分都有什么。
但是我们现在的计算机的硬件不止单单上述那几个:
在操作系统内部有多个管理模块,共同作用用于操作系统的正常运行。
- 内存管理:内存分配、内存共享、内存保护以及内存扩张等等。
- 驱动管理:对计算机设备驱动驱动程序的分类、更新、删除等操作。
- 文件管理:文件存储空间的管理、目录管理、文件操作管理以及文件保护等等。
- 进程管理:其工作主要是进程的调度。
并且我们可以吧计算机的底层硬件分为三个部分:网卡,硬盘和其他。
网卡用于将电脑与因特网链接,硬盘用于储存计算机的各种数据,而其他则包含键盘鼠标显示器等输入输出设备。
但是这里的操作系统是如何与底层的硬件进行交互的呢?
我们可以看到操作系统内有一个模块叫作驱动管理,其则是用于通过驱动让操作系统与硬件搭造一个桥梁。
因为要使硬件与系统可以交互,则必须通过接口,但是计算机的硬件厂家比较多,每个厂家都可能采用不同的协议,若因为每次更换不同厂家的硬件都要更改系统的接口,这样的代价是比较大的,所以系统则提供了一个标准的接口,使操作系统能够了解和控制硬件设备的功能,而无需了解硬件的细节或内部工作原理。驱动程序充当了操作系统与硬件之间的翻译器和中间人角色。
操作系统的上层是用户,用户通过操作系统来使用计算机。
但是出自于计算机的保护,用户不能直接访问操作系统。(直接访问的风险很大)
所以操作系统提供了各种用于访问的接口,即系统调用接口。
同时,这些接口对于我们普通的用户而言学习和使用的成本太高了,从而我们封装了系统的各种接口,构成用户调用接口层,再通过shell外壳或者部分指令来让用户来更高效率的来访问系统。
如何理解“管理"
要想学好操作系统,那么就必须正确理解到底什么是管理。
众所周知,我们在上学的时候,我们所扮演的角色是学生,一个学校还有校长,和辅导员。
很明显,这里的校长就是管理者,而我们学生则是被管理者,但是根据实际生活也知道,校长是一般不会直接来管理全校的所有学生的,所以,这里就出现了一个新角色———辅导员。
校长作为管理者来管理学生,校长实际上就是那个做决策的人,但是校长作出决策后并不需要自己来执行,而是让辅导员去执行,所以辅导员的主要任务就是执行管理者的决策,我们通常将其称为执行者。
虽然说校长是管理学生的,但是我们在学校一般情况下是看不到校长本人的,那么校长是如何做到在不看到我们的情况下对我们进行管理的呢?
举个例子,现在校长要求辅导员将计算机成绩排名前十的学生的各科资料以及平时表现记录拿过来,他将从这十名同学之中选出三名学生参加本次的编程大赛,当辅导员将资料拿来后校长选出三名学生说:“就这三个了,你找个老师对这三名学生进行一下强化训练,然后参加本次的编程大赛。”然后校长就什么也不管了。
在这一过程中,校长事实上并未亲眼见到这三名学生,却对他们进行了管理。他所依据的是什么?没错,他所依据的是数据。
实际上,学校对每个学生的各种信息进行了管理,包括基本信息、成绩信息以及健康信息等等。每一套信息都描述了一个学生,校长通过对这些信息的管理来实现对学生的管理。在C语言中,这一套信息被称为抽象结构体,在C++中则被称为面向对象。
当学生数量增多时,校长便可以将所有学生的信息组织起来。当然,组织的方式有很多种,比如链表、顺序表、树等等。每种组织方式都有其优势,因此就有了一门专门教导我们如何管理数据的课程,那就是数据结构。在这里,我们假设校长以双链表的形式将学生信息组织起来。
在这种情况下,校长对每个学生的管理实际上转变为对双链表的增删查改操作。当有新生时,直接向该双链表添加一个节点;而当学生毕业时,只需将其信息从双链表中移除即可。
概括来说:管理者对被管理者的管理实际上是先描述被管理者的各种信息,然后将这些描述信息根据某种数据结构组织起来。最后,管理者对被管理者的实际管理就是对这个数据结构的管理。
总结
计算机管理硬件
- 描述起来,用struct结构体
- 组织起来,用链表或其他高效的数据结构
进程
基本概念
课本概念: 程序的一个执行实例,正在执行的程序等。
内核观点: 担当分配系统资源(CPU时间,内存)的实体。
描述进程-PCB
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是task_struct
在Linux中描述进程的结构体叫做task_struct,task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
task_ struct内容
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
组织进程
在 Linux 内核中,进程(也称为任务)以一个双向链表的形式组织在一起。这个链表的每个节点都是一个 task_struct 结构体,包含了表示一个进程所需的所有信息。
这个链表通常被称为进程链表或任务链表,其中每个节点都指向下一个节点和上一个节点,从而形成了一个双向链表的结构。这种双向链表的结构有助于高效地遍历进程列表以及进行进程管理操作,比如创建、销毁和调度进程等。
并且由于 Linux 是一个多任务操作系统,因此这个链表中可以包含大量的进程,每个进程都有自己的 task_struct 结构体。这些结构体通过链表连接在一起,构成了操作系统内核中进程管理的基础数据结构之一。
同时当操作系统启动时,会创建一个初始的进程(通常是 init 进程),然后根据需要动态地创建和销毁其他进程。这些进程都会被加入到进程链表中,依靠这个链表,操作系统能够有效地管理系统中运行的所有进程。
进程=内核task_struct结构体+程序的代码和数据
查看进程
通过目录查询
Linux系统中万物皆文件,所以进程也一定是以文件的形式存在于系统中的。
我们可以在根目录找到下面这个路径,其里面就是存放的进程的相关信息。
这些数字其实是某一进程的PID,对应文件夹当中记录着对应进程的各种信息。我们若想查看PID为1的进程的进程信息,则查看名字为1的文件夹即可。
通过指令查询
我们也可以通过指令来查询系统的进程信息,下面来了解以下几个指令:
- ps:ps 命令是最基本和常用的进程查询命令。它可以显示当前终端下的进程信息。简单地键入 ps 就会显示当前用户的所有进程。
- ps aux:ps aux 命令显示了所有用户的所有进程的详细信息,包括进程的用户、PID、CPU 使用情况、内存使用情况等。
- top:top 命令提供了一个实时的系统进程监视功能,显示当前系统中运行的所有进程的动态信息。只需键入 top,你就会看到类似任务管理器的实时进程信息。
使用ps -aux
来查询
使用 top
来查询
因为直接使用ps来查询的话,打印的东西比较繁杂,通常我们搭配grep来使用。
ps aux |head -1 && ps aux | grep myprocess |grep -v grep
让我们来逐步来理解这段语句:
- ps aux | head -1:这部分命令首先执行
ps aux
命令,它会列出当前系统上所有进程的详细信息。然后 通过管道|
将输出传递给head -1
命令,head -1
的作用是只显示输出的第一行。这样做是为了显示ps aux
命令输出的表头 其中包含了各列的名称,比如 USER、PID、%CPU、%MEM 等。 - ps aux | grep myprocess | grep -v grep: 这部分命令使用
ps aux
命令列出所有进程的详细信息,并将输出通过管道|
传递给grep myprocess
,这个命令会过滤出包含 “myprocess” 字符串的行。接着,又将过滤出的结果再次通过管道
传递给grep -v grep
,这个命令的作用是去除包含 “grep” 字符串的行, 这是因为当你用 grep 命令查找进程时,grep 命令本身也会出现在进程列表中,我们不希望将它包括在结果中。 最终,输出的结果将显示出符合条件的进程信息。
通过系统调用获取进程的PID和PPID
我们在上文说过:
标示符: 描述本进程的唯一标示符,用来区别其他进程。
那我们来了解一下 PID(进程标识符) 和 PPID(父进程标识符) 。
-
PID(进程标识符):PID 是一个唯一的整数,用于标识在 Linux 中运行的每个进程。当操作系统启动一个新的进程时,会为该进程分配一个唯一的 PID。PID 通常从 1 开始递增,直到达到操作系统所支持的最大 PID 值(通常是 32767 或更高)。
-
PPID(父进程标识符):PPID 是指创建当前进程的进程的 PID。在 Linux 中,每个进程都有一个父进程,除了初始进程(通常是 PID 1)。当父进程创建一个新的子进程时,子进程的 PPID 就会被设置为父进程的 PID。PPID 用于建立进程之间的父子关系。通过 PPID,可以追溯到进程的创建链条,即该进程是如何被创建的。
我们可以通过系统函数的调用来得到这两个标识符,可以通过 man
来查询一下
我们可以通过getpid() 和 getppid() 来分别获得:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include <sys/types.h>
4 int main ()
5 {
6 while(1)
7 {
8 printf("I am a process my ppid is %d , my pid is %d \n",getppid(),getpid());
9 sleep(1);
10 }
11 }
这里使用sleep
函数让代码打印完之后休息1s.
当运行该代码生成的可执行程序后,便可循环打印该进程的PID和PPID。
我们可以通过ps
命令查看该进程的信息,即可发现通过ps命令得到的进程的PID和PPID与使用系统调用函数getpid
和getppid
所获取的值相同。
通过系统调用创建进程-fork初识
先man一下fork
[qq@iZ0jl65jmm6w9evbwz2zuoZ 3_19]$ man fork
fork()
函数是 Unix 和类 Unix 系统中的一个系统调用,用于创建一个新的进程,这个新进程称为子进程,它是调用 fork() 的进程(父进程)的一个复制。
当调用 fork() 函数时,操作系统会复制当前进程的所有内存内容(包括代码、数据、堆栈等) 到新的进程空间中,并在新的进程空间中执行相同的程序。这样,原始进程和新创建的进程会成为两个完全独立的进程,它们共享相同的代码和数据,但拥有不同的内存地址空间、进程 ID(PID)和父进程 ID(PPID)。
其中,pid_t
是一个整数类型,表示进程 ID,fork()
函数返回两次:在父进程中返回新创建的子进程的 PID,而在子进程中返回 0。这样,通过判断 fork() 的返回值,可以知道当前是在父进程还是子进程中执行。
我们可以通过下面这个代码来理解这个过程
在运行这段代码的时候我们可以监控这个进程:
当然运行该代码后,我们可以通过以下监控脚本,每隔一秒对该进程的信息进行检测。
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done
进程状态
操作系统创建进程时把进程的状态信息保存在进程PCB中,进程的状态主要有运行、阻塞、挂起状态
创建状态:进程在创建时需要申请一个空白PCB,向其中填写控制和管理进程的信息,完成资源分配。如果创建工作无法完成,比如资源无法满足,就无法被调度运行,把此时进程所处状态称为创建状态
就绪状态:进程已经准备好,已分配到所需资源,只要分配到CPU就能够立即运行
执行状态:进程处于就绪状态被调度后,进程进入执行状态
阻塞状态:正在执行的进程由于某些事件(I/O请求,申请缓存区失败)而暂时无法运行,进程受到阻塞。在满足请求时进入就绪状态等待系统调用
终止状态:进程结束,或出现错误,或被系统终止,进入终止状态。无法再执行
看看Linux内核源代码怎么说
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在
Linux内核里,进程有时候也叫做任务)。
下面的状态在kernel源代码里定义:
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
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 */
};
- R (running): 表示进程正在运行。
- S (sleeping): 表示进程正在睡眠状态。
- D (disk sleep): 表示进程正在等待磁盘 I/O 操作完成的睡眠状态。
- T (stopped): 表示进程已停止。
- t (tracing stop): 表示进程因为正在被跟踪而停止。
- X (dead): 表示进程已经终止。
- Z (zombie): 表示进程成为僵尸进程,即已经终止但其父进程尚未收回其资源。
注意:进程的当前状态是保存到自己的进程控制块(PCB)当中的,在Linux操作系统当中也就是保存在task_struct当中的。
进程状态查看
在Linux操作系统当中我们可以通过 ps aux 或 ps axj 命令查看进程的状态。
[qq@iZ0jl65jmm6w9evbwz2zuoZ 3_20]$ ps ajx
[qq@iZ0jl65jmm6w9evbwz2zuoZ 3_20]$ ps aux
二者的区别在于 -j 会显示作业控制相关的信息,而 -u 会显示与用户相关的信息。
运行状态-R
一个进程处于运行状态(running),并不意味着进程一定处于运行当中,运行状态表明一个进程要么在运行中,要么在运行队列里。也就是说,可以同时存在多个R状态的进程。
所有处于运行状态,即可被调度的进程,都被放到运行队列当中,当操作系统需要切换进程运行时,就直接在运行队列中选取进程运行。
浅度睡眠状态-S
一个进程处于浅度睡眠状态(sleeping),意味着该进程正在等待某件事情的完成,处于浅度睡眠状态的进程随时可以被唤醒,也可以被杀掉(这里的睡眠有时候也可叫做可中断睡眠(interruptible sleep))。
比如一个sleep函数,如下图:
这里的sleep
会是程序在运行的时候,睡眠100s,即我们可以查看到进程的浅度睡眠状态S
而处于浅度睡眠状态的进程是可以被杀掉的,我们可以使用kill命令将该进程杀掉。
深度睡眠状态-D
一个进程处于深度睡眠状态(disk sleep),表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复。该状态有时候也叫不可中断睡眠状态(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束。
例如,某一进程要求对磁盘进行写入操作,那么在磁盘进行写入期间,该进程就处于深度睡眠状态,是不会被杀掉的,因为该进程需要等待磁盘的回复(是否写入成功)以做出相应的应答。(磁盘休眠状态)
暂停状态-T
在Linux当中,我们可以通过发送SIGSTOP信号使进程进入暂停状态(stopped),发送SIGCONT信号可以让处于暂停状态的进程继续运行。
例如,我们对一个进程发送SIGSTOP信号,该进程就进入到了暂停状态。
我们再对该进程发送SIGCONT信号,该进程就继续运行了。
注意: 使用kill命令可以列出当前系统所支持的信号集。
[qq@iZ0jl65jmm6w9evbwz2zuoZ 3_20]$ kill -l
僵尸状态-Z
当一个进程将要退出的时候,在系统层面,该进程曾经申请的资源并不是立即被释放,而是要暂时存储一段时间,以供操作系统或是其父进程进行读取,如果退出信息一直未被读取,则相关数据是不会被释放掉的,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态(zombie)。
首先,僵尸状态的存在是必要的,因为进程被创建的目的就是完成某项任务,那么当任务完成的时候,调用方是应该知道任务的完成情况的,所以必须存在僵尸状态,使得调用方得知任务的完成情况,以便进行相应的后续操作。
例如,我们写代码时都在主函数最后返回0。
实际上这个0就是返回给操作系统的,告诉操作系统代码顺利执行结束。在Linux操作系统当中,我们可以通过使用echo $?命令获取最近一次进程退出时的退出码。
程序退出的信息(例如退出码),是暂时被保存在其进程控制块当中的,在Linux操作系统中也就是保存在该进程的task_struct当中。
死亡状态-X
死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,所以你不会在任务列表当中看到死亡状态(dead)。
Z(zombie)-僵尸进程
僵尸进程就是我们刚刚所提到的处于僵尸状态的进程。
退出的信息未被读取,相关的数据未被释放,该进程一直在等待其退出信息被读取,故而该进程是僵尸进程。
我们来一个创建维持30秒的僵死进程例子:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("I am running...\n");
pid_t id = fork();
if(id == 0){ //child
int count = 5;
while(count){
printf("I am child...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);
sleep(1);
count--;
}
printf("child quit...\n");
exit(1);
}
else if(id > 0){ //father
while(1){
printf("I am father...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
}
else{ //fork error
}
return 0;
}
子进程退出,一直等待父进程来读取退出信息,但是父进程因为一直还在运行,无法读取,所有此时的子进程就是一个僵尸进程(Z).
僵尸进程危害
- 僵尸进程的退出状态必须一直维持下去,因为它要告诉其父进程相应的退出信息。可是父进程一直不读取,那么子进程也就一直处于僵尸状态。
- 僵尸进程的退出信息被保存在task_struct(PCB)中,僵尸状态一直不退出,那么PCB就一直需要进行维护。
- 若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
- 僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致内存泄漏。
孤儿进程
我们思考这样一个问题:
父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
在Linux当中的进程关系大多数是父子关系,若子进程先退出而父进程没有对子进程的退出信息进行读取,那么我们称该进程为僵尸进程。但若是父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。
若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号init进程领养,此后当孤儿进程进入僵尸状态时就由int进程进行处理回收。
我们通过下面这段代码来理解这个过程:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 1;
}
else if(id == 0){//child
printf("I am child, pid : %d\n", getpid());
sleep(10);
}else{//parent
printf("I am parent, pid: %d\n", getpid());
sleep(3);
exit(0);
}
return 0;
}
父进程退出后,子进程被1号进程领养
进程优先级
基本概念
优先级实际上就是获取某种资源的先后顺序,而进程优先级实际上就是进程获取CPU资源分配的先后顺序,就是指进程的优先权(priority),优先权高的进程有优先执行的权力。
查看系统进程
在Linux或者Unix操作系统中,用ps -l命令会类似输出以下几个内容:
[qq@iZ0jl65jmm6w9evbwz2zuoZ 3_20]$ ps -l
我们先了解一下下面这几个重要信息:
- UID : 代表执行者的身份
- PID : 代表这个进程的代号
- PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行
- NI :代表这个进程的nice值
PRI and NI
- PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小
进程的优先级别越高 - 那NI,就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值,nice其取值范围是-20至19,一共40个级别。
- PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
- 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
- 所以,调整进程优先级,在Linux下,就是调整进程nice值
PRI vs NI
- 需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进
程的优先级变化。 - 可以理解nice值是进程优先级的修正修正数据
查看进程优先级的命令
我们可以使用ps -al命令查看该进程优先级的信息。
[qq@iZ0jl65jmm6w9evbwz2zuoZ 3_20]$ ps -al
用top命令更改已存在进程的nice:
top命令就相当于Windows操作系统中的任务管理器,它能够动态实时的显示系统当中进程的资源占用情况。
那怎么通过top来修改呢?
进入top后按“r”–>输入进程PID–>输入nice值
使用top命令后按“r”键,会要求你输入待调整nice值的进程的PID。
输入进程PID并回车后,会要求你输入调整后的nice值
输入nice值后按“q”即可退出,如果我们这里输入的nice值为10,那么此时我们再用ps命令查看进程的优先级信息,即可发现进程的NI变成了10,PRI变成了90(80+NI)。
注意: 若是想将NI值调为负值,也就是将进程的优先级调高,需要使用sudo命令提升权限。
通过renice命令更改进程的nice值
使用renice命令,后面跟上更改后的nice值和进程的PID即可。
其他概念
- 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便有了优先级。
- 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
- 并行: 多个进程在多个CPU下分别同时进行运行,这称之为并行。
- 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
环境变量
基本概念
环境变量(environment variables) 一般是指在操作系统中用来指定操作系统运行环境的一些参数
例如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但
是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
常见环境变量
- PATH : 指定命令的搜索路径
- HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
- SHELL : 当前Shell,它的值通常是/bin/bash。
查看环境变量方法
echo $NAME //NAME:你的环境变量名称
测试PATH
我们之前提过,在Linux中,万物皆文件,我们每一条执行的语句都是一个可执行文件。
比如我们使用的 ls
,为什么在我们使用 ls 的时候就不用带上路径呢?
这时我们可以使用which
来搜索一下ls
的路径
我们看到ls
的路径是 /usr/bin/ls
而我们刚刚查到的PATH
不正好包含了这个路径,故而执行ls
的时候不用带上路径,而对于我们的myprocess
文件,我们也可以吧其路径加入到PATH
下,以后就可以直接执行myprocess
了
我们有两种方式:
方式一:将可执行程序拷贝到环境变量PATH的某一路径下。
[qq@iZ0jl65jmm6w9evbwz2zuoZ 3_20]$ sudo cp myprocess /usr/bin/
这里需要sudo提权一下。
这样我们就可以直接运行myprocess了。
方式二:将可执行程序所在的目录导入到环境变量PATH当中。
[qq@iZ0jl65jmm6w9evbwz2zuoZ 3_20]$ export PATH=$PATH:/home/qq/bt111/Linux/3_20
将可执行程序所在的目录导入到环境变量PATH当中后,位于该目录下的可执行程序也就可以在不带路径的情况下执行了。
测试HOME
任何一个用户在运行系统登录时都有自己的主工作目录(家目录),环境变量HOME当中即保存的该用户的主工作目录。
普通用户示例:
超级用户示例:
测试SHELL
我们在Linux操作系统当中所敲的各种命令,实际上需要由命令行解释器进行解释,而在Linux当中有许多种命令行解释器(例如bash、sh),我们可以通过查看环境变量SHELL来知道自己当前所用的命令行解释器的种类。
而该命令行解释器实际上是系统当中的一条命令,当这个命令运行起来变成进程后就可以为我们进行命令行解释
和环境变量相关的命令
- echo:显示某个环境变量的值。
- export:设置一个新的环境变量。
- env:显示所有的环境变量。
- set:显示本地定义的shell变量和环境变量。
- unset:清除环境变量。
env:显示所有的环境变量:
set:显示本地定义的shell变量和环境变量。
部分环境变量说明:
环境变量的组织方式
在系统当中,环境变量的组织方式如下:
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串
通过代码如何获取环境变量
让我们重新来认识一下 main()
函数,其实main是有三个参数的。
- argc(ArgumentCount):这是一个整数,表示在命令行中传递给程序的参数数量,包括程序名。argc至少为1,因为程序名本身也算一个参数。
- argv [ ](ArgumentVector):这是一个指向字符数组的指针(指向字符指针的指针),它包含了命令行参数的实际值。 这个数组中的每个元素都是一个指向字符串的指针。
- envp [ ] (EnvironmentPointer):这个参数是可选的,在一些操作系统中可能不存在。它是一个指向环境变量的指针数组。
在Linux操作系统下,编写以下代码,生成可执行程序并运行。
结果如下:
来看下面这个代码:
运行结果分别是:
main函数的第二个参数是一个字符指针数组,数组当中的第一个字符指针存储的是可执行程序的位置,其余字符指针存储的是所给的若干选项,最后一个字符指针为空,而main函数的第一个参数代表的就是字符指针数组当中的有效元素个数。
main函数的第三个参数接收的实际上就是环境变量表,我们可以通过main函数的第三个参数来获取系统的环境变量。
例如,编写以下代码,生成可执行程序并运行。
运行结果就是各个环境变量的值:
除了使用main函数的第三个参数来获取环境变量以外,我们还可以通过第三方变量environ来获取
结果如下:
注意: libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern进行声明。
通过系统调用获取或设置环境变量
除了通过main函数的第三个参数和第三方变量environ来获取环境变量外,我们还可以通过系统调用getenv函数来获取环境变量。
getenv函数可以根据所给环境变量名,在环境变量表当中进行搜索,并返回一个指向相应值的字符串指针。
例如,使用getenv函数获取环境变量PATH的值。
程序地址空间
我们再来理解一下这个图:
- 进程地址空间是描述进程在内存中布局的数据结构,因为操作系统要对进程地址空间进行管理,(先用结构体描述起来,再用特定的数据结构组织起来)。
- 进程地址空间是指操作系统为每个运行的进程分配的虚拟内存空间。每个进程都有其独立的地址空间,32位下取值范围是[0,4GB]。这是操作系统提供给进程用于存储程序代码、数据、堆、栈以及其他相关信息的虚拟内存区域。
- 正文代码: 也称为代码段,存放可执行代码/只读常量 不允许被修改。
- 初始化数据 && 未初始化数据:叫做数据段,存储全局变量和静态变量,包括初始化和未初始化的数据。上图将数据段分为两个区域。
- 堆区: 动态分配 的内存空间,用于存储程序运行时动态分配的数据。堆的大小可以在运行时动态增长或缩小。
- 共享区: 进程间的共享数据或通信
- 栈区: 又叫做堆栈 存放非静态局部变量/函数参数/返回值等等
- 命令行参数环境变量:存放命令行参数和环境变量相关
我们可以通过下面这个代码来进行验证:
虚拟地址:
我们先来看下面这个代码:
我们使用fork()
创建了父子进程,然后分别打印出了其pid
,ppid
和g_val
结果如下:
但是如果我们对代码做出如下简单的修改呢?
结果如下:
可以看到,子进程和父进程打印出的g_val
值并不一样,子进程对g_val
的修改没有影响到父进程的g_val
。
可是为什么子父进程的g_val
打印出来的地址确实一样的呢?
- 我们可以猜测,应该时我们上文所提到的子父进程具有独立性 ,即两者的数据互不干扰,所以在我修改子进程的
g_val
值时,父进程的g_val
不会改变。 - 而且这里的地址应该不是真正的物理地址,而是一个虚拟地址!
- 并且我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
对应的看一下这个图:
进程地址空间
在多任务操作系统中,有多个程序(进程)同时运行,每个程序都需要访问内存、执行代码等。为了实现这一点,操作系统需要为每个程序提供一个独立的内存空间,即进程地址空间。
进程地址空间的实现是通过虚拟内存管理机制来完成的,这一机制的核心是分页和分段技术。
- 分页机制:虚拟内存被划分为大小固定的页(通常是4KB),这些页被用作地址空间的基本单位。物理内存也被划分为相同大小的页。操作系统负责将虚拟地址空间中的页映射到物理内存中的页,这种映射关系被称为页表。每个进程都有自己的页表,用于管理其虚拟地址空间和物理内存的映射关系。
- 分段机制:除了分页之外,Linux还使用分段机制来管理进程的地址空间。这个机制允许进程的地址空间被划分为多个段,比如代码段、数据段、堆和栈等。每个段都可以有自己的权限和大小限制。
分段机制: 分段是一种内存管理方式,就类似于一把尺子,尺子的刻度由0x00000000
到0xffffffff
,它将进程的地址空间划分为多个逻辑段或区域,每个段具有不同的属性和权限。常见的段包括代码段、数据段、堆、栈等。分段机制允许进程根据需要动态地申请和释放内存,并且可以控制不同段的访问权限,从而提高了内存的利用率和安全性。
struct mm_struct: 在Linux内核中,每个进程都有一个关联的struct mm_struct
数据结构,用于描述该进程的内存管理信息。struct mm_struct
包含了与进程地址空间相关的重要信息,比如页表、内存映射、共享内存等。它是进程地址空间的管理者,负责维护和操作进程的虚拟地址空间。
分段机制是地址空间的一部分,它描述了地址空间的逻辑结构,而struct mm_struct
则负责管理和操作这些逻辑结构。因此,struct mm_struct
与分段机制密切相关,但并不是分段机制的具体实现。
从代码的角度来看,struct mm_struct
是进程地址空间管理的核心数据结构,它保存了进程的地址空间中的重要信息,并提供了对进程地址空间的管理和操作接口。在Linux内核中,许多内存管理的操作都是通过操作struct mm_struct
来实现的,比如分配和释放内存、管理内存映射关系、更新页表等。因此,了解struct mm_struct的内部代码可以帮助我们理解进程地址空间的管理机制,以下是其相关的代码:
struct mm_struct {
// 进程的页表指针
pgd_t *pgd;
// 内存映射链表头
struct vm_area_struct *mmap;
// 进程内存锁定的页数
atomic_long_t locked_vm;
// 内存页的使用统计信息
struct mm_rss_stat rss_stat;
// 进程的内存限制
struct rlimit *rlim_stack;
// 内存映射链表的地址空间
struct vm_area_struct *mmap_cache;
// 内存映射链表中最大的地址
unsigned long mmap_base;
// 内存映射链表中最大的地址限制
unsigned long mmap_legacy_base;
// 内存映射链表的长度
unsigned long mmap_legacy_len;
// 内存映射链表中的总长度
unsigned long mmap_legacy_map;
// 内存映射链表的尾部
struct vm_area_struct *mmap_high;
// 进程的用户空间代码区的起始地址
unsigned long start_code;
// 进程的用户空间代码区的结束地址
unsigned long end_code;
// 进程的用户空间数据区的起始地址
unsigned long start_data;
// 进程的用户空间数据区的结束地址
unsigned long end_data;
// 进程的用户空间堆的起始地址
unsigned long start_brk;
// 进程的用户空间堆的结束地址
unsigned long brk;
// 进程的用户空间栈的起始地址
unsigned long start_stack;
// 进程的用户空间栈的最大大小
unsigned long stack_vm;
// 进程的用户空间栈的最大地址
unsigned long stack_vm_base;
// 进程的用户空间栈的大小限制
unsigned long stack_max;
// 进程的用户空间栈的大小
unsigned long total_vm;
// 进程的用户空间栈的大小统计信息
unsigned long locked_vm;
// 进程的用户空间数据和堆的大小
unsigned long data_vm;
// 进程的用户空间代码区的大小
unsigned long exec_vm;
// 进程的用户空间栈的大小统计信息
unsigned long stack_vm;
};
我们可以通过下面这个图来理解分段机制:
在结构体mm_struct当中,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于虚拟地址是由0x00000000到0xffffffff线性增长的,所以虚拟地址又叫做线性地址。
注意:
1、堆向上增长以及栈向下增长实际就是改变mm_struct当中堆和栈的边界刻度。
2、生成可执行程序时,编译器会对源代码进行优化,并根据程序的结构划分为不同的区域,如代码段、数据段等。这些优化和分区操作有助于提高操作系统的效率。然而,优化级别通常由编译器的参数控制,而不是完全由编译器说了算。开发人员可以根据需求选择合适的优化级别。在运行可执行程序时,操作系统会根据需要将程序的不同区域加载到内存中,由操作系统的加载器完成。因此,操作系统在程序运行时实际决定如何加载和管理程序的各个区域。
好了,让我们来理解一下一个进程的创建全过程:
1 . 分配进程控制块(task_struct): 操作系统会为新进程分配一个进程控制块,其中包含了进程的所有控制信息,如进程ID、状态、调度信息等。这个进程控制块是操作系统管理进程的重要数据结构。
2. 分配进程地址空间(mm_struct): 同时,操作系统也会为新进程分配一个进程地址空间结构体 mm_struct
,用于管理进程的内存空间。mm_struc
t 中包含了进程的虚拟地址空间的描述信息,如页表、内存映射等。
3. 初始化进程地址空间 操作系统会根据新进程的需要初始化其地址空间。这包括将代码段、数据段等映射到适当的物理内存位置,并设置合适的权限等。
4. 将进程控制块和进程地址空间关联起来: 操作系统会将进程控制块中的一个指针指向对应的进程地址空间结构体,从而建立起两者之间的关联关系。这样操作系统就可以通过进程控制块找到对应的地址空间。
此时使用 fork() 来创建子进程时,子进程初始时会与父进程共享相同的地址空间。这意味着子进程的 mm_struct
指针会指向父进程的地址空间结构体,两者共享相同的页表和内存映射。如下图:
而当父子进程中的任一进程尝试修改共享的数据时,操作系统会进行写时拷贝,即操作系统会为修改后的数据分配新的物理内存空间,并更新子进程的页表,使其指向新的物理内存位置。这样子进程的修改就不会影响到父进程或其他子进程。如下图:
写时拷贝的基本原理是延迟数据复制,即在需要修改共享数据时才进行数据的拷贝。这样可以节省内存开销,避免不必要的数据复制。因此,当父进程创建子进程时,子进程最初会与父进程共享相同的内存空间,即它们共享同一个物理内存区域。只有当父子进程中的任一进程尝试修改共享的数据时,操作系统才会进行数据的复制,从而确保各个进程之间的数据隔离和独立性。
写时拷贝技术通常用于共享内存中的数据,而不包括代码段。具体来说,写时拷贝主要涉及到进程在运行时动态分配的内存数据,比如堆(heap)中的数据以及共享内存中的数据。
举例来说,当父进程创建子进程时,子进程最初会与父进程共享相同的内存空间,包括堆中的数据和共享内存中的数据。如果父子进程中的任一进程尝试修改共享的内存数据,比如堆中的某个变量,操作系统就会触发写时拷贝机制。此时,操作系统会将被修改的数据(如堆中的变量)复制到一个新的内存位置,然后更新子进程的页表,使其指向新的内存位置。这样,父进程和其他子进程仍然共享原始的内存数据,而子进程可以安全地修改自己的副本。
需要注意的是,代码段通常不会被写时拷贝。代码段属于只读数据,通常在程序加载时就已经被映射到内存中,并且不会被修改。因此,代码段是共享的,而不需要进行写时拷贝。
所以,对于最刚开始写的代码,为什么g_val的值会发生改变,但是地址却相同的问题应该得到了解决了。
具体细节可以看下图:
Linux2.6内核进程调度队列
一个CPU拥有一个runqueue
如果有多个CPU就要考虑进程个数的父子均衡问题。
优先级
queue下标说明:
- 普通优先级:100~139。
- 实时优先级:0~99。
我们进程的都是普通的优先级,前面说到nice值的取值范围是-20 ~ 19,共40个级别,依次对应queue当中普通优先级的下标100~139。
注意: 实时优先级对应实时进程,实时进程是指先将一个进程执行完毕再执行下一个进程,现在基本不存在这种机器了,所以对于queue当中下标为0~99的元素我们不关心。
活动队列
时间片还没有结束的所有进程都按照优先级放在活动队列当中,其中nr_active代表总共有多少个运行状态的进程,而queue[140]数组当中的一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进程排队调度。
调度过程如下:
- 从0下标开始遍历queue[140]。
- 找到第一个非空队列,该队列必定为优先级最高的队列。
- 拿到选中队列的第一个进程,开始运行,调度完成。
- 接着拿到选中队列的第二个进程进行调度,直到选中进程队列当中的所有进程都被调度。
- 继续向后遍历queue[140],寻找下一个非空队列。
bitmap[5]:queue数组当中一共有140个元素,即140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5 × \times× 32个比特位表示队列是否为空,这样一来便可以大大提高查找效率。
总结: 在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不会随着进程增多而导致时间成本增加,我们称之为进程调度的O(1)算法。
过期队列
- 过期队列和活动队列的结构相同。
- 过期队列上放置的进程都是时间片耗尽的进程。
- 当活动队列上的进程被处理完毕之后,对过期队列的进程进行时间片重新计算。
active指针和expired指针
- active指针永远指向活动队列。
- expired指针永远指向过期队列。
由于活动队列上时间片未到期的进程会越来越少,而过期队列上的进程数量会越来越多(新创建的进程都会被放到过期队列上),那么总会出现活动队列上的全部进程的时间片都到期的情况,这时将active指针和expired指针的内容交换,就相当于让过期队列变成活动队列,活动队列变成过期队列,就相当于又具有了一批新的活动进程,如此循环进行即可。