CS162操作系统课程第二课-4个核心OS概念
熟肉视频地址:
我们讨论了操作系统如何扮演裁判,魔术师和粘合剂的角色,裁判是指对于资源保护的管理;魔术师是指我们要让它看起来像我们有一套非常干净易用的资源的抽象,而不是使用实际的没有统一接口的物理资源。粘合剂是一组通用服务,它们使在操作系统上编写程序变得更容易,例如文件系统服务、网络服务等等。
今天我们要讲操作系统四个基本概念:
我们将从什么是线程(Thread)开始讨论,线程会完整地描述一个程序状态或者一个线性执行上下文,它会有一个程序计数器,寄存器,执行标志,线程栈等等。然后是地址空间(Address Space),这是程序可访问的一组内存地址。然后我们会介绍什么是进程(Process),进程包括地址空间以及一个或者多个线程。最后,我们将讨论在这门课的前期特别重要的硬件机制也就是双模式操作(dual mode operation),即一个典型的处理器至少有两种不同的模式,我们可以松散地称之为内核模式(Kernel mode)和用户模式(User mode)。
我们要学习如何编写和编译程序成为可执行文件,然后从文件系统中取出这些可执行文件后,建立运行的进程,文件被加载到内存中,我们会详细讨论那个进程运行需要提供的栈和堆。然后是转移控制(Transfer Control),意思是处理器的程序计数器将会指向该进程的用户代码中的指令,然后程序就会开始执行。操作系统需要给这些进程提供各种系统服务,同时操作系统需要保护操作系统和进程不受其他进程和其他用户的影响。
我们来回忆下 CS61B + CS61C 课程的内容: 处理器一开始有一个程序计数器(PC,Program Counter),还有一个可以读取的内存(Memory),内存里面有一组指令(Instruction)。程序计数器会指向内存中的指令,让处理器读取下一条指令。我们把指令从内存中拉出来,解码,然后把它输入到执行流水线中。我们在 CS61C 中经常谈论的是一种风险类型的处理器(risk style processor),有五个执行流水线。解码后,它们会输入一组寄存器和 ALU 来进行实际操作,然后递增程序计数器并继续下一条指令。
我们的第一个操作系统概念是一个控制线程(Thread of Control),线程实际上是一个唯一的执行上下文,它有程序计数器,寄存器,执行标志,线程栈,内存状态。线程就像上一张幻灯片中你看到的处理器的虚拟版本。一个线程正在处理器或核心上执行,顺便说一下,我现在混用处理器和核心这两个概念,后面我们就能更清楚地理解这两个概念的区别。但它是在处理器寄存器中常驻(Resident)时才会执行的。寄存器中有线程的上下文(Context)或根状态(Root State),有些东西在寄存器中,剩下的在内存中:
- 包括一个程序计数器,当前正在执行的指令,程序计数器指向内存中的下一条指令,所有的指令都存储在内存中
- 包括用于进行计算的中间值
- 有一个栈指针,它有一个指向内存中栈的顶部的指针,线程的其余部分都在内存中。
当线程的状态不在寄存器中即不是常驻的时候,线程就被挂起(suspended)或不再执行。被挂起的线程实际上是在内存中,还没有执行或者根本没有执行,有其他线程正在执行。
这是另一个关于执行过程的视图(冯·诺依曼体系)
这是一组地址,我们稍后会把它叫做地址空间,从 0 到 2^32 -1。里面有一组将要执行的指令,粉色的是你的处理器。进程有一个与之相关的保护状态,处理器包括寄存器和流水线的集合,我们的执行序列(Execution Sequence)包括:在程序计数器上获取一条指令,解码它,执行它,写回寄存器,获取下一条指令,并重复这个执行循环。
那么我们是如何产生多处理器的错觉的呢?我们上次讲过在你的笔记本电脑上做一个ps -aux或者其他的操作唤起任务管理器,如果你仔细看,你会发现有数百个进程在运行,大部分在休眠状态。但它们都可以在你当前的处理器上使用,那么它是如何工作的呢?
让我们假设只有一个物理处理器上只有一个核,在任何给定的时间在硬件上只有一个执行线程。但现在我们想要的是有多个cpu,或者说是多个cpu同时运行的假象,所以我们可以有多个线程同时运行。从程序员的视图来看是,我有一堆东西都在运行,它们都共享内存。
每个线程是一个虚拟核心,我们这里有三个线程,品红色线程,青色线程以及黄色线程,造成他们同时运行的错觉的方式是通过我们多重复用硬件,我们运行一段时间的品红色线程,然后运行一会青色线程,然后运行一会黄色线程,以此循环。
线程的内容包括什么?显然,每个线程都需要一个程序计数器和一个堆指针,还有所有寄存器状态。如果线程在运行中,寄存器状态就在处理器的寄存器中;如果不在运行中,这些状态就在内存中,被称为线程控制块(TCB,Thread Control Block)
我们举一个例子:
在 T1 的时候 vCPU1 在运行,在 T2 的时候 vCPU2 在运行,在 T1 和 T2 时,发生了上下文切换(Context Switch),在底层是一个事件,我们把所有的程序计数器,栈指针,所有其他寄存器都保存在内存中的对应线程控制块中。我们从 vcpu2 加载程序计数器、堆栈指针等,然后运行 vcpu2。
一些 Q&A:
- 每个线程是否都有自己独占的 CPU 缓存?答案是否定的,一般每个核有一个高速缓存,线程都共享同一个缓存,你可以想到如果切换太快,就没有线程能利用好缓存了。原始处理器中的缓存或 TLB 必须在切换时被刷新,更高级的处理器则不会。缓存本身通常在物理空间中,你从一个线程切换到另一个,你只是改变了页表,不需要清空缓存。
- 线程上下文切换这需要多长时间?这大概需要几微秒的时间,需要确保转换的时间不要太长,不要把大部分时间都花在转换上。
然后是什么触发了上下文切换?全局倒计时器时间到了,或自愿让出CPU等这样的事情触发的。比如某个线程要做一些 I/O,那么操作系统就会调度其他线程。
我们可能有一堆内存(蓝色的代表内存),我们可以想象这些虚拟进程中的每一线程都有自己的栈、堆、数据和代码,它们都以某种方式分布在内存中,我们要做的就是以某种方式记录所有东西的位置。线程控制块是所有东西的所在,当我们从绿色切换到黄色,我们要做的第一件事是将所有的绿色线程的寄存器保存到它的线程控制块中,顺便说一下,那是内核内存的一部分。
我们今天要讲的第二个操作系统概念是地址空间:
它是一组可访问地址和与之相关的状态,这只是处理器对可用地址的视图。对于 32 位处理器,可以访问的地址空间是 2 的 32 次方大约是 40 亿,对于 64 位的处理器,2 的 64 次方是 18 万亿。但是这并不是说所有的空间里都有对应的物理 DRAM 内存可以使用,只有一部分有对应的物理 DRAM。
当你读或写一个地址空间中的地址时,也许它像普通内存一样运行,或者完全忽略写入操作;或者可能是系统导致一个I/O操作发生,这被称为内存映射 I/O;或者它可能会导致异常,如果你试图在我这里展示的栈和堆之间的某个地方读或写是可以的,但是如果没有物理内存分配给这个进程,就会出现页面错误(Page fault);或者写入内存的行为是为了与另一个程序通信。
- 程序计数器(PC,Program Counter)指向某个地址,意味着处理器能执行在那个地址的指令。
- 栈指针(SP,Stack Pointer)指向某个地址,通常是栈的底部(图里面栈向下增长,所以最后一个元素是栈底)
- 栈(stack):栈就是当你递归调用一个函数时,前一个函数的变量会被推到栈上,然后栈指针向下移动,当函数返回时,你把它们从栈中取出,栈指针向上移动。
- 堆(heap):你用 malloc 分配东西等的时候,通常就会放在堆上。堆的初始物理内存也比程序最终需要的要少,随着程序的增长,这会在堆上分配东西,你会遇到页面错误,然后分配实际的物理内存。
- 代码段(Code Segment):保存着要执行的代码。
- 静态数据(Static Data):静态变量,全局变量等等
操作系统必须保护自己不受用户程序的影响,这样做有很多原因:
- 可靠性:危及操作系统通常会导致系统崩溃
- 安全性:你想要限制恶意软件的作用范围
- 隐私:限制每个线程访问它应该访问的数据,不希望我的密码或者秘密被泄露
- 公平性:我不希望这样一个线程,例如它计算 PI 的最后一位,突然就能占用所有的cpu,以牺牲其他所有的线程为代价。
操作系统必须保护用户程序之间的安全,防止一个用户拥有的线程影响另一个用户拥有的线程。那么硬件能做些什么来帮助操作系统保护自己免受程序的侵害呢,这里有一个非常简单的想法,事实上非常简单,以至于微小的物联网设备可以用很少的晶体管做到这一点,这个概念我称之为基础和界限(B&B,base and bound):
我们要做的是我们有两个寄存器,一个base寄存器和一个bound寄存器,这两个寄存器记录黄色线程允许访问内存的哪一部分。程序运行的时候,磁盘上的文件被加载并移动到内存的这一部分。所以现在当程序开始执行的时候,它与程序计数器一起工作,比如在1010范围内,这就是代码所在的地方。硬件会做一个快速比较看看这个程序的计数器是否大于 base,以及它是否小于 bound。
这种方式实现很简单,但是访问里面的每一块内容,都要记录一个长地址。
但是对于这个进程分配的内存,在这个模型下是很难改变的。因为有多个进程在共享这个内存,所以当你想扩展内存的时候,可能需要将当前黄色的部分复制到内存中其他剩余空间更大的一块地方。
为了优化这些问题,我们一般不直接访问物理内存,而是加上地址空间翻译机制(Address Space Translation):
其中一种翻译设计是加入一个硬件加法器:
地址实际上是在动态转换的,所有的地址其实是一个偏移量,操作系统记录每个进程的内存基址(Base Address),之后加上这个偏移量就是实际的物理内存地址。
另一种方法是利用分段,在x86硬件中,我们有代码段,堆栈段等等各种段,每段有不同的基址与长度,即不同的 base 和 bound,即硬件寄存器,有 base 和 bound 硬编码在该段。代码段是有物理起点(即 base)和长度(即 bound)的,而实际运行的指令指针是段内的偏移量。
最后一种是我们实际中更常用的,我们要做的是,我们要把地址空间,也就是所有的DRAM,分成一堆大小相等的页(Page)。硬件会使用页表(Page Table)进行从虚拟内存地址到硬件 DRAM 内存地址的转换。
我们今天要讲的第三个操作系统概念是进程:
进程实际上是一个权利受限的执行环境,我们讲过,简单的虚拟线程有这样的问题,每个线程都能访问每个线程的内存,内存翻译机制可以保护我们可以访问的内存,即一个受保护的内存块。它被操作系统中的一个实体独占,这个实体叫做进程。它包括一个受限的地址空间和一个或多个线程,它拥有一些文件描述符和文件系统上下文。
进程提供了内存保护抽象,在保护和效率之间有一个基本的权衡,如果你在同一个进程中有一堆线程,它们之间可以很容易地通信,因为它们共享相同的内存,它们可以通过一个写入内存,另一个读取内存来通信,但是它们之间可能会互相覆盖导致并发安全问题。有时候你想要高性能,提高并行性,你会想要在一个进程中有很多线程。但是当你想要保护时,你想要限制进程之间的通信,所以进程之间的通信故意变得更加困难,这就是我们得到保护的方式。
这是一个单线程的进程还有一个多线程的进程。对于单线程的进程,只有一组寄存器还有栈内存。对于多线程的进程,代码段,数据,文件是共享的,但是每个线程有独立的寄存器还有栈。当我们从一个线程切换到另一个线程时,为了给人一种多处理的错觉,我们需要从第一个线程切换出寄存器,这样我们就能从第二个线程把它们加载回来。
线程封装了并发性,为什么进程要用多线程?
- 一个是并行性(Parallelism)。如果你有多个核,通过在同一个进程中有多个线程,可以让许多任务同时处理。
- 另一个是为了并发(Concurrency)。并发性就是大多数线程大部分时间都在休眠的情况,比如某个线程需要做一些 I/O,开始 I/O 时进入睡眠,然后在 I/O 完成时醒来,那么 CPU 不必等待 I/O 完成,而是可以做其他的事情。这就是多线程的好处。
那么为什么我们需要进程来保证可靠性、安全性和隐私性呢?
- 对于可靠性:Bug 只会覆盖一个进程的内存,恶意的或者被破坏的进程不能干扰其他进程。
- 对于安全还有隐私性:进程不能修改其他进程的内存
- 公平性:共享磁盘,CPU 等资源。
这个保护主要通过翻译机制实现的,每个进程地址空间通过翻译机制映射到物理内存,这个翻译是进程本身不可控的。那么操作系统是如何保证进程无法修改页表从而影响翻译呢?这就引入了下一个话题:双模式操作(Dual-mode Operation)
硬件至少提供了两种模式:内核模式(Kernel mode,或管理模式 Supervisor mode)和用户模式(User mode)。当你在用户模式下运行时某些操作会被禁止,比如当你在用户模式时你不能改变你使用的页表,这只有在内核模式下的操作系统才能做到。在用户模式下还不能禁止中断,这样,一个如果想计算PI最后一位的进程就不能阻止其他进程在计时器结束时获得CPU时间。在用户模式下你也被阻止直接与硬件交互等等,因此不能破坏磁盘上的文件。
我们小心控制的用户模式和内核模式之间的转换的是什么?包括系统调用(System call),中断,异常等等。如上图中的流程所示,我们有用户进程,它们对内核进行系统调用,从用户模式切换到内核模式执行系统调用中对应的操作,完成后退出内核模式,系统调用返回。
这是一个典型的Unix系统结构中各个模式分别包含什么的表格:
- 用户模式包含你所有的程序和库等等。
- 系统调用:表示可以安全访问各种资源的代码。
- 内核模式包含:信号处理,I/O 系统,文件系统,块交换 I/O 系统,磁盘驱动,CPU 调度,页交换,虚拟内存管理等等。内核通过接口访问并控制硬件。
举个例子,我们有硬件有内核模式和用户模式。硬件可能会 exec 创建一个新进程。用户模式下的系统调用会进入内核模式,执行完操作后返回用户模式。中断可能导致用户模式进入内核,然后可能检查硬件比如 I/O 就绪,最终从中断中返回。在你除以零或者发生一个页面错误,会发生一个异常,导致进入内核模式,然后最终返回。
总共有三种可能触发用户模式到内核模式转换的操作类型:
系统调用:
- 调用一个系统服务,例如 exit 退出进程
- 函数调用,但是涉及访问进程外的一些资源
- 目前没有系统函数需要的内存地址
- RPC 远程函数调用
- Marshall 寄存器中的系统调用id和参数并执行系统调用
中断:
- 外部异步事件触发上下文切换,例如,定时器,I/O设备
Trap 或者异常:
- 内部同步事件触发上下文切换
- 例如,违反保护(segmentation fault),除以零,…
如果你注意到这里有两个进程,一个绿色的,一个黄色的。灰色的代表的是操作系统的内存。
微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer:
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:
哈希的技术博客
Mycat和java jfr unit的核心贡献者,贡献过OpenJDK,Spring Cloud. Apache Bookkeeper. Apache RocketMQ...
JVM 输出 GC 日志导致 JVM 卡住,我 TM 人傻了
hashcon赞 1阅读 503
Laravel入门及实践,快速上手ThinkSNS+二次开发
ThinkSNS赞 1阅读 2.4k
confluence7.2.1的部署与迁移---呕心沥血版
暗涌阅读 7.8k
C++编译器和链接器的完全指南
小万哥赞 2阅读 992
在Linux上查看活跃线程数与连接数
扣钉日记赞 3阅读 1.4k
Ubuntu 20.04 读写 Windows 10 共享目录(qbit)
qbit赞 1阅读 6.3k
Ubuntu 20.04 搭建 Elasticsearch 7.x 小集群(qbit)
qbit阅读 4.1k
Mycat和java jfr unit的核心贡献者,贡献过OpenJDK,Spring Cloud. Apache Bookkeeper. Apache RocketMQ...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。