操作系统
虚拟化
以最基本的计算机资源CPU为例,
假设一个计算机只有一个CPU(尽管现代计算机一般拥有2个、4个或者更多CPU),虚拟化要做的就是将这个CPU虚拟成多个虚拟CPU并分给每一个进程【a】使用,而每一个进程只有用cpu的时候是醒着的,因此,每个应用都以为自己在独占CPU,但实际上只有一个CPU。这样操作系统就创造了美丽的假象——它虚拟化了CPU。(时分共享CPU技术)
要实现得好,需要机制【b】:上下文切换,调度策略【c】(历史信息,工作负载知识,性能指标)
在实现 CPU 虚拟化时,我们遵循的一般准则被称为受限直接访问(Limited Direct Execution,LDE)。LDE 背后的想法很简单:让程序运行的大部分指令直接访问硬件,只在一些关键点(如进程发起系统调用或发生时钟中断)由操作系统介入来确保“在正确时间,正确的地点,做正确的事”。为了实现高效的虚拟化,操作系统应该尽量让程序自己运行,同时通过在关键点的及时介入(interposing),来保持对硬件的控制。
动态重定位技术
基于硬件的地址转换:硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟(virtual)地址转换为数据实际存储的物理(physical)地址。,每个CPU需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,有时称为限制(limit)寄存器。这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。
物理地址=虚拟地址+基址
注:遗憾的是,这个简单的动态重定位技术有效率低下的问题。内存区域中可能有大量的空间被浪费。这种浪费通常称为内部碎片指的是已经分配的内存单元内部有未使用的空间(即碎片)。在我们当前的方式
中,即使有足够的物理内存容纳更多进程,但我们目前要求将地址空间放在固定大小的槽块中,因此会出现内部碎片①。所以,我们需要更复杂的机制,以便更好地利用物理内存,避免内部碎片。第一次尝试是将基址加界限的概念稍稍泛化,得到分段(segmentation)的概念,
分段
和页表的思想差不多,前几位定位段,后几位偏移量。
上面使用两位来区分段,但实际只有3个段(代码、堆、栈),因
此有一个段的地址空间被浪费。因此有些系统中会将堆和栈当作同一个段,因此只需要一位来做标识
还需要一位来判断增长方向,如果要有共享机制,那还需要权限保护位
但是出现问题:1.怎么切换不同进程上下文?2.许多空闲的大小不同的洞怎么分配给新的进程?
1.每个进程都有自己独立的虚拟地址空间,操作系统必须在进
程运行前,确保这些寄存器被正确地赋值。
2.是紧凑(compact)物理内存,重新安排原有的段。例如,操作
系统先终止运行的进程,将它们的数据复制到连续的内存区域中去,改变它们的段寄存器中的值,指向新的物理地址,从而得到了足够大的连续空闲空间。这样做,操作系统能让新的内存分配请求成功。但是,内存紧凑成本很高,因为拷贝段是内存密集型的,一般会占用大量的处理器时间。一种更简单的做法是利用空闲列表管理算法,试图保留大的内存块用于分配(梦回ads,ff,nf,bf)
虚拟化需要的硬件支持
【a】进程:一个正在运行的程序。进程可访问的内存,寄存器,特殊寄存器(PC指令指针,栈指针,帧指针)为重要的机器状态组成部分。
【b】许多操作系统中,一个通用的设计范式是将高级策略与其低级机制分开。你可以将机制看成为系统的“如何(how)”问题提供答案。例如,操作系统如何执行上下文切换?
- 对于停止的进程,寄存器上下文将保存其寄存器的内容。当一个进程停止时,它的寄存器将被保存到这个内存位置。通过恢复这些寄存器(将它们的值放回实际的物理寄存器中),操作系统可以恢复运行该进程。
【c】而策略为“哪个(which)”问题提供答案。例如,操作系统现在应该运行哪个进程?将两者分开可以轻松地改变策略,而不必重新
考虑机制,因此这是一种模块化(modularity)的形式,一种通用的软件设计原则。
操作系统所有接口
创建进程:
1.从磁盘中读取代码和静态数据储存到该进程的地址空间里。过去是尽早完成加载,现在是惰性执行(涉及分页和交换的存储机制)。2、再不断分配内存给栈堆,栈存放局部变量、函数参数和返回地址,堆用于显式请求的动态分配数据。3、其他初始化,输入输出(三个打开的文件描述符,用于输入输出和错误)4、启动程序,通过跳转到main例程,os将CPU控制权交给新创建的进程开始执行程序
注意:所有进程认为自己拥有最大位的地址空间,这是一种操作系统提供的抽象。进程不用关心实际位置有没有储存东西,操作系统会通过虚拟地址和物理地址的转换,页表调度交换来实现进程在任意地址空间的储存。
如何分配内存
1.在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间。由于我们假设每个进程的地址空间小于物理内存的大小,并且大小相同,这对操作系统来说很容易。它可以把整个物理内存看作一组槽块,标记了空闲或已用。当新进程创建时,操作系统检索这个数据结构(常被称为空闲列表,free list),为新地址空间找到位置,并将其
标记为已用。
2.栈内存,编译器隐式管理,存在于短暂的函数调用内
3.堆内存,程序员显式管理,存在于动态分配内存中,可能会导致很多操作错误,如:段错误,缓冲区溢出,忘记初始化,忘记释放,反复释放,无效释放内存。
1 | void func(){ |
但这些讨论的是库调用,建立在系统调用之上。
系统调用有brk,sbrk,mmap
销毁进程
等待进程停止运行
其他对进程的控制
获取进程状态
运行
就绪:为了跟踪每个进程的状态,操作系统可能会为所有就绪的进程保留某种进程列表(process list),储存了所有活动进程的进程控制块(PCB)
PCB:操作系统通过这个数据结构来追踪和管理进程的状态、资源使用情况等。在操作系统内核中维护。
阻塞:还必须以某种
方式跟踪被阻塞的进程。当I/O事件完成时,操作系统应确保唤醒正确的进程,让它准备好再次运行。
上下文切换
保存当前进程的上下文(寄存器、程序计数器等信息)。
更新进程的状态为适当的状态(就绪、阻塞、运行等)。
从就绪队列中选择下一个进程,并恢复该进程的上下文。
更新新进程的状态为“运行”,并将 CPU 分配给该进程。
Q:每一个进程的内存和寄存器位置难道是固定的吗,为什么可以被保存到PCB里?
A:进程的内存和寄存器位置在运行时并不是固定的,而是由操作系统动态分配和管理的。寄存器内容(如程序计数器、栈指针、寄存器值等)会保存到 PCB 中,因为它们是进程状态的一部分。也通过改变这些实现内存的动态分配分页和交换。进程内存内容(如堆、栈等)通常由内存管理单元或交换机制管理,并不会直接保存在 PCB 中。
当进程停止时(即没有运行),操作系统可以改变其地址空间的物理位置,这很容易。要移动进程的地址空间,操作系统首先让进程停止运行,然后将地址空间拷贝到新位置,最后更新保存的基址寄存器(在进程结构中),指向新位置。当该进程恢复执行时,它的(新)基址寄存器会被恢复,它再次开始运行,显然它的指令和数据都在新的内存位置了。
线程
经典观点是一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行点(多个程序计数器,每个都用于取指令和执行)。换一个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。
怎么实现:
对于进程,我们
将状态保存到进程控制块(Process Control Block,PCB)。现在,我们需要一个或多个线程
控制块(Thread Control Block,TCB),保存每个线程的状态。每个线程有自己的PC,但是,与进程相比,线程之
间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。
而且每个线程都有一个栈
问题
我们不仅要研究如何构建对同步原语的支持来支持原子性,
还要研究支持在多线程程序中常见的睡眠/唤醒交互的机制。
线程的创建
线程的创建和函数绑定实际上是多线程编程中一个常见的设计模式,它使得每个线程执行一个特定的任务(即函数)。这个绑定行为实际上是将 线程的生命周期与一个特定的任务(函数)相关联,从而实现并发执行。
线程启动后会执行这个函数。将函数绑定到线程上,线程的工作变得高度可控和可定制。你可以在创建线程时传递不同的任务(即不同的函数),从而轻松实现多任务并发。其他方式还有线程池,守护线程等。
如果要传入在函数中传入多值,需要打包成一个类型。传入单个参数不需要打包,也可以传入NULL。
1 | typedef struct myarg_t { |
线程函数 pthread_create 传递的是一个指向 void* 类型的参数,允许你传递任意类型的数据(通过转换)。当线程执行完成时,它可以返回一个指向 void 的指针(void*),这个指针可以指向任何地方——堆上的数据、静态数据,或者 NULL。
永远不要返回一个指针,并让它指向线程调用栈上分配的东西。
锁
除了线程创建和join之外,POSIX线程库提供的最有用的函数集,可能是通过锁(lock)
来提供互斥进入临界区的那些函数。
1 | pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; //初始化lock锁1 |
这段代码的注思是:如果在调用 pthread_mutex_lock()时没有其他线程持有锁,线程将
获取该锁并进入临界区。如果另一个线程确实持有该锁,那么尝试获取该锁的线程将不会
从该调用返回,直到获得该锁(注味着持有该锁的线程通过解锁调用释放该锁)。当然,在
给定的时间内,许多线程可能会卡住,在获取锁的函数内部等待。然而,只有获得锁的线
程才应该调用解锁。
条件变量
要使用条件变量,必须另外有一个与此条件相关的锁。在调用上述任何一个函数时,
应该持有这个锁。
1 | pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; |
在这段代码中,在初始化相关的锁和条件之后①,一个线程检查变量ready是否已经被设置
为零以外的值。如果没有,那么线程只是简单地调用等待函数以便休眠,直到其他线程唤醒它
while函数来等待唤醒是什么原理呢,没有解释
唤醒代码如下
1 | Pthread_mutex_lock(&lock); |
在发出信号时(以及修改全局变量ready 时),
我们始终确保持有锁。这确保我们不会在代码中注外引入竞态条件。
线程的运行
本章所有代码很容易运行。代码需要包括头文件pthread.h才能编译。链接时需要pthread
库,增加-pthread标记。
例如,要编译一个简单的多线程程序,只需像下面这样做:
1 | gcc -o main main.c -Wall -pthread |
只要main.c包含pthreads头文件,你就已经成功地编译了一个并发程序。
go
Goroutines 是 Go 语言中的轻量级并发执行单元。它们比操作系统线程更轻量,主要是因为它们与操作系统线程在底层的管理和调度方式上有很大的区别。以下是为什么 goroutines 比操作系统线程更轻量的几个关键原因:
- 用户级调度(User-level Scheduling)
操作系统线程:操作系统线程是由操作系统内核管理的。每个线程的创建和销毁通常都需要内核的干预和调度。当你创建一个操作系统线程时,它通常会消耗较多的资源,包括内存和时间。
Goroutines:Go 的 goroutines 由 Go 运行时(Go runtime)调度,而不是由操作系统调度。Go 运行时维护了自己的线程调度器,将多个 goroutines 映射到少量的操作系统线程上。这个调度器使用了一种叫做 M:N 调度(M 个 goroutines 对应 N 个操作系统线程)的方式。也就是说,Go 运行时在用户空间管理 goroutines,并且没有频繁地依赖内核来调度它们,从而避免了操作系统线程带来的开销。
- 轻量级的栈
操作系统线程:每个操作系统线程通常有一个固定大小的栈(比如 1MB 或更大)。这意味着每个线程的创建都会消耗较多的内存空间,尤其是当线程数目较多时,内存消耗会显著增加。
Goroutines:Goroutines 采用的是 动态栈,它们的初始栈大小非常小,通常只有 2KB 左右。随着 goroutine 执行的需要,Go 运行时会自动调整 goroutine 的栈大小,进行栈扩展。这种动态调整栈大小的方式可以显著减少内存消耗,尤其是在有大量并发任务时,可以高效地使用内存资源。
- 内存和资源管理
操作系统线程:每个操作系统线程不仅占用较大的内存空间,还需要内核进行额外的上下文切换(context switching)。上下文切换时,内核会保存当前线程的状态,恢复下一个线程的状态,这个过程本身就非常消耗资源。
Goroutines:Goroutines 是由 Go 运行时调度的,因此它们的上下文切换是由 Go 运行时在用户空间完成的,通常比操作系统线程的上下文切换更加高效。Go 运行时在一个操作系统线程上调度多个 goroutines,从而避免了频繁的内核上下文切换,减少了开销。
- 并发与并行
操作系统线程:操作系统线程通常对应物理 CPU 核心的数量,线程数的增加会受限于机器的硬件能力。当线程数非常多时,操作系统就必须管理大量的线程调度,导致性能下降。
Goroutines:Go 的 goroutines 可以在少数几个操作系统线程上调度和执行,这使得 Go 可以轻松管理数以万计的并发任务。Goroutines 之间的上下文切换是轻量的,因此即使有成千上万个 goroutines,Go 运行时仍然可以高效地管理它们,并且不会受到 CPU 核心数量的严格限制。
Go 调度器(Go Scheduler)
Go 的调度器是专门设计用来高效调度 goroutines 的。它采用 M:N 调度模型,即将 M 个 goroutines 映射到 N 个操作系统线程。这个调度器会动态地分配操作系统线程并将 goroutines 分配到这些线程上。这种灵活的调度策略减少了操作系统线程管理带来的开销。无阻塞的 I/O 操作
操作系统线程:操作系统线程进行 I/O 操作时通常会阻塞,直到操作完成,这会浪费 CPU 资源。
Goroutines:Go 通过 协作式调度 和 非阻塞 I/O 操作,使得 goroutines 在等待 I/O 时不会阻塞线程。Go 的调度器会将其他 goroutines 重新调度到可用的操作系统线程上,确保 CPU 可以用来执行其他任务,从而避免了因阻塞而浪费的 CPU 资源。
总结:
Goroutines 是比操作系统线程更轻量的执行单元,主要得益于以下几点:
用户级调度:Go 运行时负责调度,避免了内核的干预。
动态栈:goroutines 起始栈小且可以动态调整,节省内存。
高效的上下文切换:由 Go 运行时进行上下文切换,避免了操作系统级的上下文切换开销。
灵活的调度策略:M:N 调度模式使得 Go 能在有限数量的操作系统线程上调度成千上万个 goroutines。