进程的属性
1.进程标识符(PID): 进程描述task_struct 结构体中的 pid 字段可以唯一标识一个进程,称为进程标识符PID。进程最主要的属性就是进程号(PID)和它的父进程号(PPID),两者都是非零正整数。每个进程的PID不会相同,系统调用 getpid 来获得进程标识符。
一个PID唯一地标识一个进程。一个进程创建一个新进程称为创建子进程,创建子进程的进程称为父进程。所有的进程都是 PID 为1的 init 进程的后代。内核在系统启动的最后阶段启动 init 进程。
子父进程的关系: 父进程和子进程之间是管理者和被管理者的关系,当父进程停止时,子进程会随之消失;但子进程关闭,父进程不一定终止。
2.用户标识符(UID): 用户标识符标识创建这个进程的用户。在PCB中,还有euid(有效用户标识符),表示以有效的权限发起进程的用户。
3.进程状态:
(1)运行态(R):正在被CPU执行或者已经准备就绪随时可由调度程序执行的进程。
(2)可中断睡眠态(S):处于中断等待状态,且能够被中断唤醒转换到就绪/运行态的进程。
(3)不可中断睡眠态(D):等同于可中断睡眠态,不过不能够被中断唤醒,必须等待 wake_up 函数的明确唤醒才能转换到就绪/运行态的进程。
(4)停止态(T):进程收到信号,进入暂停状态,不再继续进行工作,此状态下该进程无法被杀死,并且进程未退出,资源未释放。
(5)僵尸态(Z):进程已停止运行,但其父进程还没有询问其状态时,则该进程处于僵尸态,该状态下进程已退出,但资源未释放。
进程控制函数
1.fork 函数
作用:用于创建子进程,创建成功后,子进程存在于系统中,并且独立于父进程。子进程可接收系统的调度,也可得到分配的系统资源。
格式如下:
#include<unistd.h>
pid_t fork (void);
创建子进程成功,则父进程的返回值是子进程的进程ID;子进程的返回值为0;
创建子进程失败,则会返回一个小于0的PID。
例子如下:
由上图可知:
<1>子进程和父进程执行的顺序取决于系统,可能父进程先于子进程运行,也可能子进程先于父进程运行。
<2>子进程只会执行在创建子进程命令向下的全部程序(不含创建命令及其以上的命令)
如果改一下呢?改动如下:
可见子进程已经修改了count ,但是父进程打印的却仍是原本的 count,这是怎么回事呢?
原来进程访问的都是虚拟地址空间
虚拟地址空间 其实是一个结构体 mm_struct {…};
是一个对内存空间的虚拟描述,通过大小以及地址的起始/结束位置虚拟的向进程描述出了一个完整的、连续的内存空间。
为什么要建立虚拟地址空间?
1.为了避免进程直接访问物理内存,造成内存泄漏。进程使用都是连续的地址,直接访问会造成浪费。
2.为了安全,比如一旦你写出一个野指针,直接访问物理内存的话会更改系统的值,后面的程序或者系统都会崩溃。
如何通过虚拟地址访问物理内存呢?
操作系统在创建虚拟内存的同时,也创建了页表用于映射虚拟地址和物理内存地址的关系。
通过页表的映射,可实现进程数据在物理内存上的离散式存储。提高了内存的利用率,同时在页表上可以直接针对某地址的访问权限,以此避免野指针的访问。
那么本题修改值无法对应的原因呢?
那是因为在创建子进程的时候,子进程会继承父进程的配置,当然也包括页表,子进程中修改了count,但是子父进程的虚拟地址的映射过去的物理内存地址不相同(此时二者的虚拟地址相同,但通过页表映射过去的物理内存地址却不相同),所以子进程修改的值对父进程没有影响。同理,父进程修改也对子进程没有影响。
2.exit 函数
进程退出的方式有以下5种:
1> main函数的自然返回
2>调用 exit函数
3>调用 _exit函数
4>调用 abort函数
5>接收到能导致进程终止的信号(Ctrl+Z,Ctrl+C)
其中,前三种方式为正常终止方式,后两者为非正常终止方式。但无论哪种方式,都会执行相同的关闭文件操作,释放占用的内存等资源。
exit 和 _eixt都是用来终止进程的。 当程序执行到该函数是,进程会无条件的停止剩下的所有操作,清除包括PCB在内的各种数据结构,并终止本程序的运行。
二者的函数原型如下:
#include<stdlib.h>
void exit (int status)
#include<unistd.h>
void _exit (int status)
二者的区别:
exit 函数在调用 exit 系统调用前要检查文件的打开情况,会把文件缓冲区内的数据写入文件,之后才会清理PCB等数据,但 _exit 会直接清除PCB等数据,所以要想保证数据的完整性最好使用 exit 函数。
举例如下:
1.exit 函数
2. _ eixt 函数
可以看到二者明显的区别在于有没有打印第二局话。exit 函数在终止进程前会把缓冲区的数据写入文件种,而后清理数据,而 _exit 函数则不会如此。
3.wait 函数
作用:阻塞父进程等待任意一个子进程的退出,并返回退出的子进程的PID,同时获取子进程的返回值放入 status 所指向的空间中并且释放子进程资源。根本目的的为了避免僵尸进程的产生。
<1> wait 函数
函数原型:
pid_t wait ( int* status)
函数中的参数 status 是一个整形指针,是子进程退出时的状态。若 status 不为空,则通过它可以获得子进程的结束状态。
举例如下:这是不加 wait 函数的进程状态
可以看到 5s 后子进程变为僵尸态(Z)
下面是加了wait函数的进程状态:
可以看出加了 wait 函数后即使过了 5s 子进程也不会变成僵尸进程。
2> waitpid 函数
函数原型:
pid_t waitpid( pid_t pid,int* status, int options)
参数 pid 一般是子进程的PID,但也会有其他的取值。具体如下:
参数 | 含义 |
---|---|
pid > 0 | 等待指定的子进程(自己的子进程)退出 |
pid = -1 | 等待任意一个子进程的退出 |
pid = 0 | 等待同一进程组的子进程退出,如果子进程加入其他组,则无需等待 |
pid < -1 | 等待用一进程组的任何一个子进程的退出,进程组的 ID 等于 pid 的绝对值 |
该函数分为阻塞和非阻塞:
阻塞:为了完成某一个功能发起一个调用,若当前不具备完成功能的条件,则调用不返回一直等待。
非阻塞:为了完成某一个功能发起一个调用,若当前不具备完成功能的条件,则立即返回报错。(该状态下易产生僵尸进程,故需要配合循环使用)
参数 options 提供控制选项,具体如下:
参数 | 含义 |
---|---|
WNOHANG | 默认设置 waitpid 函数为非阻塞状态 |
WUNTRACED | 为实现某种挫折,由 pid 指定的任一进程已被暂停,其状态子暂停以来还未报告过,则返回其状态 |
waitpid 函数的返回值会出现以下3中:
1.正常返回时,返回子进程的 pid
2.如果使用 WNOHANG 并且没有子进程退出,则返回 0
3.如果调用过程出错,返回 -1
注意:
wait(status) == waitpid(-1,status,0) -> 二者是等价关系;
下面是非阻塞配合循环的使用:
由图可看出,5s后子进程正常退出,父进程向下正常执行,没有产生僵尸进程。
4. execve 函数
该函数得作用在于能够取代调用进程的内容,让子进程可以调用 execve 函数执行另外一个程序。当调用该函数时,子进程执行的程序会完全被替换成新程序,但其进程ID不变,只是用新进程的内容替换了子进程的内容。
被替换后的子进程就会去执行新进程的命令了,这样一来既可以分担父进程的工作压力,也可以帮助父进程去 “背锅” ,一旦出现问题,不会波及到父进程的正常执行。
execve 函数原型如下:
#include<unistd.h>
int execve ( const char* filename, const char* argv[], const char* envp[]);
execve 函数是系统调用函数,而我们一般使用由它所封装的6个函数:
我把它们分为两类:
第一类:含 l 的函数,这类函数输入时需要用不定参来完成,不需要借助指针数组。
以下函数都是用 ls -al 命令去替换子进程的模板
1.
int execl (const char* path,const char* arg, ... ,NULL)
//path 是你要替换的进程的路径,arg是参数命令,可以有很多,但第一个参数一般是自己,结尾用NULL收尾
//使用例子:
execl("/bin/ls","ls","-al",NULL);
2.
int execle (const char* path,const char* arg, ... ,NULL, char *const envp[])
//path 是你要替换的进程的路径,arg是参数命令,可以有很多,但第一个参数一般是自己,结尾用NULL收尾, envp是你想要传递给子进程的环境变量
//使用例子:
char* envp[] = {"PATH =/bin",NULL);
execle("/bin/ls","ls","-al",NULL,envp);
3.
int execlp (const char* path,const char* arg, ... ,NULL)
//该函数可以不输入完整路径,只需要输入命令即可,arg是参数命令,可以有很多,但第一个参数一般是自己,结尾用NULL收尾
//使用例子:
execlp("ls","ls","-al",NULL);
第二类:含 v 的函数,这类函数输入时需要借助指针数组来完成。
1.
int execv (const char* path,const char *argv[])
//path 是你要替换的进程的路径,所有的输入全部存放在指针数组 *argv[] 中
//使用例子:
char* argv[] = {"ls","-al","NULL"); //结尾同样用 NULL收尾
execv("/bin/ls",argv);
2.
int execve (const char* path,const char *argv[],char *const envp[])
//path 是你要替换的进程的路径,所有的输入全部存放在指针数组 *argv[] 中,同时envp[]中存放你需要更改的环境变量
//使用例子:
char* argv[] = {"ls","-al","NULL"); //结尾同样用 NULL收尾
char* envp[] = {"PATH =/bin",NULL);
execve("/bin/ls",argv,envp);
3.
int execvp (const char* path,const char *arg[])
//该函数可以不输入完整路径,只需要输入命令即可,所有的输入全部存放在指针数组 *argv[] 中
//使用例子:
char* argv[] = {"ls","-al","NULL"); //结尾同样用 NULL收尾
execvp("ls",argv);
以 p 结尾 和以 e 结尾的区别:
p 结尾的不需要在写详细的进程路径了,
e 结尾的则需要传入想要更改的环境变量。
页表
主要功能
- 映射虚拟地址和物理地址的关系,并且提供内存访问控制
页表如何实现通过虚拟地址访问物理地址
- 通过 MMU -> 内存管理单元,一种负责处理中央处理器(CPU)的内存访问请求,功能包括虚拟地址到物理地址的转换(即虚拟内存管理)、内存保护、中央处理器高速缓存的控制。
内存管理
1.分页式内存管理
-
地址组成: 页号 + 页内偏移
-
页号: 页表中页表项的编号
-
页内偏移: 一个具体变量的首地址相较于内存页起始位置的偏移量
-
物理内存块号 * 物理内存块大小(页面大小) + 虚拟地址的页内偏移 = 物理地址
-
内存大小 / 页面大小 = 页号所占的位数 = 高位位数 = 页表项个数(2^高位位数,页表项个数也就是页表长度)
假设内存大小为 4G, 页面大小为 4096,4G / 4096 = 2^20,意味着页号所占的位为20位,即高20位为页号,低12位为页内偏移,页表项个数为2^20
物理地址的计算
优点
- 将物理内存进行了分块管理,通过映射页表实现了物理内存的离散式存储,提高了效率
2.分段式
- 地址组成: 段号 + 段内偏移
- 段表: 段号 - 物理段起始地址
优点
- 使程序员对内存的管理更加的方便,将内存空间分成了代码段,初始化全局段;什么变量就在什么段内申请地址
3.段页式内存管理
- 先分段,再在每一个段中进行分页
- 地址组成:段号 + 段内页号 + 页内偏移 + 段表 + 段内页表
- 段表:段号 - 段内页表起始地址
- 页表: 页号 - 物理块号
物理地址寻找流程
- 每一个分段中都有一个页表,先通过地址中的段号,找到段项表;再通过段项表中段内页表起始地址找到自己的页表。
- 通过地址中的段内页号,在这个页表中找到页表项,通过页表项中的 物理块 + 页内偏移 得到最终的物理地址。
每种方式的优点总结
- 分页式: 提高了内存利用率
- 分段式:便于程序员/编译器的内存管理
- 段页式:集合了前二者的优点
缺页中断
当电脑内存只有 8G 时,运行的代码和数据都是存放在内存中的,这就意味着一旦运行的程序过多,就会导致电脑内存不够。
而磁盘的分区有两种: 交换分区 / 文件系统分区
交换分区
- 故名思意,是作为数据交换使用的分区,主要作为交换内存使用;
- 当系统内存不够时,系统会将那些不活跃的数据,也就是长时间没有使用过的数据根据一定的算法,将这些数据保存到磁盘的交换分区中,从而为系统腾出更多的内存去加载新的数据。
- 但是每一个进程的页表中都记录着每一个虚拟地址对应的物理地址,一旦系统把某一个页表中对应的物理地址的数据保存到了交换分区中,则系统会将这个页表项位置设置成缺页中断。
- 等到下次这个进程需要访问这个被保存到交换分区的数据时(当前数据不在内存中,而在交换分区中),就会触发缺页中断,重新从交换分区中把数据交换回来(用其他不活跃的数据去交换)。
常见的几种页面交换算法
- 进程运行过程中,如果发生缺页中断,而此时内存中有没有空闲的物理块是,为了能够把所缺的页面装入内存,系统必须从内存中选择一页调出到磁盘的对换区。但此时应该把那个页面换出,则需要根据一定的页面置换算法来确定。
1.最佳置换 (OPT)
- 置换以后不会再被访问,或者在将来最迟才会被访问的页面,缺页中断率最低。但是该算法需要依据以后各业的使用情况,而当一个进程还未运行完成是,很难估计哪一个页面是以后不再使用或在很长时间以后才会用到的页面。所以该算法是基本不能实现的。
2.先进先出置换算法 (FIFO)
- 置换最先调入内存的页面,即置换在内存中驻留时间最久的页面。按照进入内存的先后次序排列成队列,从队尾进入,从队首删除。但是该算法会淘汰经常访问的页面,不适应进程实际运行的规律,目前已经很少使用。
3. 最久未使用置换算法 (LRU)
-
置换最近一段时间以来最长时间未访问过的页面。根据程序局部性原理,刚被访问的页面,可能马上又要被访问;而较长时间内没有被访问的页面,可能最近不会被访问。
-
LRU算法普偏地适用于各种类型的程序,但是系统要时时刻刻对各页的访问历史情况加以记录和更新,开销太大,因此LRU算法必须要有硬件的支持。这也是最常用的一种方式。
-
堆栈实现LRU:
系统使用特殊的堆栈来存放内存中每一个页面的页号。每当访问一页时就调整一次,即把被访问页面的页号从栈中移出再重新压入栈顶。因此,栈顶始终是最新被访问页面的页号,栈底始终是最近最久未被访问的页号。当发生缺页中断时,总是淘汰栈底页号所对应的页面。
LRU置换的一道习题