linux虚拟内存和物理内存
Linux 中的每个进程都存在于“进程树”中。你可以通过运行 pstree 命令查看进程树。树的根是 init,进程号是 1。每个进程(init 除外)都有一个父进程,一个进程都可以有很多子进程。
可能上面的内容你还意犹未尽,那就再来简单的描述一下,Linux是怎样装载可执行程序的.首先,操作系统是会为一个可执行程序分配一个进程的,然后装载相应的可执行文件并执行。当有虚拟内存存在的情况下,上面的过程就要做三件事:
- 创建一个独立的虚拟地址空间。
- 读取可执行文件头,并建立可执行文件与虚拟空间的映射。
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。
首先是创建虚拟地址空间。创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构。
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。为什么要建立映射关系呢?因为,当程序执行发生缺页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。
再来看第3步,其实仔细思考第三步是会发现一些问题的,这里的可执行文件的入口地址是虚拟内存地址,那么就可能存在两个可执行程序的虚拟入口地址相同的问题,这个问题怎么解决呢?这时一个叫做“内存管理单元(Memory Management Unit)”简称“MMU”的硬件就诞生了,它的作用之一就是地址翻译,将虚拟地址翻译成物理地址,可以看下图,加深理解
简单总结一下这部分的内容,当操作系统装载一个可执行程序时,会首先创建虚拟地址空间,这个地址空间实际上就是一个数据结构;然后建立可执行文件与虚拟地址的映射,映射的作用就是知道虚拟空间对应可执行文件的哪个位置;最后就是将CPU的指令寄存器设置成可执行文件的入口地址,开始执行程序,程序开始执行的时候是会到入口地址那里找数据或程序执行,如果入口地址没有程序或数据,就会产生缺页中断,然后将虚拟地址对应可执行文件中的部分装载到物理内存中,再将这块物理内存和虚拟内存建立映射。
一个进程运行时都会得到4G的虚拟内存。这个虚拟内存你可以认为,每个进程都认为自己拥有4G的空间,这只是每个进程认为的,但是实际上,在虚拟内存对应的物理内存上,可能只对应的一点点的物理内存,实际用了多少内存,就会对应多少物理内存。
进程得到的这4G虚拟内存是一个连续的地址空间(这也只是进程认为),而实际上,它通常是被分隔成多个物理内存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换。
进程开始要访问一个地址,它可能会经历下面的过程
- 每次我要访问地址空间上的某一个地址,都需要把地址翻译为实际物理内存地址
- 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上
- 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录
- 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)
- 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常
- 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。
页表的工作原理如下图
- 我们的cpu想访问虚拟地址所在的虚拟页(VP3),根据页表,找出页表中第三条的值.判断有效位。 如果有效位为1,DRMA缓存命中,根据物理页号,找到物理页当中的内容,返回。
- 若有效位为0,参数缺页异常,调用内核缺页异常处理程序。内核通过页面置换算法选择一个页面作为被覆盖的页面,将该页的内容刷新到磁盘空间当中。然后把VP3映射的磁盘文件缓存到该物理页上面。然后页表中第三条,有效位变成1,第二部分存储上了可以对应物理内存页的地址的内容。
- 缺页异常处理完毕后,返回中断前的指令,重新执行,此时缓存命中,执行1。
- 将找到的内容映射到告诉缓存当中,CPU从告诉缓存中获取该值,结束。
再来总结一下虚拟内存是怎么工作的
当每个进程创建的时候,内核会为进程分配4G的虚拟内存,当进程还没有开始运行时,这只是一个内存布局。实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射)。这个时候数据和代码还是在磁盘上的。当运行到对应的程序时,进程去寻找页表,发现页表中地址没有存放在物理内存上,而是在磁盘上,于是发生缺页异常,于是将磁盘上的数据拷贝到物理内存中。
另外在进程运行过程中,要通过malloc来动态分配内存时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。
利用虚拟内存机制的优点
- 既然每个进程的内存空间都是一致而且固定的(32位平台下都是4G),所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际内存地址,这交给内核来完成映射关系
- 当不同的进程使用同一段代码时,比如库文件的代码,在物理内存中可以只存储一份这样的代码,不同进程只要将自己的虚拟内存映射过去就好了,这样可以节省物理内存
- 在程序需要分配连续空间的时候,只需要在虚拟内存分配连续空间,而不需要物理内存时连续的,实际上,往往物理内存都是断断续续的内存碎片。这样就可以有效地利用我们的物理内存
程序的内存布局
虚拟地址空间
内存管理是操作系统的核心;它对于编程和系统管理都是至关重要的。
多任务操作系统中的每个进程都在自己的内存沙箱中运行。这个沙箱是虚拟地址空间,在32位模式下,它总是一个4GB内存地址块。这些虚拟地址由页表映射到物理内存,页表由操作系统内核维护并由处理器查询。每个进程都有自己的一组页表。一旦虚拟地址被启用,它们将应用于机器中运行的所有软件,包括内核本身。因此,虚拟地址空间的一部分必须保留给内核:
这并不意味着内核使用了那么多物理内存,只是它有一部分地址空间可用来映射它希望映射的任何物理内存。内核空间在页表中被标记为特权代码独享,因此,如果用户模式程序试图接触它,就会触发页面错误。在Linux中,内核空间一直存在,如下图,(因为内核是一直运行的),并在所有进程中映射相同的物理内存。内核代码和数据总是可寻址的,随时可以处理中断或系统调用。相比之下,每当发生进程切换时,地址空间的用户模式部分的映射就会发生变化:
蓝色的区域代表映射到物理地址的虚拟地址空间,白色的区域是尚未映射的部分。在上面的示例中,众所周知的内存“饕餮” Firefox 使用了大量的虚拟内存空间。在地址空间中不同的条带对应了不同的内存段,像堆(heap)、栈(stack)等等。请注意,这些段只是一系列内存地址的简化表示,它与 Intel 类型的段 并没有任何关系 。不过,这是一个在 Linux 进程的标准段布局:
进程地址空间中最顶层的段是栈,大多数编程语言存储局部变量和函数参数的地方。调用方法或函数会压入新的栈帧。当函数返回时,栈帧被销毁。这种简单的设计很容易实现,因为数据遵循严格的LIFO顺序,这意味着不需要复杂的数据结构来跟踪堆栈内容——一个指向栈顶部的简单指针就可以了。因此,压入和弹出是非常快而且确定。另外,堆栈区域的不断重用往往会使cpu缓存中的堆栈内存保持活动状态,从而加快访问速度。进程中的每个线程都有自己的堆栈。
不断压入超出堆栈所能容纳的数据会耗尽映射堆栈的区域。这将触发一个由expand_stack()在Linux中处理的页面错误,该错误反过来调用acct_stack_growth()来检查是否适合扩展堆栈。如果堆栈大小低于RLIMIT_STACK(通常是8MB),那么通常堆栈会增长,程序会愉快地继续运行,而不知道刚刚发生了什么。这是堆栈大小根据需要进行调整的正常机制。但是,如果已经达到最大堆栈大小,就会出现堆栈溢出,程序会接收到段错误(Segmentation Fault)。当映射的堆栈区域扩展以满足需求时,它不会在堆栈变小时收缩回来。就像联邦预算一样,它只会扩张。
在堆栈下面,我们有内存映射段。在这里,内核直接将文件的内容映射到内存。任何应用程序都可以通过Linux mmap()系统调用请求这样的映射。内存映射是执行文件I/O的一种方便高效的方法,因此常用于加载动态库。还可以创建不对应于任何文件的匿名内存映射,用于程序数据。
堆提供运行时内存分配,就像栈一样,这意味着数据必须比执行分配的函数活得更久,这与栈不同。大多数语言都提供了堆管理。在C语言中,堆分配的接口是malloc()。如果堆中有足够的空间来满足内存请求,那么程序运行时可以在不涉及内核的情况下处理它。否则,通过brk()系统调用(实现)来扩大堆,为请求的块腾出空间。
堆管理是复杂的,需要复杂的算法来实现高效的内存使用。堆请求所需的时间可以有很大的不同。实时系统有专门的分配器来处理这个问题。堆也会变得支离破碎,如下所示:
最后,我们讨论内存的最低段:BSS、数据段和程序段(程序段有时也翻译成文本段)。BSS和数据段都为c中的静态(全局)变量存储内容。区别在于BSS存储未初始化的静态变量的内容,这些静态变量的值在源代码中没有被初始化设置。BSS内存区域是匿名的:它不映射任何文件。
另一方面,数据段保存源代码中初始化的静态变量的内容。这个内存区域不是匿名的。它映射程序二进制映像中包含源代码中给定的初始静态值的部分。因此,如果定义了 static int cntWorkerBees = 10,则cntWorkerBees的内容位于数据段中,内容为10。即使数据段映射一个文件,它也是一个私有内存映射,这意味着对内存的更新不会反映在底层文件中。必须是这样,否则分配给全局变量将改变磁盘上的二进制映像。不可思议!
内核如何管理内存
在内核中,进程是以进程描述符task_struct来表示的,task_struct中的mm字段指向内存描述符mm_struct,它是程序内存的执行摘要。它存储如上所示的内存段的开始和结束、进程所使用的物理内存页的数量(rss表示驻留内存大小)、所使用的虚拟地址空间的大小等。在内存描述符中,我们还可以找到管理程序内存的两个设计:虚拟内存区域集和页表。Gonzo的内存分配如下图所示:
每个虚拟内存区域(VMA)是一个连续的虚拟地址范围;这些区域从不重叠。vm_area_struct的一个实例完整地描述了一个内存区域,如上图所示,包括它的起始地址和结束地址、用于确定访问权限和行为的标志,以及vm_file字段,用于指定该区域映射的文件(如果有的话),不映射文件的VMA是匿名的。
程序的VMAs存储在它的内存描述符中,既作为链表中的mmap字段,也作为红黑树的根,位于mm_rb字段。红黑树允许内核快速搜索覆盖给定虚拟地址的内存区域。当您读取文件/proc/pid_of_process/maps时,内核只是遍历进程的VMAs链表并打印每个VMAs。
4GB虚拟地址空间被划分为多个页面。32位模式下的x86处理器支持4KB、2MB和4MB的页面大小。Linux和Windows都使用4KB页面映射虚拟地址空间的用户部分。0-4095字节位于第0页,4096-8191字节位于第1页,以此类推。VMA的大小必须是页面大小的倍数。下图是页面大小为4KB的3GB用户空间。
处理器利用页表将虚拟地址转换为物理内存地址。每个进程都有自己的一组页表;每当发生进程切换时,也会切换用于用户空间的页表。Linux在内存描述符的pgd字段中存储一个指向进程页表的指针。对于每个虚拟页,在页表中对应一个页表条目(page table entry, PTE),在常规的x86分页中,这是一个简单的4字节记录,如下所示:
Linux有读取和设置PTE中每个标志的函数,P位告诉处理器虚拟页面是否存在于物理内存中。如果清除(此位等于0),访问页面将触发页面错误。请记住,当这个位为0时,内核可以对其余字段做任何它想做的事情。R/W标志代表读/写;如果清除,页面是只读的。标志U/S代表用户/内核;如果清除,则页面只能由内核访问。这些标志用于实现我们前面看到的只读内存和受保护的内核空间。
位D和位A表示脏的和可访问的。脏页面表示这个页面已经发生了写操作,而可访问表示这个页面具有写操作或读操作权限。这两个标志都是粘滞的:处理器只设置它们,它们必须由内核清除。最后,PTE存储与此页面相对应的起始物理地址,对齐到4KB。这个看起来很天真的字段是一些痛苦的根源,因为它将可寻址物理内存限制为4GB。其他PTE字段用于物理地址的扩展。
虚拟内存不存储任何东西,它只是将程序的地址空间映射到底层物理内存上,处理器将这些物理内存作为一个称为物理地址空间的大块访问。虽然总线上的内存操作有点复杂,但是我们可以在这里忽略它,并假设物理地址以1字节增量的形式从0到可用内存的顶部。这个物理地址空间被内核分解成页面帧(Frame)。处理器不知道或不关心帧,但是它们对内核非常重要,因为页帧是物理内存管理的单元。Linux和Windows在32位模式下都使用4KB页帧;下面是一个2GB内存的机器示例:
让我们将虚拟内存区域、页表和页帧放在一起,以理解这一切是如何工作的。下面是一个用户堆的例子:
蓝色矩形表示VMA范围内的页面,箭头表示将页面映射到页面帧的页面表项。一些虚拟页面缺少箭头;这意味着它们对应的PTE将P位标志清除。这可能是因为这些页面从未被使用过,或者是因为它们的内容被交换了出来。无论在哪种情况下,对这些页面的访问都将导致页面错误,即使它们位于VMA中。VMA和页表不一致似乎很奇怪,但这种情况经常发生
VMA就像程序和内核之间的契约。当你请求做某事(分配内存、映射文件等)时,内核会说“当然”,然后它会创建或更新适当的VMA。但是它实际上不会立即执行请求,而是等到页面错误发生时才执行真正的工作,这是虚拟内存的基本原理,VMAs记录了已经分配的虚拟内存,而PTE反映的是内核对虚拟内存实际做了什么。这两种数据结构一起管理程序的内存;它们都在解决页面错误、释放内存、交换内存等等方面发挥作用。让我们以内存分配的简单情况为例:
当程序通过brk()系统调用请求更多内存时,内核只需更新堆的VMA。如上图所示,此时没有实际分配页帧,并且新的页不在物理内存中。一旦程序尝试访问页面,处理器就会发生页面错误并且调用do_page_fault()。它将使用find_vma()搜索报错的虚拟地址的VMA。如果找到,检查VMA上的权限。如果没有合适的VMA,进程将发生段错误。
Linux启动一个进程的过程
Linux 中的每个进程都存在于“进程树”中。你可以通过运行 pstree 命令查看进程树。树的根是 init,进程号是 1。每个进程(init 除外)都有一个父进程,一个进程都可以有很多子进程。
所以,假设我要启动一个名为 ls 的进程来列出一个目录。我是不是只要发起一个进程 ls 就好了呢?不是的。
我要做的是,创建一个子进程,这个子进程是我(me)本身的一个克隆,然后这个子进程的“脑子”被吃掉了,变成 ls。
# 开始是这样的:
my parent
|- me
# 然后运行 fork(),生成一个子进程,是我(me)自己的一份克隆:
my parent
|- me
|-- clone of me
# 然后我让该子进程运行 exec("ls"),变成这样:
my parent
|- me
|-- ls
当 ls 命令结束后,我几乎又变回了我自己:
my parent
|- me
|-- ls (zombie)
# 在这时 ls 其实是一个僵尸进程。这意味着它已经死了,但它还在等我,以防我需要检查它的返回值(使用 wait 系统调用)。一旦我获得了它的返回值,我将再次恢复独自一人的状态。
my parent
|- me
上文提到的“脑子被吃掉”是什么意思呢?
进程有很多属性:
打开的文件(包括打开的网络连接)
环境变量
信号处理程序(在程序上运行 Ctrl + C 时会发生什么?)
内存(你的“地址空间”)
寄存器
可执行文件(/proc/$pid/exe)
cgroups 和命名空间(与 Linux 容器相关)
当前的工作目录
运行程序的用户
其他我还没想到的
当你运行 execve 并让另一个程序吃掉你的脑子的时候,实际上几乎所有东西都是相同的! 你们有相同的环境变量、信号处理程序和打开的文件等等。
唯一改变的是,内存、寄存器以及正在运行的程序,这可是件大事。
为何 fork 并非那么耗费资源(写入时复制)
你可能会问:“如果我有一个使用了 2GB 内存的进程,这是否意味着每次我启动一个子进程,所有 2 GB 的内存都要被复制一次?这听起来要耗费很多资源!”
事实上,Linux 为 fork() 调用实现了写时复制copy on write,对于新进程的 2GB 内存来说,就像是“看看旧的进程就好了,是一样的!”。然后,当如果任一进程试图写入内存,此时系统才真正地复制一个内存的副本给该进程。如果两个进程的内存是相同的,就不需要复制了
子进程的终结(termination)
当子进程终结时,它会通知父进程,并清空自己所占据的内存,并在内核里留下自己的退出信息(exit code,如果顺利运行,为0;如果有错误或异常状况,为>0的整数)。在这个信息里,会解释该进程为什么退出。父进程在得知子进程终结时,有责任对该子进程使用wait系统调用。这个wait函数能从内核中取出子进程的退出信息,并清空该信息在内核中所占据的空间。但是,如果父进程早于子进程终结,子进程就会成为一个孤儿(orphand)进程。孤儿进程会被过继给init进程,init进程也就成了该进程的父进程。init进程负责该子进程终结时调用wait函数。
当然,一个糟糕的程序也完全可能造成子进程的退出信息滞留在内核中的状况(父进程不对子进程调用wait函数),这样的情况下,子进程成为僵尸(zombie)进程。当大量僵尸进程积累时,内存空间会被挤占。
页面缓存、内存和文件之间的那些事
在面对文件时,有两个很重要的问题需要操作系统去解决。第一个是相对内存而言,慢的让人发狂的硬盘驱动器,尤其是磁盘寻道。第二个是需要将文件内容一次性地加载到物理内存中,以便程序间共享文件内容。如果你在 Windows 中使用 进程浏览器 去查看它的进程,你将会看到每个进程中加载了大约 ~15MB 的公共 DLL。我的 Windows 机器上现在大约运行着 100 个进程,因此,如果不共享的话,仅这些公共的 DLL 就要使用高达 ~1.5 GB 的物理内存。如果是那样的话,那就太糟糕了。同样的,几乎所有的 Linux 进程都需要 ld.so 和 libc,加上其它的公共库,它们占用的内存数量也不是一个小数目。
幸运的是,这两个问题都用一个办法解决了:页面缓存 --- 保存在内存中的页面大小的文件块。为了用图去说明页面缓存,我捏造出一个名为 render 的 Linux 程序,它打开了文件 scene.dat,并且一次读取 512 字节,并将文件内容存储到一个分配到堆中的块上。第一次读取的过程如下:
- render 请求 scene.dat 从位移 0 开始的 512 字节。
- 内核搜寻页面缓存中 scene.dat 的 4kb 块,以满足该请求。假设该数据没有缓存。
- 内核分配页面帧,初始化 I/O 请求,将 scend.dat 从位移 0 开始的 4kb 复制到分配的页面帧。
- 内核从页面缓存复制请求的 512 字节到用户缓冲区,系统调用 read() 结束。
读取完 12KB 的文件内容以后,render 程序的堆和相关的页面帧如下图所示:
它看起来很简单,其实这一过程做了很多的事情。首先,虽然这个程序使用了普通的读取(read)调用,但是,已经有三个 4KB 的页面帧将文件 scene.dat 的一部分内容保存在了页面缓存中。虽然有时让人觉得很惊奇,但是,普通的文件 I/O 就是这样通过页面缓存来进行的。在 x86 架构的 Linux 中,内核将文件认为是一系列的 4KB 大小的块。如果你从文件中读取单个字节,包含这个字节的整个 4KB 块将被从磁盘中读入到页面缓存中。这是可以理解的,因为磁盘通常是持续吞吐的,并且程序一般也不会从磁盘区域仅仅读取几个字节。页面缓存知道文件中的每个 4KB 块的位置,在上图中用 #0、#1 等等来描述。Windows 使用 256KB 大小的 视图(view),类似于 Linux 的页面缓存中的 页面(page)。
不幸的是,在一个普通的文件读取中,内核必须拷贝页面缓存中的内容到用户缓冲区中,它不仅花费 CPU 时间和影响 CPU 缓存,在复制数据时也浪费物理内存。如前面的图示,scene.dat 的内存被存储了两次,并且,程序中的每个实例额外多存了一次。我们虽然解决了从磁盘中读取文件缓慢的问题,但是在其它的方面带来了更痛苦的问题。内存映射文件是解决这种痛苦的一个方法:
当你使用文件映射时,内核直接在页面缓存上映射你的程序的虚拟页面。这样可以显著提升性能:Windows 系统编程报告指出,在相关的普通文件读取上运行时性能提升多达 30% ,在 Unix 环境中的高级编程的报告中,文件映射在 Linux 和 Solaris 也有类似的效果。这取决于你的应用程序类型的不同,通过使用文件映射,可以节约大量的物理内存。
对高性能的追求是永恒不变的目标,测量是很重要的事情,内存映射应该是程序员始终要使用的工具。这个 API 提供了非常好用的实现方式,它允许你在内存中按字节去访问一个文件,而不需要为了这种好处而牺牲代码可读性。在一个类 Unix 的系统中,可以使用 mmap 查看你的 地址空间,在 Windows 中,可以使用 CreateFileMapping,或者在高级编程语言中还有更多的可用封装。当你映射一个文件内容时,它并不是一次性将全部内容都映射到内存中,而是通过 页面故障来按需映射的。在 获取 需要的文件内容的页面帧后,页面故障句柄 映射你的虚拟页面到页面缓存上。如果一开始文件内容没有缓存,这还将涉及到磁盘 I/O。
现在出现一个突发的状况,假设我们的 render 程序的最后一个实例退出了。在页面缓存中保存着 scene.dat 内容的页面要立刻释放掉吗?人们通常会如此考虑,但是,那样做并不是个好主意。你应该想到,我们经常在一个程序中创建一个文件,退出程序,然后,在第二个程序去使用这个文件。页面缓存正好可以处理这种情况。如果考虑更多的情况,内核为什么要清除页面缓存的内容?请记住,磁盘读取的速度要慢于内存 5 个数量级,因此,命中一个页面缓存是一件有非常大收益的事情。因此,只要有足够大的物理内存,缓存就应该保持全满。并且,这一原则适用于所有的进程。如果你现在运行 render 一周后, scene.dat 的内容还在缓存中,那么应该恭喜你!这就是什么内核缓存越来越大,直至达到最大限制的原因。它并不是因为操作系统设计的太“垃圾”而浪费你的内存,其实这是一个非常好的行为,因为,释放物理内存才是一种“浪费”。(释放物理内存会导致页面缓存被清除,下次运行程序需要的相关数据,需要再次从磁盘上进行读取,会“浪费” CPU 和 I/O 资源)最好的做法是尽可能多的使用缓存。
由于页面缓存架构的原因,当程序调用 write()时,字节只是被简单地拷贝到页面缓存中,并将这个页面标记为“脏”页面。磁盘 I/O 通常并不会立即发生,因此,你的程序并不会被阻塞在等待磁盘写入上。副作用是,如果这时候发生了电脑死机,你的写入将不会完成,因此,对于至关重要的文件,像数据库事务日志,要求必须进行fsync((仍然还需要去担心磁盘控制器的缓存失败问题),另一方面,读取将被你的程序阻塞,直到数据可用为止。内核采取预加载的方式来缓解这个矛盾,它一般提前预读取几个页面并将它加载到页面缓存中,以备你后来的读取。在你计划进行一个顺序或者随机读取时,你可以通过 提示(hint)帮助内核去调整这个预加载行为。Linux 会对内存映射的文件进行 预读取,当然,在 Linux 中它可能会使用 O_DIRECT跳过预读取,
一个文件映射可以是私有的,也可以是共享的。当然,这只是针对内存中内容的更新而言:在一个私有的内存映射上,更新并不会提交到磁盘或者被其它进程可见,然而,共享的内存映射,则正好相反,它的任何更新都会提交到磁盘上,并且对其它的进程可见。内核使用 写时复制(copy on write)(CoW)机制,这是通过 页面表条目(page table entry)(PTE)来实现这种私有的映射。在下面的例子中,render 和另一个被称为 render3d 的程序都私有映射到 scene.dat 上。然后 render 去写入映射的文件的虚拟内存区域:
两个程序私有地映射 scene.dat,内核误导它们并将它们映射到页面缓存,但是使该页面表条目只读。render 试图写入到映射 scene.dat 的虚拟页面,处理器发生页面故障。内核分配页面帧,复制 scene.dat 的第二块内容到其中,并映射故障的页面到新的页面帧。继续执行。程序就当做什么都没发生。
上面展示的只读页面表条目并不意味着映射是只读的,它只是内核的一个用于共享物理内存的技巧,直到尽可能的最后一刻之前。你可以认为“私有”一词用的有点不太恰当,你只需要记住,这个“私有”仅用于更新的情况。这种设计的重要性在于,要想看到被映射的文件的变化,其它程序只能读取它的虚拟页面。一旦“写时复制”发生,从其它地方是看不到这种变化的。但是,内核并不能保证这种行为,因为它是在 x86 中实现的,从 API 的角度来看,这是有意义的。相比之下,一个共享的映射只是将它简单地映射到页面缓存上。更新会被所有的进程看到并被写入到磁盘上。最终,如果上面的映射是只读的,页面故障将触发一个内存段失败而不是写到一个副本。
动态加载库是通过文件映射融入到你的程序的地址空间中的。这没有什么可奇怪的,它通过普通的 API 为你提供与私有文件映射相同的效果。下面的示例展示了映射文件的 render 程序的两个实例运行的地址空间的一部分,以及物理内存,尝试将我们看到的许多概念综合到一起。
什么是缓存 I/O (Buffered I/O)
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。缓存 I/O 有以下这些优点:
- 缓存 I/O 使用了操作系统内核缓冲区,在一定程度上分离了应用程序空间和实际的物理设备。
- 缓存 I/O 可以减少读盘的次数,从而提高性能。
当应用程序尝试读取某块数据的时候,如果这块数据已经存放在了页缓存中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。当然,如果数据在应用程序读取之前并未被存放在页缓存中,那么就需要先将数据从磁盘读到页缓存中去。对于写操作来说,应用程序也会将数据先写到页缓存中去,数据是否被立即写到磁盘上去取决于应用程序所采用的写操作机制:如果用户采用的是同步写机制( synchronous writes ), 那么数据会立即被写回到磁盘上,应用程序会一直等到数据被写完为止;如果用户采用的是延迟写机制( deferred writes ),那么应用程序就完全不需要等到数据全部被写回到磁盘,数据只要被写到页缓存中去就可以了。在延迟写机制的情况下,操作系统会定期地将放在页缓存中的数据刷到磁盘上。与异步写机制( asynchronous writes )不同的是,延迟写机制在数据完全写到磁盘上的时候不会通知应用程序,而异步写机制在数据完全写到磁盘上的时候是会返回给应用程序的。所以延迟写机制本身是存在数据丢失的风险的,而异步写机制则不会有这方面的担心。
缓存 I/O 的缺点
在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,这样的话,数据在传输过程中需要在应用程序地址空间和页缓存之间进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
对于某些特殊的应用程序来说,避开操作系统内核缓冲区而直接在应用程序地址空间和磁盘之间传输数据会比使用操作系统内核缓冲区获取更好的性能.
内核如何阻塞与唤醒进程?
进程和线程
我们先从 Linux 的进程谈起,操作系统要运行一个可执行程序,首先要将程序文件加载到内存,然后 CPU 去读取和执行程序指令,而一个进程就是“一次程序的运行过程”,内核会给每一个进程创建一个名为task_struct的数据结构,而内核也是一段程序,系统启动时就被加载到内存中了。
进程在运行过程中要访问内存,而物理内存是有限的,比如 16GB,那怎么把有限的内存分给不同的进程使用呢?跟 CPU 的分时共享一样,内存也是共享的,Linux 给每个进程虚拟出一块很大的地址空间,比如 32 位机器上进程的虚拟内存地址空间是 4GB,从 0x00000000 到 0xFFFFFFFF。但这 4GB 并不是真实的物理内存,而是进程访问到了某个虚拟地址,如果这个地址还没有对应的物理内存页,就会产生缺页中断,分配物理内存,MMU(内存管理单元)会将虚拟地址与物理内存页的映射关系保存在页表中,再次访问这个虚拟地址,就能找到相应的物理内存页。每个进程的这 4GB 虚拟地址空间分布如下图所示:
进程的虚拟地址空间总体分为用户空间和内核空间,低地址上的 3GB 属于用户空间,高地址的 1GB 是内核空间,这是基于安全上的考虑,用户程序只能访问用户空间,内核程序可以访问整个进程空间,并且只有内核可以直接访问各种硬件资源,比如磁盘和网卡。那用户程序需要访问这些硬件资源该怎么办呢?答案是通过系统调用,系统调用可以理解为内核实现的函数,比如应用程序要通过网卡接收数据,会调用 Socket 的 read 函数:
ssize_t read(int fd,void *buf,size_t nbyte)
CPU 在执行系统调用的过程中会从用户态切换到内核态,CPU 在用户态下执行用户程序,使用的是用户空间的栈,访问用户空间的内存;当 CPU 切换到内核态后,执行内核代码,使用的是内核空间上的栈。
从上面这张图我们看到,用户空间从低到高依次是代码区、数据区、堆、共享库与 mmap 内存映射区、栈、环境变量。其中堆向高地址增长,栈向低地址增长。
请注意用户空间上还有一个共享库和 mmap 映射区,Linux 提供了内存映射函数 mmap, 它可将文件内容映射到这个内存区域,用户通过读写这段内存,从而实现对文件的读取和修改,无需通过 read/write 系统调用来读写文件,省去了用户空间和内核空间之间的数据拷贝,Java 的 MappedByteBuffer 就是通过它来实现的;用户程序用到的系统共享库也是通过 mmap 映射到了这个区域。
我在开始提到的task_struct结构体本身是分配在内核空间,它的vm_struct成员变量保存了各内存区域的起始和终止地址,此外task_struct中还保存了进程的其他信息,比如进程号、打开的文件、创建的 Socket 以及 CPU 运行上下文等。
在 Linux 中,线程是一个轻量级的进程,轻量级说的是线程只是一个 CPU 调度单元,因此线程有自己的task_struct结构体和运行栈区,但是线程的其他资源都是跟父进程共用的,比如虚拟地址空间、打开的文件和 Socket 等。
阻塞与唤醒
我们知道当用户线程发起一个阻塞式的 read 调用,数据未就绪时,线程就会阻塞,那阻塞具体是如何实现的呢?
Linux 内核将线程当作一个进程进行 CPU 调度,内核维护了一个可运行的进程队列,所有处于TASK_RUNNING状态的进程都会被放入运行队列中,本质是用双向链表将task_struct链接起来,排队使用 CPU 时间片,时间片用完重新调度 CPU。所谓调度就是在可运行进程列表中选择一个进程,再从 CPU 列表中选择一个可用的 CPU,将进程的上下文恢复到这个 CPU 的寄存器中,然后执行进程上下文指定的下一条指令。
而阻塞的本质就是将进程的task_struct移出运行队列,添加到等待队列,并且将进程的状态的置为TASK_UNINTERRUPTIBLE或者TASK_INTERRUPTIBLE,重新触发一次 CPU 调度让出 CPU。
那线程怎么唤醒呢?线程在加入到等待队列的同时向内核注册了一个回调函数,告诉内核我在等待这个 Socket 上的数据,如果数据到了就唤醒我。这样当网卡接收到数据时,产生硬件中断,内核再通过调用回调函数唤醒进程。唤醒的过程就是将进程的task_struct从等待队列移到运行队列,并且将task_struct的状态置为TASK_RUNNING,这样进程就有机会重新获得 CPU 时间片。
这个过程中,内核还会将数据从内核空间拷贝到用户空间的堆上。
当 read 系统调用返回时,CPU 又从内核态切换到用户态,继续执行 read 调用的下一行代码,并且能从用户空间上的 Buffer 读到数据了。
小结
今天我们谈到了一次 Socket read 系统调用的过程:首先 CPU 在用户态执行应用程序的代码,访问进程虚拟地址空间的用户空间;read 系统调用时 CPU 从用户态切换到内核态,执行内核代码,内核检测到 Socket 上的数据未就绪时,将进程的task_struct结构体从运行队列中移到等待队列,并触发一次 CPU 调度,这时进程会让出 CPU;当网卡数据到达时,内核将数据从内核空间拷贝到用户空间的 Buffer,接着将进程的task_struct结构体重新移到运行队列,这样进程就有机会重新获得 CPU 时间片,系统调用返回,CPU 又从内核态切换到用户态,访问用户空间的数据。
FAQ
Q: 用户态切换到内核态,使用的是虚拟空间内核地址?这时用户线程会挂起执行内核线程吗?这是两个线程吗?
A: cpu处于内核态时使用的是内核地址空间,用户线程的挂起其实是由内核完成的,具体来说就是系统调用发触发软中断,cpu在内核态模式下执行软中断程序,也就是系统调用的具体实现函数(内核代码),内核代码执行过程中发现网络数据未就绪就主动让出cpu。这个时候才会将当前线程阻塞,因此从这个角度看,不是两个线程,而是一个线程在不同cpu模式下的执行过程。
Q: 进程和线程都是统一在内核空间建立task_truct,根据代码是否有系统调用在用户态和内核态来做上下文切换,然后还讲了read的系统调用过程以及进程的虚拟内存和物理内存的机制,有一点没明白,是每个进程都会有一个虚拟内核空间吗?然后进程的虚拟内核空间映射到系统管理的内核空间上?
A: 每一个进程的进程空间都包含内核空间,但是内核是各进程共享的,因此可以这里理解,内核代码运行过程中访问的内存空间被映射到各个进程空间的高地址3-4G。
Q: 用户态和用户空间是啥关系?
A: 你可以理解为CPU上有个开关,可以设置CPU的工作模式:用户态和内核态。在用户态模式下访问用户空间,也就是低地址的3GB。
Q: task_struct是指进程控制块吗?
A: 对的,进程控制块是个比较抽象的名字,具体实现就是内核的一个数据结构task_struct
理解中断
什么是中断
中断其实是一种“中断”事件,中断具体代表什么意思需要考虑它所处的上下文环境和参照对象是谁。考虑事件,我们可以简单把中断抽象为这样一种模型:
当我们分析某种中断事件时,我们需要搞清楚这四个对象:
中断源
- 中断源是谁
- 中断源在什么条件下触发中断
- 中断源如何触发
中断信号
- 信号具体指的是什么
- 信号是否需要存储
- 如何存储
中断控制器
- 中断信号的管理
- 比如说中断源发送的信号是否屏蔽,信号是否可被中断处理器重复处理,信号的处理是否有优先级...
中断处理器
- 如何获取到信号
- 拿到信号做什么样的操作
- 处理完信号后做什么样的操作
下面我们主要围绕操作系统的中断机制,Java的中断机制,如何设计一个异步线程间的中断系统这三部分简单探讨下。
操作系统的中断机制
与操作系统有关的中断,通常是指:程序在执行过程中,遇到急需处理的事件时,暂时中止CPU上现行程序的运行, 转去执行相应的事件处理程序,待处理完成 后再返回原程序被中断处或调度其他程序执行的过程。
按照中断事件本身的不同,可以划分为处理器之外的中断事件,异常,系统异常。
处理器之外的中断事件
指由外围设备发出的信号引起的,与当前运行指令无关的中断事件。示意图如下:
我们分别以上述四个对象来看:
中断源
中断源:外部设备,如打印机,键盘,鼠标等。
触发条件:如外围设备报告I/O状态的I/O中断;外围设备发出的对应信号中断,如时钟中断,键盘/鼠标对应信号的中断,关机/重启动中断等。
触发方式:由外部设备向中断控制器发出中断请求IRQ。
中断信号
也就是说中断源通知给中断控制器的是什么。
可以是通过一条信号线上产生特定的电平(利用高低电平表示是否中断两种状态),也可以在总线上发送特定消息或者消息序列,也可以是在中断寄存器中设置已发生的中断状态等。
中断控制器
CPU中的一个控制部件,包括 中断控制逻辑线路和中断寄存器。负责中断的发现和响应。
也就是说负责检查中断寄存器中的中断信号,当发现中断时让CPU切换当前进程程序,去处理中断程序。响应示意图如下:
中断处理器
指的是CPU接收到不同的中断信号该怎么处理。包括“中断处理过程”和“恢复正常操作”两部分。
1.中断处理过程
首先CPU需要将当前运行进程的上下文保存,从中断进程中分析PSW,确定对应的中断源和执行对应的中断处理程序。
PSW(Program Status Word): 是指在电脑中,一段包含被操作系统使用的程序状态信息的内存或硬件区域。一般用一个专门的寄存器来指示处理器状态。可以理解为我们上面提到的中断信号存储装置.
2.恢复正常操作
当中断程序执行完毕,接下来执行哪个进程由进程调度决定,由调度策略决定是否调度到中断执行前的进程。
较为完整的中断响应流程图如下:
异常 和 系统异常 这两类中断事件主要属于处理器执行特定的指令引起的中断事件。和上述硬件外围设备引起的中断事件的中断源不同,中断的发起,控制和处理主要是由操作系统的指令逻辑和线路来承担。是一种同步的处理操作,而外部中断是由外部设备发起,是一种异步的处理操作。下面我们简要介绍下。
异常
异常指当前运行指令引起的中断事件。包括错误情况引起的故障,如除零算数错误,缺页异常;也包括不可恢复的致命错误导致的终止,通常是一些硬件错误。
异常的处理
对于故障的处理,根据故障是否能够被恢复,故障处理程序要么重新执行引起故障的指令,要么终止。
对于终止的处理,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
系统异常
系统异常指执行陷入指令而触发系统调用引起的中断事件,如请求设备、请求I/O、创建进程等。
系统调用的处理
这种有意的异常,称为陷阱处理。处理完成后陷阱程序会将控制返回给应用程序控制流的下一条指令。
总结一下
操作系统的中断类别行为如下:
FAQ
CPU检测到中断信号时,怎么知道是发给哪个进程的?
假设1:通过中断方式把内存某区域连续的1KB数据传送到某个I/O设备上去;
一个系统的的中断系统通常是类似这样的组成,我们应该注意到,设备眼中的中断,中断控制器眼中的中断,还有CPU眼中的中断,都不是同一个概念。所以,当我们说“关中断”一类的说法的时候,对它们三者也是不一样的。
设备的中断,是设备要产生一个事件通知CPU,事件的产生的方法有很多,最简单的是在一条信号线上产生特定的电平(电平中断,比如平时都是高电平,拉低了就表示有中断了),或者产生一次电平变化(边缘触发中断),复杂的可以很复杂,比如在总线上发送特定消息或者消息序列。对设备“关中断”,指的仅仅是让这个设备不要再提供中断信号了,但如果中断控制器已经获得这个中断信号,这个中断信号还是会报到CPU上的。
中断控制器,是对多个设备的中断进行采样,排队,分发的机制。对中断控制器说:关中断,是让中断控制器不要给CPU(或者上级)发送中断信号了,设备报不报信号上来,这些信号是否被排队,那是另一个问题。
最后,是对我们软件程序员最熟悉的CPU了,CPU的中断,是CPU核上有一条中断线,当这条线加上合适的电平或者信号,CPU核就会从当前的执行上下文中,直接跳转到中断处理程序中执行。在CPU的角度上关中断,就是跟CPU说:就算现在你的中断线上有中断,也不要执行“跳转到中断处理程序”这个动作。
CPU能认识的就仅仅是中断线和中断处理程序这些概念。所谓线程,进程,软中断等概念,是软件发明出来的,CPU是不认识的。所谓线程,本质上是保存CPU运行状态的一种形式,CPU的运行状态,就是CPU的所有寄存器的内容的集合(包括用来控制中断的寄存器),线程的作用就是可以把这些寄存器都保存下来(其实还有软件本身的堆栈等其他信息,但我们这里不关心软件,先忽略),然后用另一个保存的状态刷新CPU的状态,让CPU感觉自己在运行到另一个上下文上。OS对CPU不断进行状态的切换,保存上一个状态,加载下一个线程的状态,就实现线程切换了。至于进程,本质上可以认为是线程切换的同时也会切换地址空间(切换成本大)。
所以,进程也有关中断的概念,关进程的中断,会导致本进程运行的过程中,CPU不再接受中断,但如果这个进程切换到另一个进程上,那就按新进程的上下文来说了。
在大部分的通用CPU上(请注意,这里说的是CPU这个视角),中断是可嵌套的,就算你在处理中断,只要CPU中断线上又来信号的,它就有可能再次进入中断处理程序。为了避免这种情况,我们通常需要在中断控制器上玩游戏:当中断控制器给一个CPU发了一个中断,它就不会再发下一个中断(这也有很多变种,我们只说一种常见的实现),要等CPU主动对中断控制器发起EOI操作,才会允许下一个中断信号去激发CPU。Linux系统的中断处理的名称空间中,把进入中断处理,到发出EOI这一段,称为硬中断,把EOI之后,再回到原来被中断之前的程序之前的这一段,称为软中断。但这些是软件的概念,中断对于CPU来说,只有开始,没有结束一说,每次中断的发生,都只是一次强行跳转的过程。
现在可以回到问题本身了,从设备的角度,给CPU发中断,CPU可能正在运行任何进程,无论是哪个进程,效果都是陷入到内核中(通常所有进程的内核是共享的),是内核的(其实是驱动的)的中断处理程序在处理这个中断,并在返回到用户空间前,保存中断上收到的信息,然后进入调度程序,调度当前或者下一个进程运行。剩下的事情,就是进程和驱动之间的恩怨了。
硬件中断从来不是发送给进程的,而是发送给操作系统内核,由内核统一处理,而不关心当前正在执行的是哪个进程,不管哪个进程操作都是一样的:保存现场,进入内核,执行需要的操作,返回中断前的现场,继续进程执行。
CPU写入外部设备有两种不同的方式,一种是直接操作硬件设备寄存器,这一般是不需要中断的,CPU在写入一个寄存器的时候会处于阻塞状态,直到写入完成之前不能继续,因此通常只有处理非常少的数据的时候才会这样做。另一种方式是使用DMA,DMA是一个专用的外部设备,CPU将需要发送的数据提前在内存中准备好,然后设置DMA设备的寄存器,让DMA设备从内存的指定位置开始,将内存中数据依次写到对应地址的外部硬件寄存器里,这样在DMA写入的同时CPU就可以做其他工作。DMA写入完成后会产生一个中断通知CPU。这些都和当前执行的进程无关。进程只是直接跟操作系统内核通信,内核负责通过调度来通知进程操作是否完成之类的信息。
再举个详细点的例子,比如某个进程要读取一个文件,向内核发送了一个read的syscall调用,陷入内核,内核会设置DMA,然后把进程挂起。因为进程挂起了,内核另找了一个进程切换进来执行。当DMA完成发生中断的时候,不管当前执行的是哪个进程,都会直接通过中断进入内核,这个过程外部执行的进程是察觉不到的,它在执行的途中被打断然后冻结在了执行现场,就像时间停止了一样(这就是“中断”的含义),CPU开始执行内核中的中断处理程序,内核通知之前挂起的进程操作已经完成,并且取消挂起,这时候这个进程是否会立即抢占进来,取决于优先级,在Linux当中一般会把因为IO挂起的进程优先级稍微调高一点让它们立即抢占进来,提高IO效率;但如果不能抢占进来,就会恢复当前的进程的执行,等到高优先级执行结束后,再让之前挂起的进程切换进来继续执行,这对于执行IO的进程来说是不可见的。(非阻塞IO调用在底层数据没有ready的时候会立即返回,而不是挂起进程等待数据,因此调用结束之后可以马上继续往下执行,实际上只是因为它已经返回了而已。在Linux上面,非阻塞IO能处理的主要是被动接受式的数据,比如socket、pipe、fifo这些,你收不收,发送端都是会发数据到缓冲区里的(除非缓冲区满),这种对象可以选择永远只读取缓冲区里的数据,然后通过select或者epoll来获得缓冲区状态的通知,或者干脆轮询,如果通过select或epoll,那么select或者epoll会挂起,中断到来时内核恢复它的执行;如果通过轮询,那么不涉及到唤醒的过程。真正异步的接口aio*也是有的,在发起请求之后内核负责将数据直接填到用户提供的缓冲区中,然后通过信号等方式通知用户,不太常用。真正的异步io: 用户代码请求IO,内核将请求插入IO队列,然后用户线程继续运行。当设备通过中断通知cpuIO完成时,内核启动一个内核线程,将数据从内核copy到用户空间。执行copy的这个内核线程是与用户线程并发执行(被内核统一调度)(单核就是交替执行)。最后通过某种机制通知用户线程(信号?))