操作系统
操作系统
它取得 CPU、内存或磁盘等物理资源(resources),甚对它们进行虚拟化(virtualize)。它处理与甚发(concurrency)有关的麻烦且棘手的问题。它持久地(persistently)存储文件,从而使它们长期随全。
抽象
高性能:操作系统通过时间片轮转、优先级调度等机制,在宏观上实现了多个进程对CPU的分时共享
保护
虚拟化
操作系统将物理(physical)资源(如处理器、内存或磁盘)转换为更通用、更强大且更易于使用的虚拟形式。虚拟化让许多程序运行(从而共享CPU),让许多程序可以同时访问自己的指令和数据(从而共享内存),让许多程序访问设备(从而共享磁盘等),所以操作系统有时被称为资源操理器(resource manager)。每个 CPU、内存和磁盘都是系统的资源(resource),
因此操作系统扮演的主要角色就是操理(manage)这些资源,以做到高效或公平
以最基本的计算机资源CPU为例,
假设一个计算机只有一个CPU(尽管现代计算机一般拥有2个、4个或者更多CPU),虚拟化要做的就是将这个CPU虚拟成多个虚拟CPU并分给每一个进程【a】使用,而每一个进程(有自己的pid)只有用cpu的时候是醒着的,因此,每个应用都以为自己在独占CPU,但实际上只有一个CPU。这样操作系统就创造了美丽的假象——它虚拟化了CPU。(时分共享CPU技术)
要实现得好,需要机制【b】:上下文切换,调度策略【c】(历史信息,工作负载知识,性能指标)
在实现 CPU 虚拟化时,我们遵循的一般准则被称为受限直接访问(Limited Direct Execution,LDE)。LDE 背后的想法很简单:让程序运行的大部分指令直接访问硬件,只在一些关键点(如进程发起系统调用或发生时钟中断)由操作系统介入来确保“在正确时间,正确的地点,做正确的事”。为了实现高效的虚拟化,操作系统应该尽量让程序自己运行,同时通过在关键点的及时介入(interposing),来保持对硬件的控制。
【a】进程:一个正在运行的程序。进程可访问的内存,寄存器,特殊寄存器(PC指令指针,栈指针,帧指针)为重要的机器状态组成部分。
【b】许多操作系统中,一个通用的设计范式是将高级策略与其低级机制分开。你可以将机制看成为系统的“如何(how)”问题提供答案。例如,操作系统如何执行上下文切换?
- 对于停止的进程,寄存器上下文将保存其寄存器的内容。当一个进程停止时,它的寄存器将被保存到这个内存位置。通过恢复这些寄存器(将它们的值放回实际的物理寄存器中),操作系统可以恢复运行该进程。
【c】而策略为“哪个(which)”问题提供答案。例如,操作系统现在应该运行哪个进程?将两者分开可以轻松地改变策略,而不必重新
考虑机制,因此这是一种模块化(modularity)的形式,一种通用的软件设计原则。
进程
操作系统为正在运行的程序提供的抽象,就是进程。
进程是操作系统进行资源分配和调度的基本单位,是程序的一次执行实例
进程的构成:进程可访问的内存,寄存器,特殊寄存器(PC指令指针,栈指针管理函数参数栈、局部变量,帧指针管理返回地址)为重要的机器状态组成部分。
线程:引入线程的目的,除了提高并发率和共享内存外,还降低了上下文切换的开销。线程是最小单位
系统调用
操作系统还提供了一些接口(API),让应用程序调用。由于操作系统提供这些调用来运行程序、访问内存和设备,甚进行其他相关操作,我们有时也会说操作系统为应用程序提供了一个标准库(standard library)。
操作系统为了实际写入磁盘而做了什么:
首先确定新数据将驻留在磁盘上的哪个位置,然后在文件系统所维护的各种结构中对其进行记录。这样做需要向底层存储设备发出I/O请求,以读取现有结构或更新(写入)它们。
调度器
RUNNING:
表示进程正在使用 CPU 执行指令。
由调度器将进程分配到 CPU 时进入此状态。
READY:
表示进程可以运行,但当前没有被分配到 CPU(因为 CPU 被其他进程占用)。
进程从 BLOCKED 状态恢复后,或者新创建的进程会进入此状态,等待调度器分配 CPU。
BLOCKED:
表示进程正在等待某些 I/O 操作完成(例如磁盘读写)。
当进程发出 I/O 请求时,操作系统会将其从 RUNNING 状态切换到 BLOCKED。
DONE:
表示进程已经完成了所有指令的执行。
当进程执行完所有任务后,操作系统会将其状态标记为 DONE,并释放相关资源
从 READY 到 RUNNING:
调度器根据调度算法(如先来先服务 FCFS、时间片轮转 Round Robin 等)选择一个 READY 队列中的进程,将其分配到 CPU。
进程被分配到 CPU 后,状态从 READY 转换为 RUNNING。
动态重定位技术
基于硬件的地址转换:硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟(virtual)地址转换为数据实际存储的物理(physical)地址。,每个CPU需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,有时称为限制(limit)寄存器。这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。
物理地址=虚拟地址+基址
注:遗憾的是,这个简单的动态重定位技术有效率低下的问题。内存区域中可能有大量的空间被浪费。这种浪费通常称为内部碎片指的是已经分配的内存单元内部有未使用的空间(即碎片)。在我们当前的方式
中,即使有足够的物理内存容纳更多进程,但我们目前要求将地址空间放在固定大小的槽块中,因此会出现内部碎片①。所以,我们需要更复杂的机制,以便更好地利用物理内存,避免内部碎片。第一次尝试是将基址加界限的概念稍稍泛化,得到分段(segmentation)的概念,
分段
和页表的思想差不多,前几位定位段,后几位偏移量。
上面使用两位来区分段,但实际只有3个段(代码、堆、栈),因
此有一个段的地址空间被浪费。因此有些系统中会将堆和栈当作同一个段,因此只需要一位来做标识
还需要一位来判断增长方向,如果要有共享机制,那还需要权限保护位
但是出现问题:1.怎么切换不同进程上下文?2.许多空闲的大小不同的洞怎么分配给新的进程?
1.每个进程都有自己独立的虚拟地址空间,操作系统必须在进
程运行前,确保这些寄存器被正确地赋值。
2.是紧凑(compact)物理内存,重新安排原有的段。例如,操作
系统先终止运行的进程,将它们的数据复制到连续的内存区域中去,改变它们的段寄存器中的值,指向新的物理地址,从而得到了足够大的连续空闲空间。这样做,操作系统能让新的内存分配请求成功。但是,内存紧凑成本很高,因为拷贝段是内存密集型的,一般会占用大量的处理器时间。一种更简单的做法是利用空闲列表管理算法,试图保留大的内存块用于分配(梦回ads,ff,nf,bf)
虚拟化需要的硬件支持
操作系统所有接口
创建进程:
1.文件描述符的初始化 ,会预先分配并初始化标准输入 (stdin,通常是文件描述符 0)、标准输出 (stdout,通常是文件描述符 1) 和标准错误(stderr,通常是文件描述符 2)。这些文件描述符默认情况下通常会连接到终端(或根据父进程的设置继承)。文件描述符表中的其他条目会被标记为可用,等待进程后续打开文件或其他 I/O 资源。
2.信号处理的设置 (Signal Handling Setup):
信号是操作系统向进程发送的异步通知,用于告知进程发生了某些事件(例如,用户按下 Ctrl+C,子进程终止,发生错误等,)。信号掩码通常会被初始化为空,表示不阻塞任何信号
3.在进程列表上创建条目为每个新创建的进程分配一个唯一的进程 ID (PID)进程设置相关的用户 ID (UID) 和组 ID (GID),这些 ID 决定了进程的权限和对系统资源的访问控制。通常,新创建的子进程会继承其父进程的 UID 和 GID,但进程也可以通过特定的系统调用来改变这些 ID。这些 ID 会被记录在 PCB 中。 工作目录和根目录的设置。这应该就是记账信息。
4.从磁盘中读取代码和静态数据储存到该进程的地址空间里。过去是尽早完成加载,现在是惰性执行(涉及分页和交换的存储机制)。
5、再根据argc/argv参数,不断分配内存给栈堆,栈存放局部变量、函数参数和返回地址,堆用于显式请求的动态分配数据。
6、其他初始化,输入输出(三个打开的文件描述符,用于输入输出和错误)。进程初始化一些与调度相关的属性。记账信息(记录进程的运行时间、使用的资源等信息,用于系统监控和资源管理)的初始化
7、启动程序,通过跳转到main例程,os将CPU控制权交给新创建的进程开始执行程序
注意:所有进程认为自己拥有最大位的地址空间,这是一种操作系统提供的抽象。进程不用关心实际位置有没有储存东西,操作系统会通过虚拟地址和物理地址的转换,页表调度交换来实现进程在任意地址空间的储存。
如何分配内存
1.在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间。由于我们假设每个进程的地址空间小于物理内存的大小,并且大小相同,这对操作系统来说很容易。它可以把整个物理内存看作一组槽块,标记了空闲或已用。当新进程创建时,操作系统检索这个数据结构(常被称为空闲列表,free list),为新地址空间找到位置,并将其
标记为已用。
2.栈内存,编译器隐式管理,存在于短暂的函数调用内
3.堆内存,程序员显式管理,存在于动态分配内存中,可能会导致很多操作错误,如:段错误,缓冲区溢出,忘记初始化,忘记释放,反复释放,无效释放内存。
1 | void func(){ |
但这些讨论的是库调用,建立在系统调用之上。
系统调用有brk,sbrk,mmap
销毁进程
进程终止时,操作系统需要回收的资源:
- 内存:
堆内存 (Heap Memory): 进程通过动态内存分配(如 malloc 或 new)获得的内存需要被释放回系统内存池。操作系统会回收进程的堆区域。
栈内存 (Stack Memory): 进程的栈空间用于函数调用和局部变量存储,当进程终止时,其栈区域需要被释放回系统内存池。
代码段和数据段 (Code and Data Segments): 存储程序指令和数据的内存区域也需要被释放。操作系统会回收这些内存映射。
操作系统通常会维护一个内存管理结构(如页表、段表),当进程终止时,会释放这些结构中与该进程相关的条目,并标记相应的物理内存页或段为可用。
2. 文件描述符:
进程打开的所有文件描述符都需要被关闭。当进程终止时,操作系统会自动关闭该进程所有打开的文件描述符。
关闭文件描述符涉及到递减内核中文件表和inode表的引用计数。当引用计数降至零时,内核会释放与该文件表项相关的资源(例如,缓冲区、锁等),并最终关闭底层的文件。
3. 锁和信号量:
锁 (Locks, Mutexes, Read-Write Locks): 如果进程持有任何锁,这些锁需要被释放,以避免其他进程永久阻塞。操作系统通常会记录进程持有的锁,并在进程终止时强制释放这些锁。然而,不正确的程序设计可能导致死锁等问题,即使在进程终止时,锁的状态也可能不一致。
信号量 (Semaphores): 进程持有的信号量资源也需要被释放。这可能涉及到增加信号量的值,以便等待该信号量的其他进程可以继续执行。操作系统需要维护进程与信号量之间的关系,并在进程终止时进行清理。进程的退出可能会影响等待该信号量的其他进程。操作系统需要妥善处理这些依赖关系,避免死锁或其他并发问题。
4. 与其他进程的通信资源:
管道 (Pipes): 如果进程正在使用匿名管道进行通信,当进程终止时,与该管道相关的内核缓冲区和文件描述符需要被释放。对于命名管道 (named pipes 或 FIFOs),即使进程终止,管道本身可能仍然存在于文件系统中,但与该进程相关的打开文件描述符会被关闭。
消息队列 (Message Queues): 进程创建或使用的消息队列需要被清理。这可能涉及到删除消息队列本身(如果不再被其他进程使用)以及释放相关的内核数据结构。
共享内存 (Shared Memory): 进程创建或附加的共享内存段需要被分离(detached)。如果该共享内存段不再被任何进程使用,操作系统可能会将其释放。共享内存的生命周期通常独立于创建它的进程。
套接字 (Sockets): 进程打开的网络套接字需要被关闭。这会触发网络连接的关闭流程。
5. PCB(进程控制块):
当进程终止后,其 PCB 中存储的所有信息(包括进程状态、上下文、记账信息等)不再需要。
操作系统会将该进程的 PCB 标记为可用,并将其放回一个可用的 PCB 池或进行回收,以便在创建新进程时可以重新使用。
异常处理
检测到异常后,会触发一个陷阱(Trap)或异常(Exception),将控制权交给内核中的异常处理程序。异常处理程序会分析异常类型,记录相关信息(例如,出错地址、寄存器状态等),并采取相应的措施。对于导致进程崩溃的严重错误,通常会向该进程发送一个信号(例如,SIGSEGV),如果进程没有捕获或处理该信号,内核会强制终止该进程。
如果一个进程持有锁并且异常终止,而没有释放锁,其他需要该锁的进程将会永久等待,从而导致死锁。
可以为每个进程记录锁的状态,当进程终止时,强制释放锁。也可以
超时机制(Lock Timeout): 在尝试获取锁时设置一个超时时间,如果在规定时间内未能获取到锁,则放弃等待并返回错误。但这并不能完全避免持有锁的进程崩溃导致的问题。
死锁检测与恢复(Deadlock Detection and Recovery): 操作系统可以定期检测系统中是否存在死锁,如果发现死锁,可以采取一些策略来解除死锁,例如终止一个或多个死锁进程。
等待进程停止运行
其他对进程的控制
获取进程状态
运行
就绪:为了跟踪每个进程的状态,操作系统可能会为所有就绪的进程保留某种进程列表(process list),储存了所有活动进程的进程控制块(PCB)
PCB:操作系统通过这个数据结构来追踪和管理进程的状态、资源使用情况等。在操作系统内核中维护。
阻塞:还必须以某种
方式跟踪被阻塞的进程。当I/O事件完成时,操作系统应确保唤醒正确的进程,让它准备好再次运行。
时间片到期或者被更高优先级抢占运行态会转化成就绪态,等待信号量或者被锁了或者IO会到阻塞态。
等待转换成运行一般通过调度策略,FIFO,优先级,短作业优先等
上下文切换
保存当前进程的上下文(寄存器、程序计数器等信息)。
更新进程的状态为适当的状态(就绪、阻塞、运行等)。
从就绪队列中选择下一个进程,并恢复该进程的上下文。
更新新进程的状态为“运行”,并将 CPU 分配给该进程。
Q:每一个进程的内存和寄存器位置难道是固定的吗,为什么可以被保存到PCB里?
A:进程的内存和寄存器位置在运行时并不是固定的,而是由操作系统动态分配和管理的。寄存器内容(如程序计数器、栈指针、寄存器值等)会保存到 PCB 中,因为它们是进程状态的一部分。也通过改变这些实现内存的动态分配分页和交换。进程内存内容(如堆、栈等)通常由内存管理单元或交换机制管理,并不会直接保存在 PCB 中。
当进程停止时(即没有运行),操作系统可以改变其地址空间的物理位置,这很容易。要移动进程的地址空间,操作系统首先让进程停止运行,然后将地址空间拷贝到新位置,最后更新保存的基址寄存器(在进程结构中),指向新位置。当该进程恢复执行时,它的(新)基址寄存器会被恢复,它再次开始运行,显然它的指令和数据都在新的内存位置了。
用户模式切换到内核
进程不能读取或写入磁盘,现代硬件都提供了用户程序执行系统调用的能力,系统调用。
要执行系统调用,程序必须执行特殊的陷阱(trap)指令。
执行陷阱时,硬件需要小心,因为它必须确保存储足够的调用者寄存器,以便在操作系统发出从陷阱返回指令时能够正确返回。例如,在 x86 上,处理器会将程序计数器、标志和其他一些寄存器推送到每个进程的内核栈(kernel stack)上。从返回陷阱将从栈弹出这些值,并恢复执行用户模式程序(有关详细信息,请参阅英特尔系统手册[I11])。其他硬件系统使用不同的约定,但基本概念在各个平台上是相似的。
线程
经典观点是一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(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 时),
我们始终确保持有锁。这确保我们不会在代码中注外引入竞态条件。
1 |
|
线程的运行
本章所有代码很容易运行。代码需要包括头文件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。
页表
页表就是一种数据结构,用于将虚拟地址(或者实际上,是虚拟页号)映射到物理地址(物理帧号)。操作系统通过虚拟页号(VPN)检索该数组,并在该索引处查找页表项(PTE),以便找到期望的物理帧号(PFN)。
页表项
有效位,保护位,存在位,脏位 + 页贞号
对于每个内存引用(无论是取指令还是显式加载或存储),分页都需要我们执行一个额外的内存引用,以便首先从页表中获取地址转换。工作量很大!额外的内存引用开销很大,在这种情况下,可能会使进程减慢两倍或更多。
锁
程序员在源代码中加锁,放在临界区周围,保证临界区能够像单条原子指令一样执行。
锁的原理:
调用lock()尝试获取锁,如果没有其他线程持有锁(即它是可用的),该线程会获得锁,进入临界区。这个线程有时被称为锁的持有者(owner)。
如果另外一个线程对相同的锁变量(本例中的mutex)调用 lock(),因为锁被另一线程持有,该调用不会返回。这样,当持有锁的线程在临界区时,其他线程就无法进入临界区。
这样的锁怎么实现:
- 在临界区关闭中断,不被其他线程干扰。这个不支持多处理器,而且万一一个很贪的程序抢到锁就停止不了了。
- 自旋锁:让硬件支持锁。原子交换。通常称为测试并设置指令(test-and-set)这个指令是原子地完成了更新锁值或者保持自旋。不会导致因为时钟中断而让互斥被打破。但是公平和性能上都不太好。自旋的线程在竞争条件下可能会永远自旋。在单CPU的情况下,性能开销相当大。假设一个线程持有锁进入临界区时被抢占。调度器可能会运行其他每一个线程(假设有N−1个这种线程)。而其他线程都在竞争锁,都会在放弃CPU之前,自旋一个时间片,浪费CPU周期。
自旋浪费CPU时间怎么办:
park()能够让调用线程休眠,unpark(threadID)则会唤醒 threadID 标识的线程。可以用这两个调用来实现锁,让调用者在获取不到锁时睡眠,在锁可用时被唤醒。
其他硬件原语:
- 比较并交换指令无等待同步时,比测试并设置原语强大。在这些更复杂的无等待算法中,CAS 的“比较”能力允许线程在尝试更新共享数据时,先检查数据是否被其他线程修改过。如果被修改过,则重新尝试,而不是像使用传统锁那样直接阻塞等待。这避免了锁带来的上下文切换开销和潜在的死锁问题。
- 链接的加载(load-linked)和条件式存储(store-conditional)链接的加载指令和典型加载指令类似,都是从内存中取出值存入一个寄存器。关键区别来自条件式存储(store-conditional)指令,只有上一次加载的地址在期间都没有更新时,才会成功,(同时更新刚才链接的加载的地址的值)。成功时,条件存储返回1,并将ptr指的值更新为value。失败时,返回0,并且不会更新值。
- 获取并增加:本方法能够保证所有线程都能抢到锁。只要一个线程获得了ticket值,它最终会被调度。
死锁
两个或多个进程(或线程)因互相持有对方所需的资源,而都处于等待状态,导致所有进程都无法继续执行下去的僵局。
如果一个进程持有锁并且异常终止,而没有释放锁,其他需要该锁的进程将会永久等待,从而导致死锁。(超时机制(Lock Timeout): 在尝试获取锁时设置一个超时时间,如果在规定时间内未能获取到锁,则放弃等待并返回错误。但这并不能完全避免持有锁的进程崩溃导致的问题。
死锁检测与恢复(Deadlock Detection and Recovery): 操作系统可以定期检测系统中是否存在死锁,如果发现死锁,可以采取一些策略来解除死锁,例如终止一个或多个死锁进程。
资源追踪和强制释放: 内核会跟踪每个进程持有的资源(包括锁)。当进程异常终止时,内核可以强制释放该进程持有的所有锁。这需要操作系统维护额外的元数据来记录进程和锁之间的关系。)
有互斥、持有并等待、不可剥夺和环路等待这四个必要条件
死锁避免的策略:破坏这些必要条件
- 打破互斥条件 (Mutual Exclusion):
将独占资源改造为共享资源: 如果资源本身允许多个进程同时访问,那么互斥条件就不成立。例如,只读文件可以被多个进程同时访问。
虚拟化技术: 有时可以通过虚拟化技术将独占资源变成多个可同时访问的逻辑资源。例如,打印机可以采用缓冲技术,允许多个进程同时向缓冲区输出,实际的打印操作由后台进程串行执行。
这种方法并非总是可行,因为很多资源本质上就是独占的。
2. 打破持有并等待条件 (Hold and Wait):
一次性申请所有需要的资源: 进程在开始执行前,必须一次性申请它所需要的全部资源。只有当所有资源都可用时,才允许进程运行。如果某些资源暂时无法满足,则进程不能持有任何资源进入等待状态,而是释放已获得的资源,并在稍后重新尝试申请所有资源。
优点: 简单直观,能够有效避免死锁。
缺点:
资源利用率可能较低,因为进程在整个执行过程中可能并不总是需要所有已申请的资源。
可能导致饥饿问题,如果某些资源长期被其他进程占用,某些进程可能永远无法获得所需的全部资源。
进程在执行前必须知道它需要的所有资源,这在某些情况下难以预测。
3. 打破不可剥夺条件 (No Preemption):
允许资源抢占: 当一个进程持有某些资源,并且请求其他被阻塞的资源时,如果操作系统判断其所需的资源被其他持有某些资源的进程占用,且该持有资源的进程也在等待其他资源,操作系统可以强制剥夺当前进程已持有但不是立即需要的资源,将其分配给需要的进程。被剥夺资源的进程需要释放这些资源,并在稍后重新申请。
优点: 提高了资源利用率,减少了死锁发生的可能性。
缺点:
只有当资源可以安全地被剥夺且恢复时才适用(例如,内存可以被换出)。
可能导致进程状态的保存和恢复,增加了系统开销。
可能导致某些进程的饥饿,如果它们总是被剥夺资源。
4. 打破环路等待条件 (Circular Wait):
资源有序分配策略: 为系统中所有资源进行编号,规定进程必须按照资源的编号顺序来申请资源。一个进程只有在已经占用了编号较小的资源时,才能申请编号较大的资源。不允许进程请求编号小于其已持有资源编号的资源。
优点: 简单有效,能够破坏环路等待的条件。
缺点:
资源编号的合理分配可能比较困难,需要预先了解进程对资源的需求顺序。
可能限制了进程申请资源的灵活性,导致资源利用率不高。
新增资源时可能需要重新调整编号。
除了上述打破必要条件的策略外,还有一些其他的避免死锁的方法:
死锁避免的算法 (Deadlock Avoidance Algorithms):
银行家算法 (Banker’s Algorithm): 是一种更复杂的死锁避免策略。操作系统会维护系统中所有资源的可用数量、每个进程已分配的资源数量以及每个进程还需要多少资源。当一个进程申请资源时,操作系统会首先进行安全性检查,判断分配这些资源后系统是否仍然处于安全状态(即是否存在一个进程执行序列,使得所有进程都能在有限时间内完成)。如果分配后系统进入不安全状态,则拒绝该请求,即使该资源当前是空闲的。
优点: 允许更多的并发执行,资源利用率通常比一次性申请所有资源高。
缺点:
进程在执行前必须声明其所需的所有资源的最大数量。
算法的开销较大,每次资源申请都需要进行安全性检查。
可能导致进程等待时间过长。
条件变量
文件系统 (File System):
主要功能: 文件系统的主要功能是组织、存储、检索和管理计算机上的数据。它提供了一种结构化的方式来组织信息,使得用户和应用程序可以通过文件名和目录层次结构来访问和操作数据,而无需关心底层存储设备的物理细节。
作用:
数据持久化: 文件系统允许数据在计算机关机后仍然能够保存下来,存储在诸如硬盘驱动器 (HDD)、固态驱动器 (SSD) 等持久性存储设备上。
逻辑组织: 它通过目录(文件夹)和文件的层次结构,将大量的数据组织成易于管理和查找的形式。
命名和访问控制: 文件系统为每个文件和目录提供唯一的名称,并管理用户对它们的访问权限(例如,读、写、执行)。
数据完整性和可靠性: 许多文件系统包含机制来确保数据的完整性,例如校验和、日志记录等,以防止数据损坏或丢失。
空间管理: 文件系统负责管理存储设备的可用空间,分配空间给新创建的文件,并回收已删除文件释放的空间。
抽象接口: 它为应用程序提供了一组统一的 API (例如,open(), read(), write(), close()),使得应用程序可以以一致的方式访问不同类型的存储设备上的数据。
I/O 子系统 (Input/Output Subsystem):
主要功能: I/O 子系统的主要功能是管理计算机系统中各种输入/输出 (I/O) 设备,例如键盘、鼠标、显示器、打印机、网络接口卡、磁盘驱动器等。它负责处理应用程序发出的 I/O 请求,并控制硬件设备完成相应的操作。
作用:
设备管理: 操作系统需要识别、初始化和管理连接到计算机的各种 I/O 设备。这通常涉及到设备驱动程序的加载和管理。
提供抽象接口: I/O 子系统为应用程序提供了一组抽象的、与设备无关的接口,使得应用程序无需了解底层硬件的具体细节就可以进行 I/O 操作。例如,应用程序可以使用相同的 read() 和 write() 系统调用来操作文件和网络连接。
数据传输: I/O 子系统负责在内存和 I/O 设备之间传输数据。这可能涉及到使用缓冲区、高速缓存和直接内存访问 (DMA) 等技术来提高效率。
错误处理: I/O 子系统需要处理设备操作过程中可能发生的错误,并向应用程序报告错误状态。
设备调度: 当多个应用程序同时请求访问同一个 I/O 设备时,操作系统需要进行设备调度,以决定哪个请求先得到服务,从而提高设备的利用率和系统的整体性能。例如,磁盘调度算法(如 SCAN、C-SCAN)用于优化磁盘磁头的移动。
设备独立性: 操作系统通过设备驱动程序实现了设备独立性,使得上层应用程序可以使用统一的接口来访问不同类型的设备,而无需修改代码。