提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
目录
文章目录
前言
一、环境变量的补充
二、进程空间的地址
2.1、程序地址空间
2.2、研究背景
2.3、程序地址空间
来段代码感受一下
2.4、进程地址空间
2.5、如何理解地址空间
a、什么是区域划分
b、地址空间的理解
2.6、为什么要有地址空间
2.7、进一步理解页表和写实拷贝
2.8、如何理解虚拟地址
三、Linux2.6内核进程调度队列
3.1、一个CPU拥有一个runqueue
3.2、优先级
3.3、活动队列
3.4、过期队列
3.5、active指针和expired指针
总结
前言
世上有两种耀眼的光芒,一种是正在升起的太阳,一种是正在努力学习编程的你!一个爱学编程的人。各位看官,我衷心的希望这篇博客能对你们有所帮助,同时也希望各位看官能对我的文章给与点评,希望我们能够携手共同促进进步,在编程的道路上越走越远!
提示:以下是本篇文章正文内容,下面案例可供参考
一、环境变量的补充
我们不带 export 向命令行中输入 HELLO=33333333 这条指令。
echo $HELLO
// 能够打印出来HELLO变量的内容
env | grep HELLO
// 查找HELLO变量时,是查不到的
这个变量查不到,并不代表它真的没有,这个变量的内容既然能够被打印出来,说明它还是在我们的bash进程内部存在的,只不过并没有把它当作环境变量来看待,不在环境变量表里。
用export导入环境变量有两种做法:
- 第一种:export HELLO=33333333 变量名后面直接跟内容
- 第二种:HELLO这个变量已经存在了,也在bash对应的内存当中,只不过HELLO没有被视作环境变量(没有被添加到环境列表里),所以,我们可以直接 export HELLO 将变量导入环境变量表里。
总结:
没有被 export 修饰的变量,我们叫做本地变量。本地变量只在本bash内部有效,无法被子进程继承下去。导成环境变量,才能够获取。要看到本地变量,只能通过内建命令(export、echo)。
二、进程空间的地址
2.1、程序地址空间
- 程序段(Text):程序代码在内存中的映射,存放函数体的二进制代码。
- 初始化过的数据(Data):在程序运行初已经对变量进行初始化的数据。
- 未初始化过的数据(BSS):在程序运行初未对变量进行初始化的数据。
- 栈 (Stack):存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。
- 堆 (Heap):存储动态内存分配,需要程序员手工分配,手工释放.注意它与数据结构中的堆是两回事,分配方式类似于链表。
2.2、研究背景
- kernel 2.6.32
- 32位平台
2.3、程序地址空间
可是我们对于它并不了解。
来段代码感受一下
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;int main()
{pid_t id = fork();if (id < 0) {perror("fork");return 0;}else if (id == 0) {//childprintf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else {//parentprintf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
输出:
//与环境相关,观察现象即可
parent[2995]: 0 : 0x80497d8
child[2996] : 0 : 0x80497d8
我们发现,输出出来的全局变量值和全局变量的地址是一模一样的,很好理解呀,因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改。可是将代码稍加改动:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;int main()
{pid_t id = fork();if (id < 0) {perror("fork");return 0;}else if (id == 0) { //child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取g_val = 100;printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else { //parentsleep(3);printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
输出:
//与环境相关,观察现象即可
child[3046]: 100 : 0x80497e8
parent[3045] : 0 : 0x80497e8
我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
- 但地址值是一样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做虚拟地址。
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。
OS必须负责将 虚拟地址 转化成 物理地址 。
分析图中,使用系统调用接口创建新的进程时,fork后的数据代码,父子进程将会同时执行,同时增加新的进程控制块,父子进程通过刚开始相同的页表指向相同的物理空间,其所使用的进程地址空间对应的位置也是相同的,父子进程指向同一个g_val,因此,父进程和子进程对应的g_val的地址是相同的,但是,当子进程尝试修改g_val变量时,为保证进程的独立性,操作系统识别到当前子进程通过页表找到g_val,想修改g_val,此时,操作系统会重新开辟一段空间,将上述值拷贝下来,修改映射关系,因此使用不同的物理内存地址,互不影响,互相独立。
2.4、进程地址空间
所以之前说‘程序的地址空间’是不准确的,准确的应该说成 进程地址空间 ,那该如何理解呢?看图:
说明:
- 上面的图就足矣说名问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!
- 进程地址空间在内核里本质是一个(struct mm_struct)结构体对象。
如果创建了一个全局变量,但是父子进程不写这个全局变量?未来一个全局变量,默认是被父子进程共享的,代码(只读的)是共享的。对应的父子进程之间的数据不会分离,它们在底层都是指向同一块物理内存,只有在写入数据时,我们对应的操作系统才能够帮我们进行写实拷贝。
不管是父进程,还是子进程,都要有自己独立的 task_struct(进程控制块)、进程地址空间和页表。为什么呢?
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。
那么可不可以把数据在创建子进程的时候,全部给子进程拷贝一份?
不可以。因为子进程是能够看到并访问父进程的数据的,大部分情况下,是不需要进行全部拷贝过来,那样太浪费空间了;我们通常是要进行写入的时候,OS才会要写入的变量复制一份,重开一个大小一样的空间,在新开的空间内写入数据,再将新空间的地址交给页表。这是按需申请。通过调整拷贝的时间顺序,达到节省空间的目的。
2.5、如何理解地址空间
a、什么是区域划分
联想你上学期间与同桌的三八线。
用计算机语言来进行描述:
要管理地址空间,要先对地址空间进行描述。
地址空间本质是内核的一个struct结构体!内部很多的属性都是表示start,end的范围。
b、地址空间的理解
假设有一个富豪,他有 10 亿美元的家产,而这个富豪他有 4 个私生子,但这 4 个私生子彼此之间并不知道对方的存在,这个富豪对他的每个私生子都说过同一句话:儿子,这10亿的家产未来都是你的。
站在每个私生子的视角中,每个私生子都认为自己拥有 10 亿美元。
如果每个私生子都找父亲一次性要 10 个亿,这个富豪是拿不出来的,但实际上这是不可能的,每个私生子找父亲要钱,一般只会几千几万这样一点点去要,这个富豪只要有,就一定会给。如果私生子要的钱太多,富豪不给,私生子也只会认为是父亲不想给我。
换言之,这个富豪给每个私生子在大脑中建立一个「虚拟」的概念:都认为自己拥有 10 亿美元。
类比到计算机中:
- 操作系统 ------> 富豪
- 进程 -------> 私生子
- 10亿家产 ------> 物理内存(不可能随身携带10亿现金)
- 进程地址空间 -------> 富豪给私生子画的大饼 (10 亿家产)
2.6、为什么要有地址空间
第一个原因:
将无序变成有序,让进程以统一的视角看待物理内存以及自己运行的各个区域!
- 因为在物理内存中理论上可以任意位置加载,那么物理内存中几乎所有的数据和代码在内存中是乱序的!无疑会增加cpu访问时间,但是,因为页表的存在,它可以将地址空间的虚拟地址和物理地址进行映射,那么是不是在进程视角所有的内存分布,都可以是有序的。
- 地址空间 + 页表 的存在,可以将物理内存空间的分布有序化。
- 地址空间是OS给进程画得大饼,结合上述,进程要访问的物理内存中的数据代码,并没有在物理内存中,同样的,也可以让不同的进程映射到不同的物理内存,便很容易做到进程独立性的实现!!
- 进程的独立性可以通过地址空间 + 页表的方式实现。
第二个原因:
进程管理模块和内存管理模块进行解耦。
- 如果没有虚拟地址空间,那么进程直接使用的就是物理地址,会要求进程要把进程的全部的代码和数据,在物理内存中的地址都要记录下来。对于进程来讲负担是很大的。进程直接访问物理地址的话,可能会存在访问越界,修改到其他进程的代码和数据。
- 凡是非法的访问或者映射,操作系统都会识别到,并终止你这个进程!
- 所有的进程崩溃,就是进程退出!页表还有对其映射关系维护的读写权限以及其他权限,因此是操作系统杀掉了这个进程。
- 地址空间的存在,有效拦截我们对物理内存空间访问的本质就是有效的保护了物理内存。
- 因为地址空间和页表是OS创建并维护的,也就意味着凡是想使用地址空间和页表进行映射,也一定要在OS的监管下进行访问!
- 也有效保护了物理内存中的所有合法数据,包括各个进程,以及内核相关的有效数据。
第三个原因:
拦截非法请求,对物理内存进行保护。
- 因为有地址空间及也表映射的存在,我们的物理内存中,可以对未来的数据进行在物理内存中任意位置的加载。
- 正是由于其存在,物理内存的分配 和 进程的管理,可以做到互不相干!!
- 物理内存分配在Linux内核中映射内存管理模块,而进程的管理映射进程管理模块,就完成了解耦合!
- 所以,我们在C、C++等语言上new、malloc空间的时候,本质是在虚拟地址空间内存申请的。
- 如果申请的是物理内存,不立马使用,便会产生空间的浪费!所以,本质上,(因为有了地址空间的存在,上层申请空间,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你,而当你真正对物理空间访问的时候,才执行相关的管理算法,帮你申请内存,构建也表映射关系),然后,再让你进行内存的访问!其中括号内容是由操作系统自动完成,用户,包括进程,完全不知道!(涉及技术:缺页中断)(分配内存采用了——延迟分配策略,提高整机的效率)
我在执行代码,代码有2MB,虚拟地址通过页表映射到物理地址上了,在物理内存上的代码,已经执行了1MB,等要执行另1MB的代码时,操作系统发现,我们的整体物理内存的空间不够了,我需要把那些闲置空间都释放掉;那么此时,我们已经执行了1MB的代码,操作系统也可以将这部分代码释放掉,把页表对应的地址关系去掉,或者也可以将这部分代码换出到外设当中,进程地址空间不变,所以在我看来我的代码依旧是2MB,实际在物理内存中只有1MB。
我们要在进程地址空间的堆区上申请空间,虽然要申请空间,但是这块空间你不一定马上要用,所以,页表先不建立虚拟地址和物理内存的映射关系和物理内存先不开空间,等你真正需要用到的时候,在建立映射关系和开空间。
进程在访问虚拟地址的时候,这个虚拟地址在进程地址空间是不存在的,或者你访问堆区时,越界访问到了堆区之外的区域,那么此时拿到堆区之外的虚拟地址,在页表中是查不到的,没有对应的映射关系,会发生拦截,所以,我们也不用担心会通过映射关系在物理内存写入错误的数据了。越界也会发生拦截。
总结:
因为有地址空间的存在,每个进程都认为自己拥有4GB(32位环境下)空间,并且各个区域是有序的,进而可以通过页表映射到不同的区域,来实现进程的独立!(每个进程都认为自己独占内存,并不知道其他进程的存在)
2.7、进一步理解页表和写实拷贝
页表里的选项可以支持理解挂起状态:
页表有虚拟地址映射到物理内存的关系,当物理内存中的代码和数据唤入到磁盘当中的话,页表中对应的标记位会被置为0,物理的地址也不要了,但是虚拟地址仍然存在,所以依然可以进行权限内的访问,只不过此时的代码和数据不在物理内存当中,被唤入到磁盘当中。
页表的每一个条目当中,还是有我们对应的其他的选项字段或者标记位的,有些标记位用来标识指定的物理内存是否在内存中,这些标记位里还有我们对应的某一个单元是否具有rxw权限。
为什么字符常量区不能被修改?
char* str = "hello world";
*str = 'H';
因为页表不只有虚拟地址和物理地址之间的映射关系,还有对应的标记位,有的标记位判断进程的代码和数据是否在物理内存中,而有的标记位标明对应权限的多少,这里是只有读权限的,而没有写权限。
写时拷贝
父子进程创建时使用相同的虚拟地址,而进行修改时,经操作系统识别,重新复制一份,并开辟新的空间,经过页表映射的是不同的物理地址,此时修改的是不同的物理地址的数据,其虚拟地址不受影响。
写时拷贝(Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
2.8、如何理解虚拟地址
代码经过编译之后形成可执行程序,那么可执行程序内还有变量名和函数名吗?
没有了,全部都变成了地址了。那么可执行程序没有被加载之前,单纯变成二进制的时候,程序里面本身就有地址了。
CPU里面有一个MMU内存管理单元(是一个硬件),快速的把一个指定的虚拟地址 + 页表 = 转换成物理地址。
在最开始的时候,进程地址空间和页表里面的数据从哪里来的呢?
是从可执行程序内部来的。
程序里面本身就有地址!!!这个地址就是虚拟地址(逻辑地址)。我们的可执行程序里面已经没有变量名和函数名,都变成了地址;
比如:我写了一个Add函数,Add函数的地址是 call 0x11223344,main()函数调用函数,调用的就是 call 0x11223344这个地址,当可执行程序加载到物理内存中时,这个进程的物理地址就是 call 0x11223344,一旦页表把虚拟地址和物理地址之间建立映射关系;CPU读到 call 0x11223344这条指令的时候,那么 0x11223344就是虚拟地址。
objdump _S name
// 查看一个文件的反汇编
三、Linux2.6内核进程调度队列
上图是Linux2.6内核中进程队列的数据结构
3.1、一个CPU拥有一个runqueue
如果有多个CPU就要考虑进程个数的负载均衡问题。
3.2、优先级
- 普通优先级:100~139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)
- 实时优先级:0~99(不关心)
3.3、活动队列
- 时间片还没有结束的所有进程都按照优先级放在该队列
- nr_active: 总共有多少个运行状态的进程
- queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下 标就是优先级!
- 从该结构中,选择一个最合适的进程,过程是怎么的呢?
- 从0下表开始遍历queue[140]
- 找到第一个非空队列,该队列必定为优先级最高的队列
- 拿到选中队列的第一个进程,开始运行,调度完成!
- 遍历queue[140]时间复杂度是常数!但还是太低效了!
- bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个 比特位表示队列是否为空,这样,便可以大大提高查找效率!
3.4、过期队列
- 过期队列和活动队列结构一模一样
- 过期队列上放置的进程,都是时间片耗尽的进程
- 新加入要运行的进程一般也是放在过期队列中的
- 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算
3.5、active指针和expired指针
- active指针永远指向活动队列
- expired指针永远指向过期队列
- 可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的。
- 没关系,在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了一批新的活动进程!
总结
在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法!
总结
好了,本篇博客到这里就结束了,如果有更好的观点,请及时留言,我会认真观看并学习。
不积硅步,无以至千里;不积小流,无以成江海。