本篇首先抛出了为什么同一个地址空间的变量值会不一样的问题,然后就此问题对虚拟地址和写时拷贝进行了简单的介绍,接着又深入地址空间的组织形式,在这里进一步的探讨了虚拟地址,然后又探讨了页表和写时拷贝的组织形式。最后介绍了 Linux 中内核调度队列,在这真正的从底层探讨了进程在 Linux 中的调度形式。
目录如下:
目录
1. 程序地址空间
首先我们先通过现象,来看程序地址空间中的内容,如下:
如上图所示,我们先生成一个子进程,然后打印我们的全局变量的值以及其地址,同时父进程也打印,我们发现子进程和父进程的全局变量地址和值都是一样的,然后在子进程内将全局变量修改之后,子进程和父进程的全局变量的地址一样,但是值却不一致,这是为什么呢?
子进程与父进程具有独立性,在产生子进程的时候,也会产生一份独立的内核数据几个,虽然说子进程我们没有写代码,但是子进程是继承父进程的代码,且这份代码是只读的,子进程同时也继承了父进程的数据,这些数据有些是可读可写的,说明我们修改数据也是可以的,但是子进程和父进程的地址却是一样的。这也从侧面反映出,子进程的地址和父进程的地址绝对不会是物理地址,因为同一个地址不可能有两个值,这和我们的虚拟地址有关,
1.1 虚拟地址空间
我们在前文中提到 task_struct 和内存,磁盘之间的关系,其实在 task_struct 、内存之间还存在两个东西,其分别为虚拟地址空间和页表,如下图:
如上图所示,task_struct 并不是和物理内存中的代码数据直接相联系的,而是先通过地址空间,地址空间中维护着虚拟地址,然后在地址空间和物理内存之间存在一个页表,用来将虚拟地址和物理内存地址像转换,当我们访问数据和代码的时候,先通过访问虚拟地址,然后通过页表中虚拟地址和物理内存中的映射关系,从而访问到物理内存中的数据和代码。这样的好处在于,我们在地址空间建立的虚拟地址可以建立连续的地址,假若在物理内存中建立地址,由于开辟的进程较多,那么很可能产生的地址会零零碎碎,访问代码和数据的时候,效率就会十分低下。
1.2 写时拷贝
上文中已经介绍了关于虚拟地址空间,那么当我们创建子进程的时候,也会独立创建一份 task_struct ,同时也会创建虚拟地址空间,也会创建一份页表。其中关于新创建的 task_struct ,子进程中的内容大多都是直接拷贝父进程中的 task_struct 的内容的,但也会有不同,比如 pid 与 ppid,关于地址空间和页表也是,基本都是对父进程的内容进行浅拷贝。如下:
如上图所示,子进程的所有结构基本都是拷贝父进程的,我们访问数据的时候,同样访问的是同一块物理内存的数据,访问数据的时候,可以访问同一块物理内存中的地址。
但是当子进程或者是父进程之一想要进行写入的时候,写入的时候就需要对数据进行修改,而父子进行之间是相互独立的,修改其中之一不能影响到另一个,所以在写入的时候,系统会在物理内存数据区中开辟一块新的空间,然后将当前的值拷贝过去,然后在对这块区域内的内容进行修改,接着将页表中映射物理内存的地址给修改,因为映射虚拟地址的没有改变,所以我们在上面的程序结果中,可以发现,地址一样,内容却不一样。
那么关于写时拷贝发生在什么时候呢?写时拷贝按照按需申请的原则,只有当我们需要对某些数据进行修改的时候,我们才需要将内存中的数据,进行写时拷贝,通过调整拷贝的时间顺序,达到有效节省空间的目的。
1.3 地址空间的组织形式
我们在上文中已经直接了当的给出了地址空间的形式,其中包括栈区、共享区、堆区、未初始化区、代码区等划分区域。但是其实对于这样的一些划分,其实都已给出一块连续的地址形式(因为这个地址空间本就是虚拟的),所以关于这个地址空间的组织形式,仍然是由一个内核数据结构所组织起来的:mm_struct,这个内核数据结构中的变脸就可以用来表征这些区域,如下:
如上,在内核数据结构 task_struct 中就维护着一个 mm_struct 的指针。
地址空间存在的意义?
假若没有地址空间这样的一个结构体,那么 task_struct 就需要直接的记录下代码和数据在物理内存中的位置,当然这样直接记录下来也是可以的,但是一旦物理内存空间十分紧张,物理内存中加载的进程十分的多,那么在每个时刻杀掉的物理内存和需要重新加载的物理内存就会非常的多,会出现非常的内存碎块,所以加载到物理内存空间的数据很可能是很多碎块,而地址空间就可以将无序变成有序,让进程以统一的视角看待物理内存以及自己运行的各个区域,这样访问数据的时候,就可以线性迅速的访问数据和代码。
当我们像堆空间申请一块地址,但我们还不会立即使用的情况,地址空间会表面上将这块的空间开辟出来,在页表的地址空间映射先给出,但是并不会直接的给出物理内存空间的映射,这是因为还并未在物理内存空间开辟出空间,只有当真正需要使用这块空间的时候,才会开辟,这样只有使用时候才开辟空间的方式可以提高内存的使用率,达到进程管理模块和内存管理模块进行解耦的作用。
当我们在申请的堆上空间越界访问的时候,需要到页表上去映射找到对应的物理内存数据的时候,在页表上的并未查找到存在这样的堆空间地址,说明进行了非法访问,这时候系统就可以检测出这种非法请求并拦截,从而达到对物理内存的保护的目的。
1.4 页表和写时拷贝的组织形式
关于页表的组织形式并不是简单的一边为虚拟地址,一边为物理地址的映射。在CPU中存在一个寄存器将页表给存储起来,还存在一个寄存器 MMU (memory manage unit) 可以迅速的将虚拟地址和物理地址转化。在物理内存地址的一侧还存在着两个标志位:需要的内存是否在物理内存中,rwx 权限标志位。需要的内存是否在物理内存中的标志位:可以帮助检测目前这个进程是否处于挂起状态(进程的内核数据结构在物理内存中,而代码和数据在磁盘的 swap 分区中),等到需要运行该进程的时候,在将内存从磁盘中换入。rwx 权限位:检测当前这个物理内存处于什么权限状态,是可读还是可写还是可执行,我们在 C/C++ 程序中创建的常量字符串就只有读权限,当我们想要对其进行修改的时候,操作系统就会检测出错误,从而报错。
写时拷贝的组织形式:当我们创建子进程的时候,对于从父进程中继承下来的数据,会进行引用计数统计,当我们数据的引用计数大于1的时候,说明该份数据不能随意的被修改,当前数据在页表中的状态为只读的状态,当我们想要修改的时候,会重新开辟一个空间,将原数据拷贝过去,然后在写入,这时将原来引用计数减一。
对于写时拷贝不仅仅如此,当一份数据的引用计数从1变成2的时候,页表会将该段数据的权限设置为 r(只读)权限,当我们想要去写的时候,会由OS去检测这份数据目前处于什么状态,若:当前数据不在物理内存中,会发生缺页中断,然后会在磁盘中将数据读取出到物理内存中,然后在进行操作。检测数据是不是需要进行写时拷贝。如果不是以上两种场景,那么将会进行异常处理。
1.5 虚拟地址从哪来
在地址空间和页表中,都记录着进程中各种变量的虚拟地址,那么我们的虚拟地址到底是从哪里来的呢?虚拟地址其实就是记录在进程中的。
如下将一个程序进行反汇编:
如上图所示,我们将一个进程进行反汇编,其中的很多二进制表现形式就是地址,这些地址是不变的,每一次加载到地址空间的时候,都是一样的这些地址,但是物理内存地址则每次都可能不一样,虚拟地址则是已经被记录到进程中,当从进程中开始读入数据的时候,每一个数据的虚拟地址都已经固定。
2. Linux 内核调度队列
现在我们来通过学习 Linux 中的调度队列,来查看 Linux 是如何真正的对进行进行调度的。Linux 中的运行队列的数据结构如下:
如上所示,运行队列中其中关于调度进程最重要的就是如上的活跃进程和过期进程中的数据结构,我们发现,这两个数据结构的类型一模一样。我们先观察 *queue[140] 里面的值,这里面一共有140个指针,其中有关进程调度的为100~139,刚好是40个,这和进程的优先级刚好相对应(进程的优先级也是只有40,从60~99),我们要运行进程的时候,按照进程的优先级将其链入到对应的 queue 中,然后运行的时候,也是按照从 100 ~ 139 的位置逐一遍历运行。
对于活跃进程来说,只出不进,对于过期进程来说,只进不出。当活跃进程中的某个进程的时间片结束的时候,就会将其放入到过期进程中的 queue 队列中(仍然按照优先级进行链入),直到活跃进程遍历结束的时候,调用 swap 函数交换以上的 active 和 expired 的值,交换了过期进程和活跃进程的指针,就可以重新进行运行了,然后又可以重新运行了。
关于 bitmap 变量,bitmap 中一共有 5 * 32 = 160,我们只需要使用其中的140个 bit 位,用这140个 bit 位表征 queue 队列中的 140 元素,bit 位 为0表示queue中该位置没有元素,为1表示该位置有元素(这样的方法,可以快速的帮助我们在queue中到元素)。这也叫位图。