地址空间
之前在学习进程的概念时,我们留了一个问题:在创建父子进程时,我们用 id = fork()
来接收 fork 的返回值,以便在接下来区分父子进程,那为什么 id 可以同时表示两个不同的值呢?今天我们就来初步了解一下其中的原理
直接看现象
在下面的代码中,我们定义一个全局变量 g_val ,之后创建子进程,子进程在运行5 秒后,对 g_val 进行修改。在修改前后,父子进程都把 g_val 的值与地址打印出来
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
int g_val = 100; // 全局变量
printf("Father process is running, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(5);
// 创建进程
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 0;
while(1)
{
if (cnt == 5)
{
g_val = 300; // 运行 5 秒,子进程修改全局变量
printf("I am child process, g_val: 100->300\n", g_val);
}
printf("I am child process, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
cnt++;
sleep(1);
}
}
else
{
// father
while(1)
{
printf("I am father process, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
运行结果:
可以看到,子进程对 g_val 进行修改后,父子进程输出的 g_val 的值是不同的,但令人迷惑的是,它们的地址却是相同的。这是为什么呢?
我们知道,一个 g_val 不可能同时表示两个不同的值,所以父子进程中的两个 g_val 必然不是同一个变量,它们只是名字相同,也就是说两个 g_val 的地址也不可能是相同的。所以父子进程打印出来的 g_val 地址必然不是真实的物理地址,而是虚拟地址
我们用 C/C++ 等语言看到的地址,都不是真正的物理地址,而是虚拟地址
引入地址空间
在进程的概念中我们已经知道:在操作系统内,启动一个进程,意味着需要创建对应进程的 PCB + 代码与数据。
而实际情况远比这复杂,操作系统还会为每个进程创建一个虚拟地址空间,并将其划分为代码区、堆区、栈区等,用来存放进程的代码与数据的地址等,准确来说是存放它们的虚拟地址。地址空间的大小在 32 位操作系统下是 4GB
与 PCB 一样,地址空间本质也是一种内核数据结构,在 Linux 中,它是 struct mm_struct
如果只有虚拟地址,当我们要对数据进行修改时,如何找到它们的物理地址呢?这就要引入另一个新概念——页表。页表中存放着进程的虚拟地址与物理地址,一一对应,建立了虚拟地址与物理地址的映射,通过页表,可以凭借虚拟地址找到真实的物理地址
所以,当一个进程创建时,操作系统会将程序代码与数据加载到内存中,并为进程创建 PCB、地址空间、页表。地址空间中存放着代码与数据的虚拟地址,通过地址空间+页表,可以找到代码与数据的真实的物理地址
写时拷贝
回看我们一开始写的程序,初始只有父进程时,创建了全局变量 g_val,并将其初始化为 100,那么 g_val 应该在初始化数据区
当父进程创建子进程时,子进程同样也会有自己的 PCB、地址空间、页表,且这些内核结构体的属性都是拷贝父进程的,除了一些较为特殊的属性,如 pid、ppid 等。对于代码与数据,则是子进程浅拷贝父进程的,即只拷贝地址。代码是只读的,所以父子进程共用一份;数据则是父子进程暂时共用
当子进程要修改 g_val 时,它可以直接修改吗?不可以,因为此时的 g_val 是父子共用的,如果修改后父进程又拿 g_val 参与运算,就会影响到父进程的运行,破坏了进程之间的独立性
因此,如果子进程要对共用的数据 g_val 进行修改,就必须拷贝一份当作自己的,然后修改自己的 g_val。然后修改自己页表的物理地址指向新的 g_val,虚拟地址则不用修改。这种拷贝就叫做写时拷贝。以上操作由操作系统完成
此时,两个 g_val 的值不同,虚拟地址相同,与我们程序的运行结果相同
写时拷贝的意义
既然写时拷贝为了维护进程间的独立性,那为什么不在一开始就让子进程把父进程的数据全部拷贝一份,而是使用写时拷贝呢?这样不会很慢吗?
如果在子进程创建时,就无脑拷贝父进程的全部数据,未免有些浪费空间了,因为并不是所有数据都会被父子进程修改。而写时拷贝按需拷贝,避免了空间的浪费
写时拷贝只会拷贝父子进程需要修改的数据,最差的情况也是全部拷贝,所以理论上写时拷贝的效率是大于等于全部拷贝。
写时拷贝通过调整拷贝的时间顺序,到达了节省空间的目的
地址空间的细节问题
如何理解地址空间
区域划分
为了更好地理解区域划分,我们可以举一个例子:
小明和小红是同桌,课桌长 100 cm,他们为了更好地区分各自的课桌区域,在课桌中间划了一条”三八线“。如果谁越过了三八线,三八线就会往谁那边移动 10 cm
一开始的时候,小明和小红各自拥有 50 cm长的课桌区。有一天,小明睡觉的时候不小心越过了三八线,于是他按照规定,将三八线向自己移动了 10 cm,他的课桌区域变为了 40 cm长,小红的课桌区域变为了 60 cm长
如果是在计算机中,我们应该如何描述上述过程呢?
对于一个区域对象,一般可以用 start 表示起始位置,用 end 表示终止位置来限定一个区域。区域结构体如下:
struct area
{
int start;
int end;
}
而小明和小红的课桌存在两个区域area,可以分为一左一右两个区域,两个区域各自都有自己的 start 和 end。课桌结构体如下:
struct desk
{
struct area left;
struct area right;
}
有了课桌结构体,我们就可以根据小明小红的区域划分情况,实例化一个课桌对象 d
struct desk d;
// 小红课桌区
d.left.start = 0;
d.left.end = 50;
// 小明课桌区
d.right.start = 50;
d.right.end = 100;
而地址空间是一个内核数据结构,它内部的很多属性都是表示 start 和 end 的范围
地址空间的理解
上面我们大致了解地址空间内部的区域划分,下面我们结合操作系统来看一下地址空间。还是结合一个例子:
在 M 国有一个大富翁,个人资产 10亿 美金,有 4 个私生子ABCD,每个私生子都不知道彼此的存在,都认为自己是大富翁的独生子。大富翁对每个私生子都画了一个大饼:大富翁的资产将由私生子继承,在那之前每个私生子也可以向大富翁申请钱,数额只要不超过 10亿 就可以。大富翁画饼画多了就需要将这些“饼”管理起来,比如写在小本本上
此时,私生子都向大富翁申请了一笔金额:A 申请了 2 万,B 申请了 10 万,C 申请了 2k,D 申请了 900。这些金额加起来对于大富翁的资产来说微不足道,所以大富翁一般都会同意
然后再回到我们的地址空间。大富翁对应操作系统,10个亿对应物理内存,私生子对应进程,大饼对应进程地址空间
也就是说,OS 在创建进程时,为每个进程画一个“大饼”,也就是地址空间。OS 告诉操作系统,地址空间内的内存进程可以随便用
为什么要有地址空间
-
乱序到有序
如果没有地址空间和页表,那么进程就会直接与物理内存进行交互,代码和数据不一定是在内存中连续有序地存放着,所以进程需要严格记录各个数据的地址,防止越界操作,对其他进程造成影响。并且内存中还会有其他进程的代码和数据,各种不同数据交替存放,在进程看来这是很乱的,维护压力很大
而有了进程地址空间+页表的存在,虽然不会改变代码数据在物理内存中的乱序,但是进程是与地址空间进行交互的,进程看到的就是有序的
所以说,地址空间可以使进程以统一的视角看待物理内存以及自己运行的各个区域
-
进程管理模块与内存管理模块解耦
当一个进程申请空间时,不一定是立刻使用,可能是进程开始时申请的,到进程快结束才使用。如果一开始就给这个进程分配空间,那么这块空间就闲置了一段时间。
有了地址空间,可以在进程申请空间时,只在地址空间分配,而不分配物理内存。也就是在地址空间上告诉进程已经分配好空间,实际上并没有。在页表上只有虚拟地址,没有物理地址。系统可以将未分配的空间给其他急于使用的进程,从而提高空间利用率
当进程真正要使用申请的空间时,再分配物理内存,并将物理内存的地址存入页表中
这就是将进程管理模块与内存管理模块解耦,可以提高空间的利用率
-
保护物理内存
如果进程直接与内存进行交互,万一发生越界修改数据了,不仅会对此进程造成影响,还可能会导致其他进程的崩溃。
有了地址空间,就相当于增加一层检测,直接对非法请求进行拦截,避免对内存数据造成破环
例如,进程要对红色区域的数据进行修改,红色区域是非法区,在页表上不存在地址。
在页表上检测不到地址,说明发生了越界访问,操作系统就会终止进程并报错。这也就避免了对内存数据造成破坏
进一步理解页表与写时拷贝
页表
页表中存放着虚拟地址与物理地址的映射,凭借虚拟地址可以得到物理地址。那这些工作是谁负责做的呢?
在 CPU 上存在许多寄存器,存放着页表中的虚拟地址;还有一个叫MMU的模块,MMU 得到虚拟地址就可以将其转换为物理地址
页表上不仅有虚拟地址和物理地址,还会有其他表示状态的信息,例如“是否在物理内存中”,“rwx权限”。
“是否在物理内存中”
就是表明代码和数据在不在内存中。我们之前在进程的状态和优先级中说到的挂起,就可以用页表来表示,“挂起”态将代码与数据唤出到磁盘中,就会在页表上“是否在物理内存中”标记为“否”
“rwx”权限
表明进程对代码与数据的权限。一般代码都是只读的;而数据的权限有只读,也有读写。如果进程对只读的的数据进行修改,会导致进程被终止并报错。例如下面的操作:
char* str = "Hello World";
*str = "123";
str 是常量字符串,放在字符常量区,在页表中的 rwx 权限对应着“r”,不可修改
写时拷贝
在父进程创建子进程时,子进程会复制父进程的页表,同时父子进程对于数据的操作权限都变为“r”。父进程或者子进程对数据进行修改时,操作系统就会识别到错误,这时会有如下情况:
- 是不是数据不在内存中。如果是,就会发生缺页中断
- 是不是需要写时拷贝。如果是,发生写时拷贝
- 以上两种情况都不是,那就进行异常处理
如何理解虚拟地址
这里只是简略提一下,因为我只知道这么多(流汗
页表中存放着代码与数据的虚拟地址和物理地址,这些地址是哪里来的?
当进程的代码与数据被加载到内存中后,就会得到物理地址。而虚拟地址是从程序中得来的
我们平时在在写代码时也会调试,一个变量,一个函数调用,我们都可以看到它们的地址,这些是逻辑地址,也就是页表中的虚拟地址
Linux2.6内核进程调度队列
我们在进程的状态中了解了 Linux 中的进程调度机制:基于时间片的轮转来调度。这只是简单的认识,下面我们来详细看看进程是如何调度的
我们知道,一个 CPU 维护着一个运行队列 runqueue,每个运行队列都会有如下属性:
先看蓝色部分的属性,其实是一个结构
struct q // 结构体名字随便起的
{
int nr_active;
long bitmap[5];
task_struct* queue[140];
}
其中 queue 是个数组task_struct* queue[140]
,存放着 140 个进程 PCB 的指针,我们这里不关心 [0, 99] ,只会用到 [100, 139] 的元素,一共有 40 个元素
这里的 40 个元素对应着进程优先级的 40 个数字,60~99
进程会根据优先级被链入到队列中,优先级相同的就链入同一下标
这样 queue[140] 队列就维护了 40 个优先级不同的进程队列。我们可以通过遍历的方式找到特定优先级的进程
但是如果,我们的进程优先级都是 80,而前面的队列都是空的,这样遍历找岂不是效率太低了
这时就要用到 long bitmap[5]
了。这里我们用到的是比特位,一个 long
整形是 4 个字节,一个字节是 8 比特位,这个数组之所以有 5 个数,就是因为 32* 4 = 128,32 * 5 = 160,而 128 < 140 < 160
这里用的是 140 个比特位来表示queue数组中的元素是否存在。比如第 100 个比特位为”1“,就说明 queue[100] 有进程存在;为”0“没有进程
这样遍历查找进程时就很方便了,如果 bitmap[0] = 0,说明前 32 个位置都没有队列,那么直接遍历下一组。这样以 32 个元素为一组的遍历效率很高,找到合适优先级的进程花费的时间几乎为O(1)
还有一个nr_active
用来表示 queue[] 队列中有多少正在运行的进程
这就是 O(1) 调度算法
活动队列与过期队列
我们上面所说的结构在 运行队列runqueue 中其实是存在两个的,一个叫活动队列,一个叫过期队列,它们构成了一个数组 struct q arr[2]
- 活动队列上的进程只出不进,进程的时间片耗尽就会出队列
- 过期队列上的进程只进不出,时间片耗尽的进程和新启动的进程就会入队列
而 runqueue 中还有两个属性 active 和 expired,其中 active 永远指向活动队列,expired 永远指向过期队列。CPU 调度进程只会到 active 指向的队列取进程,这样活动队列中的进程越来越少,过期队列进程越来越多
当活动队列为空时,只需交换 active 和 expired 两个指针的内容,这样就又有了一批新的活动进程,开始新一轮的轮转
以上就是 Linux 的 O(1) 进程调度算法