现代操作系统 第十章 UNIX、Linux 和 Android 上

现代操作系统 第十章 UNIX、Linux 和 Android

本文为读书摘要(个人认为重要的知识点)

本文内容主要关于 Linux的进程与内存管理、I/O、文件系统,以及相关习题

Linux

Linux的设计目标

  • 一直以来,UNIX都被设计成一种能够同时处理多逬程和多用户的交互式系统,Linux系统自然而然地继承了这些设计目标。

  • 设计Linux的ー个基本指导方针就是每个程序应该只做一件事并且把它做好。

  • 减少冗余设计

到Linux的接口

​ ー个Linux系统可被看成一座金字塔,如图10-1所示。最底层的是硬件,包括CPU、内存、磁盘、显示器、键盘以及其他设备。运行在硬件之上的是操作系统。它的作用是控制硬件并且为其他程序提供系统调用接口。这些系统调用允许用户程序创立并管理进程、文件以及其他资源。

image-20220323143650686

​ POSIX规定哪些库函数是ー个符合标准规范的系统必须提供的,它们的参数是什么,它们的功能是什么,以及它们返回什么样的结果。

​ 除了操作系统和系统调用库,所有版本的Linux必须提供大量的标准程序,其中一些是由POSIX 1003.2标准指定的,其他的根据不同版本的Linux而有所不同。它们包括命令处理器(shell)、编译器、编辑器、文本处理程序以及文件操作工具。用户使用键盘调用的是上述这些程序。

因此,我们可以说Linux具有三种不同的接口:真正的系统调用接口、库函数接口和由标准应用程序构成的接口

内核结构

内核坐落在硬件之上,负责实现与I/O设备和存储管理单元的交互,并控制CPU对前述设备的访问

image-20220323144830692

如图10-3所示,在最底层,内核包含中断处理程序,它们是与设备交互的主要方式,以及底层的分派机制。

这种分派在中断时发生。底层的代码中止正在运行的进程,将其状态存储在内核进程结构中,然后启动相应的驱动程序。进程分派也在内核完成某些操作,并且需要再次启动ー个用户进程时发生。进程分派的代码是汇编代码,并且和进程调度代码有很大不同。

处在最顶层的是到内核的系统调用接口。所有系统调用都来自这里,其触发ー个陷入,并将系统从用户态转换到受保护的内核态,继而将控制权交给上述的内核部件之一。

Linux中的进程

Linux系统中主要的活动实体就是进程。

每个进程执行一段独立的程序并且在进程初始化的时候拥有一个独立的控制线程。 进程以其PID来命名。

Linux系统中的进程可以通过ー种消息传递的方式进行通信。在两个进程之间,可以建立一个通道,ー个进程向这个通道里写入字节流,另ー个进程从这个通道中读取字节流。这些通道称为管道(pipe)。

  • shell中的管线就是用管道技术实现的。当shell看到类似下面的一行输入时:

    sort <f I head

    它会创建两个进程,分别是sort和head,同时在两个进程间建立一个管道使得sort进程的标准输出作为head进程的标准输入。这样ー来,sort进程产生的输出可以直接作为head逬程的输入而不必写入到ー个文件当中去。如果管道满了,系统会停止运行sort进程直到head进程从管道中取出ー些数据。

进程还可以通过另ー种方式通信:软中断。ー个进程可以给另ー个进程发送信号(signal)。进程可以告诉操作系统当信号到来时它们希望发生什么事件.相关的选择有忽略这个信号、抓取这个信号或者被信号杀死,终止进程是处理信号的默认操作。

Linux中进程管理相关的系统调用

image-20220323150642226

Linux中进程与线程的实现

每ー个进程都有一个运行用户程序的用户模式。但是当它的某ー个线程调用系统调用之后,进程会陷入内核模式并且运行在内核上下文中,它将使用不同的内存映射并且拥有对所有机器资源的访问权。它还是同一个线程,但是现在拥有更高的权限,同时拥有自己的内核堆栈以及内核程序计数器。

这几点非常重要,因为ー个系统调用可能会因为某些原因陷入阻塞态,比如说,等待ー个磁盘操作的完成。这时程序计数器和寄存器内容会被保存下来使得不久之后线程可以在内核模式下继续运行 。

在Linux系统内核中,进程通过数据结构task_struct被表示成任务(task)。

  • ー个单线程的进程只有一个任务数据结构,而一个多线程的进程将为每ー个用户级线程分配ー个任务数据结构。
    最后,Linux的内核是多线程的,并且它所拥有的是与任何用户进程无关的内核级线程,这些内核级线程执行内核代码。
  • 对于每ー个进程,ー个类型为task_struct的进程描述符是始终存在于内存当中的。它包含了内核管理全部进程所需的重要信息,如调度参数、已打开的文件描述符列表等。 进程描述符从进程被创建开始就一直存在于内核堆栈之中。
  • 为了与其他UNIX系统兼容,Linux还通过 进程标识符(PID) 来区分进程。内核将所有进程的任务数据结构组织成一个双向链表。不需要遍历这个链表来访问进程描述符,PID可以直接被映射成进程的任务数据结构所在的地址,从而立即访问进程的信息。

任务数据结构包含非常多的分量。

  • 其中一些分量包含指向其他数据结构或段的指针,比如说包含关于已打开文件的信息。有些段只与进程用户级的数据结构有关,当用户进程没有运行的时候,它们是不被关注的。
    (比如说文件描述符可以放在进程用户级数据结构,可以被交换进磁盘,而信号必须永远被保存再内存里)
进程描述的相关信息

1)调度参数。进程优先级,最近消耗的CPU时间,最近睡眠的时间。上面几项内容结合在ー起决定了下ー个要运行的进程是哪ー个。

2)内存映射。指向代码、数据、堆栈段或页表的指针。如果代码段是共享的,代码指针指向共享代码表。当进程不在内存当中时,关于如何在磁盘上找到这些数据的信息也被保存在这里。

3)信号。掩码显示了哪些信号被忽略、哪些信号需要被捕捉、哪些信号被暂时阻塞以及哪些信号在传递当中。

4)机器寄存器。当内核陷阱发生时,机器寄存器的内容(也包括被使用了的浮点寄存器的内容)会被保存。

5)系统调用状态。关于当前系统调用的信息,包括参数和返回值。

6)文件描述符表。当ー个与文件描述符有关的系统调用被调用的时候,文件描述符作为索引在文件描述符表中定位相关文件的i节点数据结构。

7)统计数据。指向记录用户、进程占用系统CPU时间的表的指针。ー些系统还保存ー个进程最多可以占用CPU的时间、进程可以拥有的最大堆栈空间、进程可以消耗的页面数等。

8)内核堆栈。进程的内核部分可以使用的固定堆栈。

9)其他。当前进程状态。如果有的话,包括正在等待的事件、距离警报时钟超时的时间、PID、父进程的PID以及其他用户标识符、组标识符等。

实际上,创建一个新进程的机制非常简单。为子进程创建一个新的进程描述符和用户空间,然后从父进程复制大量的内容。这个子进程被赋予ー个PID,并建立它的内存映射,同时它也被赋予了访问属于父进程文件的权限。然后,它的寄存器内容被初始化并准备运行。

  1. 当系统调用fork执行的时候,调用fork函数的进程陷入内核并且创建一个任务数据结构和其他相关的数据结构,如内核堆栈和thread_info结构。
  2. 进程描述符的主要内容根据父进程的进程描述符的值来填充。Linux系统寻找ー个可用的PID,且该PID此刻未被任何进程使用。更新进程标识符散列表的表项使之指向新的任务数据结构即可。以防散列表发生冲突,相同键值的进程描述符会被组成链表。
  3. 理论上,现在就应该为子进程的数据段、堆栈段分配内存,并且对父进程的段进行复制,因为fork函数意味着父、子进程之间不共享内存。(写时复制)

复制内存的代价相当昂贵,所以现代Linux系统都使用了 “欺骗”的手段来代替。**它们赋予子进程属于它的页表,但是这些页表都指向父进程的页面,同时把这些页面标记成只读。**当进程(可以是子进程或父进程)试图向某ー页面中写入数据的时候,它会收到写保护的错误。内核发现进程的写入行为之后,会为进程分配ー个该页面的新副本,并将这个副本标记为可读、可写。通过这种方式,使得
只有需要写入数据的页面オ会被复制。这种机制叫作写时复制。它所带来的额外好处是,不需要在内存中维护同一个程序的两个副本,从而节省了RAM。

图10-8通过下面的例子解释了上述的步骤:某用户在终端键入ー个命令Is, shell调用fork函数复制自身以创建一个新进程。新的shell进程调用exec函数用可执行文件 ls 的内容覆盖它的内存。完成后,Is 开始运行。

image-20220323153714330

Linux中的线程

引入线程的最大争议在于维护传统UNIX语义的正确性。

Linux系统用一种非常值得关注的有趣的方式支持内核线程。

  • 从历史观点上说,进程是资源容器,而线程是执行单元。ー个进程包含一个或多个线程,线程之间共享地址空间、已打开的文件、信号处理函数, 警报信号和其他。像上面描述的ー样,所有的事情简单而清晰。

2000年的时候,Linux系统引入了一个新的、强大的系统调用clone,模糊了进程和线程的区别,甚至使得两个概念的重要性被倒置。

传统观念上,当一个新线程被创建的时候,之前的线程和新线程共享除了寄存器内容之外的所有信息。

clone函数可以设置这些属性是进程特有的还是线程特有的。
调用方式:pid = clone(function, stack_ptr, sharing_flags, arg);

调用这个函数可以在当前进程或新的进程中创建一个新线程,具体依赖于参数sharing_flag。

  • 如果新线程在当前进程中,它将与其他已存在的线程共享地址空间,任何一个线程对地址空间做出修改对于同一进程中的其
    他线程而言都是立即可见的。
  • 另外一种情况,如果地址空间不是共享的,新线程会获得地址空间的完整副本,
    但是新线程对这个副本进行的修改对于旧的线程来说是不可见的。这些语义同POSIX的fork函数是相同的。

在这两种情况下,新线程都从function处开始执行,并以arg作为唯一的参数。同时,新线程还拥有私有堆栈,其中私有堆栈的指针被初始化为stack_ptr。

参数sharing_flags是ー个位图,这个位图允许比传统的UNIX系统更加细粒度的共享。

image-20220323160032415

由于Linux系统为不同的项目维护了独立的数据结构(见1033小节,如调度参数、内存映射等),因此细粒度的共享成为了可能。任务数据结构只需要指向这些数据结构即可,所以为每ー个线程创建ー个新的任务数据结构变得很容易,或者使它指向旧线程的调度参数、内存映射和其他的数据结构,或者复制它们。

Linux系统的线程模型带来了另ー个难题。UNIX系统为毎ー个进程分配ー个独立的PID,不论它是单线程的进程还是多线程的进程。

  • 为了能与其他的UNIX系统兼容,Linux对进程标识符(PID)和任务标识符(TID)进行了区分。这两个分量都存储在任务数据结构中。当调用clone函数创建一个新进程而不需要和旧进程共享任何信息时,PID被设置成一个新值;否则,任务得到ー个新的任务标识符,但是PID不变.这样ー来,ー个进程中所有的线程都会拥有与该进程中第一个线程相同的PID。

Linux中的调度 与 两种调度器

Linux系统的线程是内核线程,所以 Linux 系统的调度是基于线程的,而不是基于进程的。

为了进行调度,Linux系统将线程区分为三类:

1)实时先入先出。
2)实时轮转。
3)分时。

实时先入先出线程具有最高优先级,它不会被其他线程抢占,除非那是ー个刚刚准备好的、拥有更高优先级的实时先入先出线程。实时轮转线程与实时先入先出线程基本相同,只是每个实时轮转线程都有一个时间量,时间到了之后就可以被抢占。

事实上,这两类线程都不是真正的实时线程。执行的最后期限无法确定,更无法保证最后期限前线程可以执行完毕。这两类线程比起分时线程来说只是具有更高的优先级而已。

在系统内部,实时线程的优先级从 0 到 99, 0 是实时线程的最高优先级,99是实时线程的最低优先级。

传统的非实时线程形成单独的类并由单独的算法进行调度,这样可以使非实时线程不与实时线程竞争资源。在系统内部,这些线程的优先级从100到139,也就是说,Linux系统包含140个不同的优先级(包括实时和非实时任务)。就像实时轮转线程一样,Linux系统根据非实时线程的要求以及它们的优先级分配CPU时间片。

像大多数UNIX系统一样,Linux系统给每个线程分配ー个nice值(即优先级调节值)。默认值是0,但是可以通过调用系统调用nice (value)来修改,修改值的范围从 -20到+19。这个值决定了线程的静态优先级。ー个在后台大量计算出直的用户可以在他的程序里调用这个系统调用为其他用户让出更多计算资源。 只有系统管理员可以要求比普通服务更好的服务(意味着nice函数参数值的范围从 -20到 -1)。

  • Linux O(1) 调度器( O(1) scheduler)是历史上一个流行的Linux系统调度程序。

命名为这个名字是因为它能够在常数时间内执行任务调度,例如从执行队列中选择ー个任务或将一个任务加入执行队列,这与系统中的任务总数无关。

在 O(1) 调度器里,调度队列被组织成两个数组,一个是任务正在活动的数组,ー个是任务过期失效的数组。
如下图a 所示,每个数组都包含了 140个链表头,毎个链表具有不同的优先级。链表头指向给定优先级的双向进程链表。

  1. 调度器从正在活动数组中选择ー个优先级最高的任务。如果这个任务的时间片过期失效了,就把它移动到过期失效数组中(可能会插入到优先级不同的列表中)。
  2. 如果这个任务阻塞了,比如说正在等待I/O事件,那么在它的时间片过期失效之前,一旦所等待的事件发生,任务就可以继续运行, 它将被放回到之前正在活动的数组中,时间片根据它所消耗的CPU时间相应的减少。一旦它的时间片消耗殆尽,它也会被放到过期失效数组中。
  3. 当正在活动数组中没有其他的任务了,调度器交换指针,使得正在活动数组变为过期失效数组,过期失效数组变为正在活动数组。 这种方法可以保证低优先级的任务不会被饿死(除非实时先入先出线程完全占用CPU,但是这种情况是不会发生的)。

由于Linux系统(或其他任何操作系统)事先不知道一个任务究竟是 I/O 密集型的,还是CPU密集的,它只是依赖于连续保持的交互启发式方法。通过这种方式,Linux系统区分静态优先级和动态优先级。线程的动态优先级不断地被重新计算,其目的在于:(1)奖励互动进程,(2)惩罚占用CPU的进程。在 Linux O(1) 调度器中,最高的优先级奖励是ー5,是从调度器接收的与更高优先级相对应的较低优先级的值。最髙的优先级惩罚是+5。

调度器给每ー个任务维护ー个名为sleep_avg的变量。**每当任务被唤醒时,这个变量会增加;当任务被抢占(I/O)或时间量过期时,这个变量会相应地减少。减少的值用来动态生成优先级奖励,奖励的范围从 -5 到+5。**当ー个线程从正在活动数组移动到过期失效数组中时,调度器会重新计算它的优先级。

不同的优先级被赋予不同的时间片长度,高优先级的进程拥有较长的时间片。例如,优先级为100 的任务可以得到800ms的时间片,而优先级为139的任务只能得到5ms的时间片。

image-20220323161821902

O(1)调度器有显著的缺点。最值得注意的是,利用启发式方法来确定一个任务的交互性,会使该任务的优先级复杂且不完善,从而导致在处理交互任务时性能很糟糕

  • 完全公平调度器(Completely Fair Scheduler, CFS)

CFS的主要思想是使用ー棵红黑树作为调度队列的数据结构。根据任务在CPU上运行的时间长短而将其有序地排列在树中,这种时间称为虚拟运行时间(vruntime)。CFS采用ns级的粒度来说明任务的运行时间。

如上图 b 所示,树中的每个内部节点对应于ー个任务。左侧的子节点对应于在CPU上运行时间更少的任务,因此左侧的任务会更早地被调度,右侧的子节点是那些迄今消耗CPU时间较多的任务, 叶子节点在调度器中不起任何作用。

​ CFS调度算法可以总结如下,该算法==总是优先调度那些使用CPU时间最少的任务,通常是在树中最左边节点上的任务。==CFS会周期性地根据任务已经运行的时间,递增它的虚拟运行时间值,并将这个值与树中当前最左节点的值进行比较,如果正在运行的任务仍具有较小虚拟运行时间值,那么它将继续运行,否则,它将被插入红黑树的适当位置,并且CPU将执行新的最左边节点上的任务。

​ 考虑到任务有优先级的差异和“友好程度”,因而当一个任务在CPU上运行时,CFS会改变该任务的虚拟运行时间流逝的有效速率。对于优先级较低的任务,时间流逝更快,它的虚拟运行时间值也将增加得更快,考虑到系统中还有其他任务,因此有较低的优先级的任务会失去CPU的使用权,相较于优先级高的任务更快地重新插入树中。以这种方式,CFS可避免使用不同的调度队列结构来放置不同优先级
的任务。

​ 调度器只考虑可以运行的任务,这些任务被放在适当的调度队列当中。不可运行的任务和正在等待各种i/o操作或内核事件的任务被放入另ー个数据结构当中,即等待队列。每ー种任务可能需要等待的事件对应了一个等待队列。等待队列的头包含ー个指向任务链表的指针及一枚自旋锁。

image-20220323182801308

Linux中的内存管理

毎个Linux进程都有一个地址空间,逻辑上有三段组成:代码、数据和堆栈段。

图10-12a中的进程A 就给出了一个进程空间的例子。

代码段 包含了形成程序可执行代码的机器指令。它是由编译器和汇编器把C、C++或者其他程序源码转换成机器代码而产生的。
Linux支持共享代码段 (运行同样的程序)

image-20220323183040983

数据段 包含了所有程序变量、字符串、数字和其他数据的存储。它有两部分,初始化数据和未初始化数据。由于历史的原因,后者就是我们所知道的BSS (历史上称作符号起始块)。数据段的初始化部分包括编译器常量和那些在程序启动时就需要一个初始值的变量。所有BSS部分中的变量在加载后被初始化为0。

**为了避免分配ー个全0的物理页框,在初始化的时候,Linux就分配了一个静态零页面,即ー个全0的写保护页面。**当加载程序的时候,未初始化数据区域被设置为指向该零页面。当ー个进程真正要写这个区域的时候,写时复制的机制就开始起作用,一个实际的页框被分配给该进程 。

跟代码段不一样,数据段可以改变。程序总是修改它的变量。而且,许多程序需要在执行时动态分配空间。Linux允许数据段随着内存的分配和回收而增长和缩减,通过这种机制来解决动态分配的问题。 有一个系统调用brk,允许程序设置其数据段的大小。

第三段是 栈段 。在大多数机器里,它从虚拟地址空间的顶部或者附近开始,并且向低地址空间延伸。 例如,在32位x86平台上,栈的起始地址是0XC000000(),这是在用户态下对进程可见的3GB虚拟地址限制。如果栈生长到了栈段的底部以下,就会产出ー个硬件错误同时操作系统把栈段的底部降低ー个页面。 程序并不显式地控制栈段的大小。

除了动态分配更多的内存,Linux中的进程可以通过 内存映射文件 来访问文件数据。这个特性使我们可以把ー个文件映射到进程空间的一部分而该文件就可以像位于内存中的字节数组ー样被读写。把ー个文件映射进来使得随机读写比使用read和write 之类的 I/O 系统调用要容易得多。共享库的访问就是用这种机制映射进来后进行的。在图10-13 中,我们可以看到ー个文件被同时映射到两个进程中,但在不
同的虚拟地址上。

image-20220323183451210

Linux中内存管理的实现

为了允许多个进程共享物理内存,Linux监视物理内存的使用,在用户进程或者内核构件需要时分配更多的内存,把物理内存动态映射到不同进程的地址空间中去,把程序的可执行体、文件和其他状态信息移入移出内存来高效地利用平台资源并且保障程序执行的进展性。

物理内存管理 与 内存映射

在许多系统中由于异构硬件限制,并不是所有的物理内存都能被相同地对待,尤其是对于I/O和虚拟内存。
Linux区分以下内存区域(zone):

  1. ZONE_DMA 和 ZONE_DMA32:可以用于DMA操作的页。
  2. ZONE_NORMAL: 正常的,规则映射的页。
  3. ZONE_HIGHMEM: 高内存地址的页,并不永久性映射。

Linux的内存由三部分组成。前两部分是内核和内存映射(物理内存使用情况),被固定在内存中(页面从来不换出)。内存的其他部分被划分成页框,每ー个页框都可以包含ー个代码、数据或者栈页面,ー个页表页面,或者在空闲列表中。

pagetable不在 这里的 内存映射,而是内核自己一份,进程一份

  • 内核维护内存的ー个映射,该映射包含了所有系统物理内存使用情况的信息,比如区域、空闲页框等。如图10-15,这些信息是如下组织的。
image-20220323185619510

首先,Linux维护ー个页描述符数组,称为mem_map,其中页描述符是page类型的,而且系统当中的每个物理页框都有一个页描述符。

  • **每个页描述符都有个指针,在页面非空闲时指向它所属的地址空间, 另有一对指针可以使得它跟其他描述符形成双向链表,来记录所有的空闲页框和一些其他的域。**在图10-15 中,页面150的页描述符包含一个到其所属地址空间的映射。页面70、页面80、页面200是空闲的, 它们是被钵接在ー起的。页描述符的大小是32字节,因此整个mem_map消耗了不到1%的物理内存(对于4KB的页框)。

因为物理内存被分成区域,所以Linux为每个区域维护ー个区域描述符

  • 区域描述符包含了每个区域中内存利用情况的信息,例如活动和非活动页的数目,页面置换算法(本章后面介绍)所使用的高低水印位,还有许多其他相关信息等。

  • 此外,区域描述符包含ー个空闲区数组。该数组中的第i个元素标记了 2 ^ i 个空闲页的第一个块的第一个页描述符。既然可能有多块2 ^ i 个空闲页,Linux使用页描述符的指针对把这些页面链接起来。这个信息在Linux的内存分配操作中使用。在图10-15中,free_area[0]标记所有仅由一个页框组成的物理内存空闲区, 现在指向页面70,三个空闲区当中的第一个。其他大小为ー个页面的空闲块也可通过页描述符中的链接来获取其地址。

为了使分页机制在32位和64位体系结构下都能高效工作,Linux采用了一个四级分页策略。
使用二级 或 三级,只要把 上级、中级 目录域 大小置为0 即可。

image-20220323193105495

总结
物理内存可以用于多种目的。

  • 内核自身是完全“硬连线”的,它的任何一部分都不会换出。(包括内存映射)
  • 内存的其余部分可以作为用户页面、分页缓存和其他目的。
    • 页面缓存保存最近已读的或者由于未来有可能使用而预读的文件块,或者需要写回磁盘的文件块页面,例如那些被换出到磁盘的用户进程创建的页面。用户进行操作时随时变化的页面共同竞争页面缓存这个有限的空间。
    • 分页缓存并不是ー个独立的缓存,而是那些不再需要的或者等待换出的用户页面集合。 如果分页缓存当中的ー个页面在被换出内存之前复用, 它可以被快速收回。
内存分配机制 伙伴算法 与 slab机制 vmalloc & kmalloc

Linux支持多种内存分配机制。分配物理内存页框的主要机制是页面分配器,它使用了著名的伙伴算法。

管理一块内存的基本思想如下。刚开始,内存由一块连续的片段组成,图10- 17a的简单例子中是 64 个页面。当ー个内存请求到达时,首先上舍入到2的幕,比如8个页面。然后整个内存块被分割成两半, 如图b所示。因为这些片段还是太大了,较低的片段被再次二分(c),然后再二分(d)。现在我们有一块大小合适的内存,因此把它分配给请求者,如图d所示。

image-20220323194050876

Linux用伙伴算法管理内存,同时有一些附加特性。它有个数组,其中的第一个元素是大小为1个单位的内存块列表的头部,第二个元素是大小为2个单位的内存块列表的头部,下一个是大小为4个单位的内存块列表的头部,以此类推。通过这种方法,任何2的幕次大小的块都可以快速找到。

伙伴算法的过程

答:

1)将所有的空闲的页框分组为11块链表,大小是从1、2、4等到1024

2)分配的过程:例如请求256个页框,先在256的空闲链表的查找是否有空闲的块,如果没有空闲的块,查找更大的块,也就是有516个页框的链表,在这个链表中有256个页框的空闲块,则将其中的256个页框分配出去满足请求,另外256插入回链表中;如果这是还没有满足要求的空闲块,再向后,,,

3)释放的过程:采用迭代合并的方法。当两个空闲的块他们的大小相等,并且物理地址都是连续的,将这两块合并起来。

伙伴算法的缺点 —— 导致大量的内部碎片

为了缓解这个问题,Linux有另ー个内存分配器,slab分配器。它使用伙伴算法获得内存块,但是之后从其中切出slab (更小的单元)并且分别进行管理。

因为内核频繁地创建和撤销一定类型的对象(如task_struct),它使用了对象缓存。这些缓存由指向ー个或多个slab的指针组成,而slab可以存储大量相同类型的对象。每个slab要么是满的,要么是部分满的,要么是空的。

  • 例如,当内核需要分配ー个新的进程描述符(ー个新的task_struct)的时候,它在task结构的对象缓存中寻找,首先试图找ー个部分满的slab并且在那里分配ー个新的task_struct对象。如果没有这样的slab可用,就在空闲slab列表中查找。最后,如果必要,它会分配ー个新的slab,把新的task结构放在那里,同时把该slab连接到task结构对象缓存中。
    在内核地址空间分配连续的内存区域的kmalloc内核服务,实际上就是建立在slab和对象缓存接口之上的

第三个内存分配器vmalloc==(物理内存不连续,导致性能损失)==也是可用的,并且用于那些仅仅需要连续虚拟地址空间的请求,在物理内存中它并不适用。

虚拟地址空间表示

虚拟地址空间被分割成同构连续页面对齐的区域。 也就是说,每个区域由一系列连续的具有相同保护和分页属性的页面组成。代码段和映射文件就是区(area)的例子(见图10/5)。**在虚拟地址空间的区之间可以有空隙。所有对这些空隙的引用都会导致ー个严重的页面故障。**而页大小是确定的(一般为4KB)。

在内核中,每个区是用vm_area_stmct项来描述的。 ー个进程的所有vm_area_stnict用ー个链表链接在ー起,并且按照虚拟地址排序以便可以找到所有的页面。当这个链表太长时(多于32项),就创建ー个树来加速搜索。vm_area_struct项列出了该区的属性。这些属性包括:保护模式(如,只读或者可读可写)、是否固定在内存中(不可换出)、朝向哪个方向生长(数据段向上长,栈段向下长)。

vm_area_struct也记录该区是私有的还是跟ー个或多个其他进程共享的。 fork之后,Linux为子进程 复制一份区链表,但是让父子进程指向相同的页表。区被标记为可读∕可写,但是页面自己却被标记为只读。如果任何ー个进程试图写页面,就会产生一个保护故障,此时内核发现该内存区逻辑上是可写的, 但是页面却不是可写入的,因此它把该页面的ー个副本给当前进程同时标记为可读可写。这个机制就说明了写时复制是如何实现的。

vm_area_struct也记录该区是否在磁盘上有备份存储,如果有,在什么地方。 代码段把可执行二进
制文件作为备份存储,内存映射文件把磁盘文件作为备份存储。其他区,如栈,直到它们不得不被换出,
否则没有备份存储被分配。

​ ー个顶层内存描述符mm_struct收集属于ー个地址空间的所有虚拟内存区相关的信息,还有关于不
同段(代码,数据,栈)和用户共享地址空间的信息等。

一个地址空间的所有vm_area_struct元素可以通过内存描述符用两种方式访问。

  • 首先,它们是按照虚拟地址顺序组织在链表中的。这种方式的有用之处是:当所有的虚拟地址区需要被访问时,或者当内核査找分配ー个指定大小的虚拟内存区域时。
  • 此外, vm_area_stmct项目被组织成“红黑”树(ー种为了快速査找而优化的数据结构)。**这种方法用于访问ー个指定的虚拟内存地址。**为了能够用这两种方法访问进程地址空间的元素,Linux为每个进程使用了更多的状态,但是却允许不同的内核操作来使用这些访问方法,这对进程而言更加高效。

Linux中的分页

​ Linux 内存管理单元是ー个页,并且几乎所有的内存管理部件以页为操作粒度。交换子系统也是以页为操作粒度的,并且跟页框回收算
法紧耦合在ー起。

Linux分页背后的基本思想是简单的:为了运行,一个进程并不需要完全在内存中。实际上所需要的是用户结构和页表。如果这些被换进内存,那么进程被认为是“在内存中”,可以被调度运行了。 代码、数据和栈段的页面是动态载人的,仅仅是在它们被引用的时候。如果用户结构和页表不在内存中, 直到交换器把它们载入内存进程才能运行。

分页是一部分由内核实现而一部分由一个新的进程——页面守护进程实现的。 页面守护进程是进程 2 (进程 0 是idle进程,传统上称为交换器,而进程1是init,如图10-11所示)。跟所有守护进程一样,页面守护进程周期性地运行。一旦唤醒,它主动查找是否有工作要干。如果它发现空闲页面数量太少,就开始释放更多的页面。

代码段和映射文件换页到它们各自在磁盘上的文件中。所有其他的都被换页到分页分区(如果存在)或者ー个固定长度的分页文件,叫作交换区。 分页文件可以被动态地添加或者删除,并且每个都有一个优先级。

为什么要有一个独立的分区(swap)而不是 换页到一个临时文件?

  • 换页到ー个独立的分区并且像ー个 原始设备那样访问的这种方式要比换页到一个文件的方式更加髙效。
    之所以这么说是有多重原因的:
    1. 首先, 文件块和磁盘块的映射不需要了。 (节省了磁盘 I/O 读间接块) 原本每次申请都要访问
    2. 其次,物理写可以是任意大小的,并不仅仅是文件块大小。 (磁盘使用更充分)
    3. 第三,ー个页总是被连续地写到磁盘,用ー个分页文件,就并一定是这样。 (写和读快)
页面置换算法 PFRA

Linux试图保留一些空闲页面,这样可以在需要的时候分配它们。当然,这个页面池必须不断地加以补充。PFRA (页框回收算法, Page frame recycling algorithm)算法展示了它是如何发生的。

​ 首先,Linux区分四种不同的页面:不可回收的(unreclaimable)、可交换的(swappable),可同步的(syncable)、可丢弃的(discardable)。

  • 不可回收页面包括保留或者锁定页面、内核态栈等,不会被换出。
  • 可交换页必须在回收之前写回到交换区或者分页磁盘分区。
  • 可同步的页面如果被标记为dirty就必须要写回到磁盘。
  • 最后,可丢弃的页面可以被立即回收。

在启动的时候,init开启ー个页面守护进程kswapd (每个内存节点都有一个),并且配置它们能周期性运行(保证有空闲页面可用)。每次kswapd被唤醒,它通过比较每个内存区域的高低水位和当前内存的使用来检査是否有足够的空闲页面可用。如果有足够的空闲页面,它就继续睡眠。当然它也可以在需要更多页面时被提前唤醒。

如果任何内存区域的可用空间低于ー个阈值,kswapd 初始化页框回收算法。在每次运行过程中,仅有一个确定数目的页面被回收,典型值是最大值32。这个值是受限的,以控制I/O压カ(由PFRA操作导致的磁盘写的次数)。回收页面的数量和扫描页面的总数量都是可配置的参数。

正如人们都会按照先易后难的顺序做事ー样,每次PFRA执行时,它首先回收容易的页面,然后处理更难的。可丢弃页面和未被引用的页面都可以把它们添加到区域的空闲链表中从而立即回收。接着它査找有备份存储同时近期未被使用的页面,使用ー个==类似于时钟的算法==。再后来就是用户使用不多的共享页面。接下来是普通用户页面,如果被选中换出,它们必须被调度写入交换区。

image-20220323205322604

PFRA用ー个类似时钟的算法来选择旧页面换出。

为了维护ー些启发式方法并且尽量找出没有被引用的和近期不可能被使用的页面,PFRA为毎个页面维护两个标记:活动 ∕ 非活动和是否被引用。 这两个标记构成四种状态,如图10-18所示。在对ー个页面集合的第一遍扫描中, PFRA首先清除它们的引用位。如果在第二次运行期间确定它已经被引用,则把它提升到另ー个状态,这样就不太可能回收它了。否则,将该页面移动到ー个更可能被回收的状态。

处在非活动列表上的页面,自从上次检査未被引用过,故而是移出的最佳候选。有些页面的PG_active和PG_referenced都被置为0,如图 10-18所示。然而,如果需要,处于其他状态的页面也可能会被回收。图10-18中的重装箭头就说明这个事实。

另ー个守护进程pdfhιsh,实际上就是ー组后台守护线程。pdflush线程要么(1)周期性醒来(通常是每500ms),把非常旧的“脏”(dirty)页面写回到磁盘, 要么(2)当可用的内存水平下降到ー个阈值时由内核显式唤醒,把页面缓存的“脏”页面写回到磁盘。 在便携模式(laptop mode)下,为了保留电池寿命,每次pdflush线程醒来,“脏”页面就被写到磁盘。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值