3.2 背景
后面的各节将讲述操作系统的概念和内核的一般性知识。具体的内核间的差异会在后面介绍。
3.2.1 内核
内核管理着CPU 调度、内存、文件系统、网络协议,以及系统设备(磁盘、网络接口,等等)。通过系统调用提供访问设备和内核服务的机制。图3.1 就是内核的角色图。
图3.1 操作系统内核的角色
从图中还可以看到系统库,较之只使用系统调用,系统库提供的编程接口通常更为丰富和简单。应用程序包括所有运行在用户级别的软件,有数据库、Web 服务器、管理员工具和操作系统的shell。
此处系统库所画的环有一个缺口,表示应用程序是可以直接进行系统调用的(如果操作系统允许)。传统意义上,这张图的环是封闭的,表示从位于中心的内核起,特权级别逐层降低(该模型源于Multics[Graham 68],是UNIX 的前身)。
内核执行
内核是一个庞大的程序,通常有几十万行代码。内核的执行主要是按需的,例如,当用户级别的程序发起一次系统调用,或者设备发送一个中断时。一些内核线程会异步地执行一些系统维护的工作,其中可能包括内核时钟程序和内存管理任务,但是这些都是轻量级的,只占用很少的 CPU 资源。
I/O 执行频繁的工作负载,如Web 服务器,会经常执行在内核上下文中。计算密集型的工作负载则会尽量地不打扰内核,因此它们能不中断地在CPU 上运行。你很可能会想内核是无法影响到这些工作负载的性能的,但是在许多情况下,确实会影响。最明显的例子就是CPU 竞争,这时其他的线程在争夺CPU 资源,而内核调度器需要决定哪个线程会运行、哪个会等待。内核还要选择线程运行在哪颗CPU 上,内核会选择硬件缓存更热或者对于进程内存本地性更好的CPU,以显著地提升性能。
时钟
经典的UNIX 内核的一个核心组件是clock()例程,从一个计时器中断执行。历史上它每秒执行次数为60 100 或1000 次,[2]每次执行称为一次tick。功能包括更新系统时间、计时器和线程调度时间片的到时结束、维护CPU 统计数据,以及执行callout(内核调度例程)。
有关时钟曾经存在过一些性能问题,不过在之后的内核中都得到了改进。
● tick 延时:对于100Hz 的时钟,因为要等待在下一个tick 做处理,遇到的延时可能会长达10ms。这一问题已经用高精度的实时中断解决了,执行可以立即发生而不需要等待。
● tick 开销:现代的处理器有动态电源功能,可以在空闲的时候降低耗能。clock 例程会打断这个过程,空闲的系统也会不必要地消耗功率。Linux 采用了动态tick,这样当系统空闲时,计数器例程(clock)不启动。
现代内核已经把许多功能移出了clock 例程,放到了按需中断中,这是为了努力创造无tick内核。包括Linux 在内,clock 例程,即系统计时器中断,除了更新系统时钟和更新jiffies 计数器之外,执行的工作很少(jiffies 是Linux 的时间单元,与tick 类似)。
内核态
内核是唯一运行在特殊CPU 模式的程序,这一特殊的CPU 模式叫做内核态,在这一状态下,设备的一切访问以及特权指令的执行都是被允许的。由内核来控制设备的访问,用以支持多任务处理,除非明确允许,否则进程之间和用户之间的数据是无法彼此访问的。
用户程序(进程)运行在用户态,对于内核特权操作(例如I/O)的请求是通过系统调用传递的。执行系统操作,执行会做上下文切换从用户态到内核态,然后用更高的特权级别执行,如图3.2 所示。
图3.2 系统调用执行模式
无论是用户态还是内核态,都有自己的软件执行上下文,包括栈和寄存器。在用户态执行特权指令会引起异常,这会由内核来妥善处理。
在这些状态切换上下文是会耗时的(CPU 周期),这对每次I/O 都增加了一小部分的时间开销。有些服务,如NFS,会用内核态的软件来进行实现(而不是用户态的守护进程),这样从设备来回执行I/O 的时候才无须上下文切换到用户态。
上下文切换也会发生在不同进程之间,例如CPU 调度时。
3.2.2 栈
栈用函数和寄存器的方式记录了线程的执行历史。使用栈令CPU 可以高效地处理函数执行。
当函数被调用时,CPU 当前的寄存器组(保存CPU 状态)会存放在栈里,在顶部会为线程的当前执行添加一个新的栈帧。函数通过调用CPU 指令“return”终止执行,从而清除当前的栈,执行会返回到之前的栈,并恢复相应的状态。
栈检查是一个对于调试和性能分析非常宝贵的工具。栈可以显示通往当前的执行状态的调用路径,这一点常常可以解释为什么某些事情会被执行。
如何读栈
下面的内核栈示例(来自Linux)显示了TCP 传输的调用路径,正如调试工具打印出来的信息那样:
栈的顶部通常出现在第一行。在这个例子里,第一行包含了tcp_sendmsg——当前执行函数的名字。函数名字的左侧和右侧是调试器提供的细节信息:内核模块的位置(`kernel)和指令的偏移量(0x1,这指的是函数内指令的地址)。
函数称为tcp_sendmsg( ),它的父函数能在它下面看到:inet_sendmsg( )。这个函数的父函数也在它下面:sock_aio_wirte( )。通过自上而下阅读栈,能看到全部的调用历史:函数、父函数、祖父函数,依此类推。或者,自下而上阅读,你能跟踪执行到当前函数的路径:我们是怎么到这里的。
由于栈揭示出的内部路径源于源代码,除了代码之外,这些函数没有任何文档。对应这个例子里栈的是Linux 内核的源代码。除非,函数是某一API 的一部分而且有公开的文档。
用户栈和内核栈
在执行系统调用时,一个进程的线程有两个栈:一个用户级别的栈和一个内核级别的栈,它们的范围如图3.3 所示。
图3.3 用户栈和内核栈
线程被阻塞时,用户级别的栈在系统调用期间并不会改变,当执行在内核上下文时,线程用的是一个单独的内核级别的栈。(此处有一个例外,信号处理程序取决于其配置,可以借用用户级别的栈。)
3.2.3 中断和中断线程
除了响应系统调用外,内核也要响应设备的服务请求,这称为中断,它会中断当前的执行,如图3.4 所示。
图3.4 中断处理
中断服务程序(interrupt service routine)需要通过注册来处理设备中断。这类程序的设计要点是需要运行得尽可能快,以减少对活动线程中断的影响。如果中断要做的工作不少,尤其是还可能被锁阻塞,那么最好用中断线程来处理,由内核来调度。
怎样实施取决于内核的版本。对于Linux 而言,设备驱动分为两半,上半部用于快速处理中断,到下半部的调度工作在之后处理[Corbet 05]。上半部快速处理中断是很重要的,因为上半部运行在中断禁止模式(interrupt-disabled mode),会推迟新中断的产生,如果运行的时间太长,就会造成延时问题。下半部可以作为tasklet 或者工作队列,之后由内核做线程调度,如果需要也可休眠。如果有较多的工作要做,基于Solaris 的系统会把中断放在中断线程里[McDougall 06a]。
从中断开始到中断被服务之间的时间叫做中断延时(interrupt latency),这主要取决于实现。有专门研究实时或低延时系统的学科。
3.2.4 中断优先级
中断优先级(interrupt priority level,IPL)表示的是当前活跃的中断服务程序的优先级。中断优先级是在中断信号发出时从处理器读取的,如果读到的级别要高于当前执行的中断(如果有),那么该中断成功;否则,该中断会排队以待之后运行。这就避免了高优先级的工作被低优先级的工作打断的问题。
图3.5 显示的是一个IPL 范围的示例,内核服务作为中断线程的IPL,范围为1~10。
图3.5 中断优先级范围
串行I/O 的中断优先级很高,这是因为硬件的缓冲通常很小,需要快速服务以避免溢出。
3.2.5 进程
进程是用以执行用户级别程序的环境。它包括内存地址空间、文件描述符、线程栈和寄存器。从某种意义上说,进程像是一台早期电脑的虚拟化,里面只有一个程序在执行,用着自己的寄存器和栈。
进程可以让内核进行多任务处理,使得在一个系统中可以执行着上千个进程。每一个进程用它们的进程ID 做识别(process ID,PID),每一个PID 都是唯一的数字标示符。
一个进程中包含有一个或多个线程,操作在进程的地址空间内并且共享着一样的文件描述符(标示打开文件的状态)。线程是一个可执行的上下文,包括栈、寄存器,以及程序计数器。多线程让单一进程可以在多个CPU 上并发地执行。
进程创建
正常情况下进程是通过系统调用fork( )来创建的。fork( )用自己的进程号创建自身进程的一个复制,然后调用系统调用exec( )才能开始执行不同的程序。
图3.6 展示的一个shell(sh)执行ls 命令的进程创建过程。
图3.6 进程创建
系统调用fork( )可以用写时拷贝(copy-on-write,COW)的策略来提高性能。这会添加原有地址空间的引用而非把所有内容都复制一遍。一旦任何进程要修改被引用的内存,就会针对修改建立一个独立的副本。这一策略推迟甚至消除了对内存拷贝的需要,从而减少了内存和CPU的使用。
进程生命周期
图3.7 展示的就是进程的生命周期。这是一个简化的示意图,对于现代多线程操作系统还会有线程的调度和执行,关于如何把这些映射成进程状态还有一些实现的细节(作为参考,可阅读你内核代码的proc.h 文件)。
图3.7 进程生命周期
on-proc 状态是指进程运行在处理器(CPU)上。ready-to-run 状态是指进程可以运行,但还在CPU 的运行队列里等待CPU。I/O 阻塞,让进程进入sleep 状态直到I/O 完成进程被唤醒。zombie 状态发生在进程终止,这时进程等待自己的进程状态被父进程读取,或者直至被内核清除。
进程环境
图3.8 展示的是进程环境,包括进程地址空间内的数据和内核里的元数据(上下文)。
内核上下文包含了各种进程的属性和统计信息:它的进程ID(PID)、所有者的用户ID(UID),以及各种类型的时间。这些通常用ps(1)命令来检查。还有一套文件描述符,指向的是打开的文件,这些文件为线程之间所共享(通常来说)。
图3.8 画了两个线程,每一个线程都有一些元数据,包括在内核上下文里自己的优先级以及在用户地址空间里自己的栈。这幅图并没有按比例绘制,相对于进程地址空间内核上下文的大小是很小的。
图3.8 进程环境
用户地址空间包括进程的各种内存段:可执行文件、库和堆,参见第7章。
3.2.6 系统调用
系统调用请求内核执行特权的系统例程。可用的系统调用数目是数百个,但需要努力确保这一数目尽可能地小,以保持内核简单(UNIX 的理念,[Thompson 78])。更为复杂的接口应该作为系统库构建在用户空间中,在那里开发和维护更为容易。
需要记住的关键的系统调用列在了表3.1 中。
表3.1 关键系统调用
系统调用都有很好的文档,每个系统调用都有一个Man 手册,通常随操作系统一起。一般而言,它们的接口简单且一致,接口里包括设置一个特殊的变量,errno,出现错误时可用以指示错误及其类型。
这些系统调用的目的都很明显。下面是一些常见但可能不太明显的用法。
● ioctl( ):这个系统调用用于向内核请求各种各样的操作,特别是针对系统管理工具,这是另一个系统调用不适合的。参见下面的例子。
● mmap( ):这个系统调用通常用来把可执行文件和库以及内存映射文件映射到进程的地址空间。有时候会替代基于brk( )的malloc( )对进程的工作内存做分配,以减少系统调用的频率,提升性能(并不总是这样,内存映射管理会做一些权衡)。
● brk( ):这个系统调用用于延伸堆的指针,该指针定义了进程工作内存的大小。这个操作通常是由系统内存分配库执行的,当调用malloc( )(内存分配)不能满足堆内现有空间时发生。参见第7章。
如果你对某个系统调用不熟悉,可以从它的Man 手册了解更多信息(Man 手册的第2 节:syscalls 中)。
系统调用ioctl( )是学习起来最困难的,因为它本身用法太过多样。举一个例子,Linux的perf(1)工具(在第6章中有介绍)执行特权指令来协调性能监测点。并非对每一个行为都添加一个系统调用,而是只添加一个系统调用:perf_event_open(),它会用ioctl()返回一个文件描述符。用不同的参数调用ioctl()会执行不同行为。例如,ioctl(fd, PERF_EVENT_IOC_ENABLE)能开启监测点。在这种情况下,开发人员可以很容易地对参数PERF_EVENT_IOC_ENABLE 做添加和修改。
3.2.7 虚拟内存
虚拟内存是主存的抽象,提供进程和内核,它们自己的近乎是无穷的和私有的主存视野。支持多任务处理,允许进程和内核在它们自己的私有地址空间做操作而不用担心任何竞争。它还支持主存的超额使用,如果需要,操作系统可以将虚拟内存在主存和二级存储(磁盘)之间映射。
图3.9 显示的是虚拟内存的作用。一级存储是主存(RAM),二级存储是存储设备(磁盘)。
图3.9 虚拟内存地址空间
是处理器和操作系统的支持使得虚拟内存成为可能,它并不是真实的内存。多数操作系统仅仅在需要的时候将虚拟内存映射到真实内存上,即当内存首次被填充(写入)时。
关于更多虚拟内存的内容,参考第7章。
3.2.8 内存管理
当虚拟内存用二级存储作为主存的扩展时,内核会尽力保持最活跃的数据在主存中。有以下两个内核例程做这件事情。
● 交换:让整个进程在主存和二级存储之间做移动。
● 换页:移动称为页的小的内存单元(例如,4KB)。
swapping 是原始的UNIX 方法,会引起严重的性能损耗。paging 是更高效的方法,经由换页虚拟内存的引入而加到了BSD 中。两种方法,最近最少使用(或最近未使用)的内存被移动到二级存储,仅在需要时再次搬回主存。
在Linux 里,术语swapping 用于指代paging。Linux 内核是不支持(老的)UNIX 风格的整体线程和进程的swapping 的。
关于paging 和swapping,可参考第7章。
3.2.9 调度器
UNIX 及其衍生的系统都是分时系统,通过划分执行时间,让多个进程同时运行。进程在处理器上和CPU 间的调度是由调度器完成的,这是操作系统内核的关键组件。图3.10 展示了调度器的作用,调度器操作线程(Linux 中是任务(task)),并将它们映射到CPU 上。
图3.10 内核调度器
调度器基本的意图是将CPU 时间划分给活跃的进程和线程,而且维护一套优先级的机制,这样更重要的工作可以更快地执行。调度器会跟踪所有处于ready-to-run 状态的线程,传统意义上每一个优先级队列都称为运行队列 [Bach 86]。现代内核会为每个CPU 实现这些队列,也可以用除了队列以外的其他数据结构来跟踪线程。当需要运行的线程多于可用的CPU 数目时,低优先级的线程会等待直到轮到自己。多数的内核线程运行的优先级要比用户级别的优先级高。
调度器可以动态地修改进程的优先级以提升特定工作负载的性能。工作负载可以做以下分类。
● CPU 密集型:应用程序执行繁重的计算,例如,科学和数学分析,通常运行时间较长(秒、分钟、小时)。这些会受到CPU 资源的限制。
● I/O 密集型:应用程序执行I/O,计算不多,例如,Web 服务器、文件服务器,以及交互的shell,这些需要的是低延时的响应。当负载增加时,会受到存储I/O 或网络资源的限制。
调度器能够识别CPU 密集型的进程并降低它们的优先级,可以让I/O 密集型工作负载(需要低延时响应)更快地运行。计算最近的计算时间(在CPU 上执行时间)与真实时间(逝去时间)的比例,通过降低高(计算)比例的进程的优先级就可以达到这一目的[Thompson 78]。这一机制更优先选择那些经常执行I/O 的短时运行进程,包括与人类交互的进程在内。
现代内核支持多类别调度,对优先级和可运行线程的管理实行不同的算法。其中包括实时调度类别,该类别的优先级要高于所有非关键工作的优先级(甚至包括内核线程)。还有抢占的支持(稍后会讲述),实时调度级别对实时系统提供低延时的调度。
关于内核调度和其他调度级别的内容,可参考第6章。
3.2.10 文件系统
文件系统是作为文件和目录的数据组织。有一个基于文件的接口用于访问,该接口通常是基于POSIX 标准的。内核能够支持多种文件系统类型和实例。提供文件系统支持是操作系统最重要的作用之一,曾经被描述为是最为重要的作用[Ritchie 74]。
操作系统提供了全局的文件命名空间,组织成为一个以根目录(“/”)为起点,自上而下的拓扑结构。通过挂载(mounting)可以添加文件系统的树,把自己的树挂在一个目录上(挂载点)。这使得遍历文件命名空间对于终端用户是透明的,不用考虑底层的文件系统类型。
图3.11 是一个典型的操作系统的组织图。
图3.11 操作系统文件的层次结构
顶层的目录包括:etc 放系统配置文件,usr 是系统提供的用户级别的程序和库,dev 是设备文件,var 是包括系统日志在内的各种文件,tmp 是零时文件,home 是用户的home 目录。在图中所示的示例中,var 和home 可以是在自身的文件系统实例里,位于不同的存储设备中;然而,它们能像这个树的其他部分一样,做同样的访问。
多数文件系统使用存储设备(磁盘)来存放内容。某些文件系统类型是由内核动态生成的,诸如/proc 和/dev。
VFS
虚拟文件系统(virtual file system,VFS)是一个对文件系统类型做抽象的内核界面,起源于Sun Microsystems 公司,最初的目的是让UNIX 文件系统(UFS)和NFS 能更容易地共存。VFS 的作用见图3.12。
图3.12 虚拟文件系统
VFS 接口让内核添加新的文件系统时更加简单。之前的图中也表述过,VFS 也支持全局的文件命名空间,用户程序和应用程序能透明地访问各种类型的文件系统。
I/O 栈
基于存储设备的文件系统,从用户级软件到存储设备的路径被称为I/O 栈。这是之前说过的整个软件栈的一个子集。一般的I/O 栈如图3.13 所示。
第8章会详细地介绍文件系统和各文件系统的性能,关于它们所构建其上的存储设备的内容将在第9章介绍。
图3.13 一般的I/O 栈
3.2.11 缓存
由于磁盘I/O 的延时较长,软件栈中的很多层级通过缓存读取和缓存写入来试图避免这一点。可以包括的缓存如表3.2 所示(按可以用于核对的顺序排列)。
表3.2 磁盘I/O 缓存层级示例
续表
举个例子,缓冲区高速缓存是主存的一块区域,用于存放最近使用的磁盘块,如果请求的块在,磁盘读取就能立即完成,避免了高延时的磁盘I/O。
基于不同的系统和环境,缓存的类型会有较大的不同。
3.2.12 网络
现代内核提供一套内置的网络协议栈,能够让系统用网络进行通信,成为分布式系统环境的一部分。栈指的是TCP/IP 栈,这个命名源自最常用的TCP 协议和IP 协议。用户级别应用程序通过称为套接字的编程端点跨网络通信。
连接网络的物理设备是网络接口,一般使用网络接口卡(network interface card,NIC)。系统管理员的一个常规操作就是把IP 地址关联到网络接口上,这样才能用网络进行通信。
网络协议不经常变化,但是协议的增强和选项会变化,诸如新的TCP 选项和新的TCP 阻塞控制算法需要内核支持。另一个可能的变化是对于不同的网络接口卡的支持,需要内核有新设备的驱动。
关于网络和网络性能更多的内容,可参考第10章。
3.2.13 设备驱动
内核必须和各种各样的物理设备通信。这样的通信可以通过使用设备驱动达成。设备驱动是用于设备管理和设备I/O 的内核软件。设备驱动常常由开发硬件设备的厂商提供。某些内核支持“可插拔”的设备驱动,这意味着不需要系统重启就可以装载或卸载这些设备驱动。
设备驱动提供给设备的接口有字符接口也有块接口。字符设备,也称为原始设备,提供无缓冲的设备顺序访问,访问可以是任意I/O 尺寸的,也可以小到单一字符,取决于设备本身。这类设备包括键盘和串行口(对于最早的UNIX,还有纸带和行打印机)。
块设备所执行的I/O 以块为单位,从前一直是一次512B。基于块的偏移值可以随机访问,偏移值在块设备的头部以0 开始计数。对于最早的UNIX,块设备接口还为设备的缓冲区提供缓存来提升性能,这相对于主存来说,称为缓冲区高速缓存。
3.2.14 多处理器
支持多处理器使得操作系统可以用多个CPU 实体来并行地执行工作。通常实现成为对称多处理结构(symmetric multiprocessing,SMP),对所有的CPU 都是平等对待的。这在技术上是很难实现的,因为并行运行的线程间访问与共享内存和CPU 会遇到不少问题。关于调度和线程同步的细节,可参考第6章,关于内存访问和架构的细节,参考第7章。
CPU 交叉调用
多处理器的系统,时常会出现CPU 需要协调的情况,如内存翻译条目的缓存一致性(通知其他CPU,如果缓存了这一条目,现在失效了)。CPU 可以通过CPU 交叉调用去请求其他CPU,或者所有CPU 去立即执行这类工作。交叉调用被设计成了能快速执行的处理器中断,以最小化对其他线程中断的影响。
抢占也可以使用交叉调用。
3.2.15 抢占
支持内核抢占让高优先级的用户级别的线程可以中断内核并执行。这让实时系统成为可能——这些系统有着严格的响应时间要求。支持抢占的内核称为完全可抢占的,虽然实际上还是会有少量的关键代码路径是不能中断的。
Linux 所支持的一种方法是自愿内核抢占,在内核代码中的逻辑停止点可以做检查并执行抢占。这就避免了完全抢占式内核的某些复杂性,对于常见工作负载提供低延时的抢占。
3.2.16 资源管理
操作系统会提供各种各样可配置的控制,用于精调系统资源,如CPU、内存、磁盘,以及网络等。这些资源控制,能用在跑不同应用程序的系统或者租户环境(云计算)上来管理性能。这类的控制可以对每个进程(或者进程组)设定固定的资源使用限制,或者采用更灵活的方法——允许剩余的资源用于共享。
UNIX 和BSD 的早期版本有基本的基于每个进程的资源控制,包括用nice(1)调整CPU优先级和用ulimit(1)对某些资源做限定。
基于Solaris 的系统从Solaris 9(2002)起就提供了先进的资源管理,其文档参见resources_controls(5)的Man 手册。
Linux 则是开发了控制组(control groups,cgroups)并将其整合进了2.6.24(2008年),还为此添加了各种控件,这些都记录在内核源码的Documentation/cgroups 中。
后续各章会在合适的时候讲述具体的资源控制。第11章就有一个关于管理OS 虚拟化租户性能的用例。
3.2.17 观测性
操作系统由内核、库和程序组成。这些程序包括观测系统活动和性能分析的工具,通常安装在/usr/bin 和/usr/sbin 目录下。用户也可以安装第三方工具到系统上以提供额外的观测。
观测工具,以及基于操作系统组件构建的观测工具会在下一章做介绍。