操作系统基础-CPU虚拟化

Posted 云服务与SRE架构师社区

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了操作系统基础-CPU虚拟化相关的知识,希望对你有一定的参考价值。

前言

最近在学习威斯康星大学的CS-537课程:操作系统导论[1],笔者计划用五篇文章来结束这个课程,目的包括:

  1. 作为过去几个月学习经历的回顾和总结
  2. 参考陈皓的 《TCP/IP那些事》 [2],尝试用尽可能简洁的篇幅来描述操作系统的基础
  3. 最后,可以作为学习Linux Kernel的一个辅助材料

这门课的教材是本课程的教授Remzi夫妇合著的,名为《Operating Systems: Three Easy Pieces》[3],中译本就叫《操作系统导论》。

操作系统的三个要素

操作系统的定位是计算机资源(CPU,内存,硬盘,各种I/O设备等)的管理者。最早的计算机系统一次只运行一个程序,操作系统是作为库函数的形式存在的,这种模式无法充分的利用计算机资源,对于早期造价动辄数百万美元的计算机来说,这是巨大的浪费,因此人们引入了现代的操作系统来支持方便的多进程并发执行,允许多个用户同时运行他们的程序。具体来说,操作系统提供了这么三个要素:

  1. 虚拟化(Virtualization),主要指的是CPU和内存虚拟化,仿佛每个进程都有自己独占的CPU和内存。
  2. 并发(Concurrency),主要指的是线程级的并发。
  3. 持久化(Persistance),主要指的是文件系统。

存储器层次结构

为什么说单个程序不能充分利用计算机资源呢?这跟计算机的存储器层次结构有关,计算机中有各种各样的存储器:CPU上的寄存器、一二级缓存,内存、硬盘……这些存储器的容量、性能和成本各不相同,一个典型的存储器层次结构如下:

越是靠近上层(CPU)存储器的性能越好,但是容量越小,(每字节)存储成本越高;越是远离CPU,存储器的性能越差,但是容量越大,(每字节)存储成本越低。比如,CPU访问一级缓存缓存只需要1个时钟周期,而进行磁盘I/O可能需要上千万个时钟周期。程序在进行I/O操作的时候,CPU实际是空闲的,这时候可以让CPU运行其他程序,提供计算机资源的利用率。

操作系统基础-CPU虚拟化

另一方面,为了弥补高速CPU到低速I/O设备之间的差距,在存储器之间引入了多层的缓存,比如本地硬盘作为网络的缓存,内存(DRAM)作为硬盘的缓存,SRAM作为内存的缓存。由于局部性原理的存在,这个存储器层次结构通常工作得很好。所谓得局部性原理包含两项:

  • 一是时间局部性,程序一旦引用过某个存储器位置,接下来它很可能还会引用这个位置;
  • 二是空间局部性,程序一旦引用过某个存储器位置,接下来它很可能还会引用附近的位置。

进程

操作系统提供了进程这个抽象概念,一个进程就是一个正在运行的程序。根据Steam 2020年5月的调查,现在主流的PC配置是64位的4核物理CPU和16G内存[1],而目前x86_64的PC上通常会运行几十上百个进程,每个进程拥有256TB的的虚拟内存。正是通过CPU和内存虚拟化,操作系统提供了这种幻象:似乎每一个进程都有一个独占的CPU和一片巨大的独占内存。

在深入这些细节以前,我们先来看看计算机上运行一个进程需要维护些什么状态信息:

  1. 用来存取指令和数据的内存,由于进程会根据地址来读写内存,它们也叫做内存地址空间,当然这里指的虚拟内存地址空间(Virtual Memory Address Space)。进程的堆栈信息也在这个地址空间中。
  2. CPU中的通用寄存器,如%rax
  3. CPU中的特殊寄存器,如:
    1. 程序计数器(Program Counter/PC),或叫做指令指针(Instruction Pointer/IP)
    2. 栈指针(stack pointer)及其对应的基址指针(frame pointer)
  4. I/O相关的信息,比如当前打开的文件,Socket套接字等。

操作系统通过分时复用的方式实现了CPU的虚拟化,运行进程A一段时间后,主动或被动地把这个进程的状态信息写入物理内存然后从物理内存中读取另一个进程B的状态信息,从而恢复进程B的运行。

进程在其生命周期中,始终处于以下三个状态中的一个:

  • Running:进程正在通过CPU执行指令
  • Ready:进程可以运行,但是操作系统还没有调度它
  • Blocked:进程在等待某个事件发生(比如等待磁盘读取完成),因此还不能运行

这是一个理想化的状态,Linux中进程还有一些别的状态

内核中有一个数据结构叫做Process Control Block(PCB),用来记录上面提到的各种信息,每个进程都有一个对应的PCB。

CPU虚拟化

下面来考虑实现CPU虚拟化要解决的两个核心问题:

  1. 安全:用户的进程不应该拥有无限制的权限,比如它不应该能访问另一个用户的文件,而权限检查的把关就需要由操作系统来实现。
  2. 性能:操作系统提供CPU虚拟化这种抽象机制的时候,不应该有太大的性能损失

计算机系统采用了一种叫Limited Direct Execution的机制,通过硬件和操作系统的协作解决了这两个问题。在具体实现上,CPU中有一个状态位,表明了当前运行在什么模式下:

  • 用户的进程运行在 用户模式下,这种模式能做的操作有限,比如它无法发起一个I/O请求,尝试这么做会引发一个异常(exception),导致进程被操作系统杀死。
  • 与之相对的是,内核代码运行在 内核模式下,它能执行所有特权操作,比如发起I/O请求。

如果用户进程需要发起特权操作,必须通过操作系统内核来进行,操作系统提供了很多这样的服务入口,这就是系统调用,比如说打开一个文件用到的open()系统调用。这些系统调用看起来像是一个普通的函数,而内部实现上只是把系统调用的编号,和对应的参数放到栈上某个特定的位置,然后调用trap指令,这个指令会完成以下几个操作:

  1. 把当前进程的CPU寄存器的值保存到内核栈中
  2. 把运行模式切换为内核模式
  3. 跳转到该系统调用的处理函数

内核检查参数和权限和合法性,然后执行相应的处理,无论结果如何,最终调用return-from-trap指令返回用户进程,具体过程如下:

  1. 从内核栈中还原该进程的CPU寄存器值
  2. 把运行模式切换为用户模式
  3. 把程序计数器(PC)设置为进程的下一条指令,从而恢复用户进程的运行。

可以看到,用户进程直接运行在CPU上, 因此保证了性能,而通过内核模式和用户模式的区分保证了安全,这里主要的损耗在于上下文切换带来的开销。

内核调用return-from-trap之前还会检查进程是否有待处理的信号,如果有的话在这里触发信号处理函数。

抢占式调度

Limited Direct Execution 存在一个问题,一个进程可能会长久地占用CPU,导致其他进程无法得到服务,那么这个进程什么时候把控制权还给操作系统,让操作系统调度其他进程呢?很自然地,一个合理的时间点是触发系统调用的时候,操作系统可能会决定先执行另一个进程。但如果是一个无限循环,中间没有任何系统调用呢?一些早期的系统如Mac OS采用了合作式的调度方案,长期运行的进程需要周期性地让出CPU,比如在循环体中加入一个yield()之类的系统调用,允许操作系统调度其他进程。这个方案治标不治本,存在这些场景:

  1. 某些恶意程序希望独占CPU资源,不按照要求来做
  2. 程序bug导致 yield()一直没有运行

这种情况下,唯一能打破这种循环的方法只有重启。要解决这个问题,操作系统仍然需要硬件的协助。硬件中有个计时器可以编程为每隔一定的时间(比如每十毫秒)就发起一个时钟中断,它会挂起当前运行的进程,跳转到操作系统预先设置的中断处理函数中。在这里,操作系统可以决定是继续运行这个进程,或是调度别的进程。这就是抢占式调度。

异常处理流

程序运行的过程中会遇到各种各样的异常情况,在计算机启动的时候,操作系统就需要为各种异常指定对应的处理函数。CPU在执行完一条指令之后,总是会检查是否存在异常,如果有则触发对应的异常处理函数,否则继续执行下一条指令。

《CS:APP》中把异常分为四类:

类别 原因 异步/同步 返回行为 例子
中断(intterrupt) 来自I/O设备的信号 异步 总是返回到下一条指令 时钟中断
陷阱(trap) 有意的异常 同步 总是返回到下一条指令 系统调用
故障(fault) 潜在可恢复的错误 同步 可能返回当前指令 缺页异常
终止(abort) 不可恢复的错误 同步 不会返回 硬件错误

其中异步和同步的区别是:异步中断是由CPU外部的设备产生的,而同步异常是执行某条指令产生的结果,比如除零错误。

Direct Limited Execution

现在可以完整的描述这个协议了

OS启动(内核模式) 硬件
初始化异常处理函数


记下异常处理函数的内存地址
启动中断计时器


启动计时器,每隔一段时间中断CPU



OS运行(内核模式) 硬件 进程(用户模式)


进程A正在运行

时钟中断

1. 把进程A的寄存器保存到A的内核栈中

2. 切换到内核模式

3. 跳转到trap处理函数(系统调用)
处理这个系统调用

调用stwich()切换进程

1. 把进程A的内核寄存器保存到A的PCB中

2. 从进程B的PCB中还原B的内核寄存器

3. 切换到进程B的内核栈

调用return-from-trap,返回到B中


1. 从进程B的内核栈中还原B的寄存器

2. 切换到用户模式

3. 跳转到进程B的程序计时器(Program Counter)


进程B运行

注意:

  • 上文中有两组寄存器的保存/还原操作,第一组是用户态的寄存器,第二组是内核态的寄存器
  • 内核处理完系统的调用后,也可以选择不切换进程,直接调用return-from-trap返回进程A

进程调度策略

上面描述了进程切换的机制,接下来讨论进程调度的策略,也就是说每次操作系统要调度一个进程的时候,选择运行哪一个进程。通常来说,我们有两种类型的工作负载:

  • 交互式的进程,这种进程大部分时候都在Blocked的状态等待I/O,不怎么占用CPU,但是需要得到高优先级的处理,比如shell里面用户每输入一个字符,总是希望操作系统尽快响应并把这个字符显示到屏幕上。
  • 非交互式的进程,他们大部分时间都在使用CPU执行指令,处于Running的状态,这种进程的诉求是高吞吐量,尽可能减少进程切换带来的开销。

下面来看看两种常见的调度策略

多级反馈队列

多级反馈队列(Multi-Level Feedback Queue)致力于提高系统的整体响应时间。

操作系统中维护多个进程队列,从高到底依次为每个队列分配不同的优先级:高优先级的进程分配较短的时间片,保证快速响应;低优先级的进程分配较长的时间片,保证其高吞吐量。具体调度策略如下:

  1. 如果A的优先级大于B,运行A
  2. 如果A的优先级等于B,轮流运行A和B

然而我们不知道每个进程的工作模式是交互式的还是非交互式,因此先假设他们都是需要快速响应的交互式进程:

  1. 当进程启动的时候,把它放到最高优先级的队列中
  2. 当一个进程用完它的时间片之后,降低它的优先级,也就是移到下一个队列中

还存在一个问题,如果有大量高优先级任务,那么低优先的任务可能会被饿死,因此:

  1. 每隔一段时间把所有的进程都移动到最高优先级的队列

按比例共享调度

与MLFQ相对的,按比例共享调度(Proportional Share Scheduling)的目标是让各个进程公平地获取CPU时间。它最简单的形式叫做彩票调度(lottery scheduling):假设系统使用100张彩票(编号为0-99),每次随机选择一张来决定运行哪个进程,进程A持有75张(编号为0-74),进程B持有剩余的25张(编号75-99)。任务调度器每次计算出一个0-99之间的随机值,如果落在0-74之间则运行进程A,反之运行进程B,这样保证了两个进程总体获得的CPU时间跟它们持有的票据数量一致(75%:25%)。

最后还有个问题,怎么为进程分配票据(或者说权重),可以跟nice值关联起来。

Linux的进程调度器

Linux当前采用的进程调度器叫做完全公平调度器(Completely Fair Scheduler/CFS),内部采用红黑树,实现了跟按比例共享调度类似的目标。之前采用的进程调度器为O(1),其实现类似前面说的多级反馈队列。

关于作者

不怎么务正业的程序员,BUG制造者、CPU0杀手。从事过开发、运维、SRE、技术支持等多个岗位。原Oracle系统架构和性能服务团队成员,目前在腾讯从事运营系统开发。

参考资料

[1]

CS-537课程:操作系统导论: http://pages.cs.wisc.edu/~remzi/Classes/537/Spring2018/

[2]

《TCP/IP那些事》: https://coolshell.cn/articles/11564.html

[3]

《Operating Systems: Three Easy Pieces》: http://pages.cs.wisc.edu/~remzi/OSTEP/


以上是关于操作系统基础-CPU虚拟化的主要内容,如果未能解决你的问题,请参考以下文章

虚拟化技术

虚拟化技术漫谈

介绍一下什么是“虚拟化”

虚拟化技术简介(转载学习)

kvm虚拟化

kvm虚拟化