操作系统三个关键:虚拟化( virtualization) 并发(concurrency) 持久性(persistence)
虚拟化CPU:许多任务共享物理CPU,让它们看起来像是同时运行。
时分共享:运行一个进程一段时间,然后运行另一个进程,如此轮换,以此实现虚拟化。
进程创建:将代码和所有静态数据加载到内存中,分配栈内存并初始化,初始化I/O等模块,执行main()函数
进程三种状态:运行、就绪、阻塞
数据结构:操作系统会保存进程相关的信息,比如进程列表,寄存器上下文等。
进程API:
这种fork()及exec()的方式有利于编写shell脚本。
虚拟化CPU两个关键问题:高效、可控
受限直接执行:程序直接在CPU上运行,但指令执行和执行时间受到限制。
用户程序运行于用户模式,执行I/O等操作是受限的;操作系统运行于内核模式,允许执行受限操作。
程序执行受限操作时,需要执行系统调用,此时会陷入内核模式,操作系统执行完操作后,再从陷阱返回到用户模式。陷入内核会保存进程的寄存器以及程序计数器,从陷阱返回时恢复。
系统启动时会初始化陷阱表,告诉CPU陷阱指令对应的处理程序地址。
进程切换:程序是直接运行在CPU上的,此时操作系统并没有运行,操作系统需要重新获取CPU控制权,才能进行进程切换。获取控制权有两种方式:
进程切换时系统会执行上下文切换:把当前程序寄存器保存到它的内核栈,然后从即将执行的程序的内核栈恢复寄存器。
指标:
非抢占式调度:一个任务完成,再执行下一个任务
抢占式调度:可以中断一个任务,执行另一个任务
先进先出(FIFO)
先到的任务先执行,非抢占式。若短时间任务排在长时间任务之后,会导致平均周转时间很长。
最短任务优先(SJF)
先运行时间最短的任务,非抢占式。平均周转时间优于FIFO。
最短完成时间优先(STCF)
每当新任务进入时,执行剩余时间最少的任务,抢占式。平均周转时间优于SJF。
以上调度算法适用于批处理系统,响应时间和交互性很差。
轮转(RR)
一个工作运行一个时间片,然后切换到下一个任务,依次循环。抢占式。
时间片越短响应性越好,但上下文切换的成本影响整体性能。
任务在I/O期间不会使用CPU,此时可以切换到下一个任务。
调度:多级反馈队列(MLFQ)
目的:在运行过程中学习进程的特征,做出更好的调度决策,以此优化周转时间和响应时间。
一种具体算法:为每个工作设置一个优先级,优先级会动态改变,满足以下规则
调度:比例份额
目的:确保每个工作获得一定比例的CPU时间
一种具体实现是彩票调度,每个进程获得一定数量的彩票,每次调度时随机抽取彩票,以此决定调度哪个进程。难点在于如何分配彩票,常用于虚拟机等容易确定额度的系统中。
多处理器与单处理器的区别在于对硬件缓存的使用,以及多处理器之间共享数据的方式。
缓存是基于局部性的概念:
缓存一致性:
多个CPU同时访问内存会出现缓存一致性问题,即一个CPU在本地缓存中修改了数据,新的值不会立即同步到内存,此时另一个CPU读取内存的值,就会出现两个CPU看到的值不一致。硬件提供了基本解决方案:通过监控内存访问,硬件可以保证获得正确的数据,并保证共享内存的唯一性,比如总线窥探技术。
但是跨CPU访问仍需要使用互斥原语或无锁数据结构来保证正确性。比如多CPU修改同一个队列。
总线窥探:每个缓存都通过监听链接所有缓存和内存的总线,来发现内存访问。如果CPU发现对它放在缓存中的数据的更新,会作废本地副本,或更新它。
缓存亲和度
多处理器调度还有缓存亲和度的问题:一个进程在某个CPU上运行时,会在该CPU的缓存中维护许多状态。下次该进程在相同CPU上运行时,由于缓存中的数据而执行得更快。
单队列多处理器调度(SQMS)
与单处理器调度类似,将所有工作放入一个单独的队列中,问题在于多CPU访问队列需要加锁,性能损失极大,而且不能很好的保证缓存亲和度。
多队列多处理器调度(MQMS)
每个CPU有一个队列,可以减小加锁损失,且具有良好的缓存亲和度,问题是不同队列负载不均衡。
实现负载均衡的方法是让工作跨CPU迁移,一种具体实现是工作窃取技术:工作量较少的队列不定期地“偷看”其他队列,如果目标队列比源队列更满,就“窃取”一个或多个工作。
现代操作系统的调度算法有O(1)调度程序、完全公平调度程序(CFS)以及BF调度程序(BFS)等。
早期系统只运行一个程序,内存简单分为操作系统部分和程序部分。
后来出现运行多道程序的时分系统,最简单的方式是CPU程序切换时,将程序内存全部保存到硬盘上,再将下一个程序从硬盘加载到内存中。缺点是读写硬盘效率低下。
可以在进程切换时,仍将进程信息保留在内存中,每个进程拥有一部分内存,如下图所示:
此时就出现了内存保护的问题:我们不希望一个程序读、写另一个程序的内存。所以操作系统抽象出了地址空间,即一个进程可见的内存,进程的地址空间包含程序的所有内存状态,包括代码、堆、栈等,如下图:
地址空间是操作系统提供的内存抽象,是从0开始的连续空间,而程序可能加载在物理内存的任意位置,操作系统需要将地址空间中的虚拟地址与物理内存地址对应起来。这便是内存虚拟化的关键。
虚拟内存的实现有3个目标:
内存操作API:
地址转换:硬件对每次地址访问进行处理,将指令中的虚拟地址转换为实际物理地址。
动态重定位:每个CPU需要两个寄存器,基址寄存器和界限寄存器,基址寄存器存储进程在物理内存中的实际加载地址,此时,物理地址 = 虚拟地址 + 基址。而界限寄存器提供了访问保护,若进程访问超过界限的地址,CPU会发生异常。
CPU的这个负责地址转换的部分统称为内存管理单元(MMU)。
动态重定位存在资源浪费:必须将进程的地址空间完整的加载到连续的物理内存上。
分段:将地址空间分为代码、栈、堆等不同逻辑段, MMU为每个逻辑段分配一对基址和界限寄存器。这样,只有已使用的内存才在物理内存中分配空间。
段错误:在支持分段的机器上内存访问超过界限。也用于不支持分段的机器。
分段的地址转换方式:
栈是反向增长的,所以硬件除了记录基址和界限,还需要记录段的增长方向。
支持共享:硬件为每个段设置保护位,标记是否可读写、执行,并检查程序内存访问是否允许。这样不可写的段就可以被多个进程共享。
分段粒度:上述的分为代码、栈、堆三个段是粗粒度分段。早期有的系统会划分大量较小的段,称为细粒度分段,这种分段需要进一步的硬件支持,并在内存中保存某种段表。
分段的操作系统支持:
空闲空间管理
管理空闲空间的数据结构称为空闲列表,记录哪些空间还没有分配,如下所示:
分配内存时,从空闲列表找到合适的空间,并更新列表。释放内存时,会对列表进行合并。
查找可用空间的具体策略有最优匹配、最差匹配、首次匹配、下次匹配、分离空闲列表、伙伴算法等。
分页:将进程的地址空间分割成固定大小的单元,每个单元称为一页。相应地,把物理内存看成是定长槽块的阵列,叫作页帧。每个页帧包含一个虚拟内存页。
示例:
页表:操作系统为每个进程创建的数据结构,记录地址空间的每个虚拟页放在物理内存中的位置。
典型页的大小一般为4KB。
两个进程可以共享同一物理页,比如代码页。
快速地址转换TLB:
TLB是虚拟到物理地址转换的硬件缓存,会缓存页表中的部分映射关系。
由于TLB只对当前进程生效,所以上下文切换时,要么清空TLB,要么TLB支持多进程,方式是在TLB中添加表示进程的地址空间标识符。
替换策略:RLU(最近最少使用)或随机策略。
页表实现方式:
程序的地址空间超过物理内存大小时,需要将一部分地址空间存到磁盘等大而慢的设备上。
交换空间: 在硬盘上开辟一部分空间用于物理页的移入和移出。
如果一个物理页已被交换到硬盘,访问该页会产生页错误,操作系统会将该页交换到内存中。
页交换策略: 决定哪些页被交换出内存,具体策略有FIFO、随机、LRU、近似LRU等。
线程:进程可以有多个线程,和进程类似,每个线程有自己的程序计数器、寄存器,线程切换会发生上下文切换。一个进程的每个线程有自己的栈空间,共享堆空间。
共享数据:
以下代码,假设两个线程各执行一次mythread(),执行1000万次+1操作,那么预期结果应该是2000万。
static volatile int counter = 0;
void mythread()
{
for (int i = 0; i < 1e7; i++) {
counter = counter + 1;
}
}
然而实际的结果可能不是2000万,原因是,counter = counter + 1 实际的汇编代码可能是:
mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
这个例子假定,变量counter位于地址0x8049a1c。在这3条指令中,先用x86的mov指令,从内存地址处取出值,放入eax。然后,给eax寄存器的值加1(0x1)。最后,eax的值被存回内存中相同的地址。
假设counter=50,线程1先执行前2条汇编指令,此时eax=51,然后发生时钟中断,切换到线程2运行。
线程2执行了全部的3条指令,将共享变量counter设为51(每个线程都有自己的专用寄存器)。
然后又发生了一次上下文切换,线程1恢复运行,线程1的eax=51,执行mov,counter再次被设置为51。
所以,counter = counter + 1 代码执行了两次,counter的值却只增加了1。
此段代码称为临界区,即访问共享资源的代码片段,资源通常是一个变量或数据结构。
这种情况称为竞态条件,出现在多个线程同时进入临界区时,它们都试图更新共享资源,导致了意外的结果。
不确定性程序由一个或多个竞态条件组成,程序的输出因运行而异,具体取决于哪些线程在何时运行。
我们真正想要的代码就是所谓的互斥。这个属性保证了如果一个线程在临界区内执行,其他线程将被阻止进入临界区。
原子性:原子性是指作为一个单元,要么全部执行,要么没有执行。以上例子,如果counter = counter + 1是一个原子操作,则不会有不确定性问题。
同步原语:指硬件提供的一些指令,用于受控的访问临界区,产生确定的结果。
锁:为临界区加锁,保证临界区能够像单条原子指令一样执行。
1 lock_t mutex; // 锁,为全局变量
2 ...
3 lock(&mutex); // 获取锁
4 balance = balance + 1; // 临界区代码
5 unlock(&mutex); // 释放锁
锁变量保存了锁在某一时刻的状态,要么是unlocked,表示没有线程持有锁,要么是locked,表示有一个线程持有锁。锁也会保存其他的信息,比如持有锁的线程,或请求获取锁的线程队列。
调用lock()尝试获取锁,如果没有其他线程持有锁,该线程会获得锁,进入临界区。如果有其他线程持有锁,该线程会等待。 持有锁的线程调用unlock()释放锁,此时如果有等待线程,其中一个会获取该锁,进入临界区。
锁评价标准:
锁的实现方式
锁由硬件提供的同步原语以及操作系统共同实现。硬件原语有:
自旋锁由于一直自旋占用CPU,可能会产生性能问题,解决方式有:
基于锁的并发数据结构
在很多情况下,线程需要检查某一条件满足之后,才会继续运行,比如父线程需要检查子线程是否执行完毕 (称为join)。
当某些条件不满足时,线程把自己加入等待队列。其他线程改变了上述条件时,唤醒一个或者多个等待线程,让它们继续执行。这种思想称为条件变量。
条件变量有两种相关操作:wait() 和 signal()。线程要睡眠的时候,调用wait()。当线程想唤醒等待在某个条件变量上的睡眠线程时,调用signal()。调用signal和wait时要持有锁。
int done = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITIALIZER;
void thr_exit() {
Pthread_mutex_lock(&m);
done = 1;
Pthread_cond_signal(&c);
Pthread_mutex_unlock(&m);
}
void thr_join() {
Pthread_mutex_lock(&m);
while (done == 0)
Pthread_cond_wait(&c, &m);
Pthread_mutex_unlock(&m);
}
生产者/消费者(有界缓冲区)问题:
有一个或多个生产者线程和一个或多个消费者线程。生产者把生成的数据项放入缓冲区;消费者从缓冲区取走数据项。当缓冲区满时,生产者等待,当缓冲区空时,消费者等待。
覆盖条件:用 pthread_cond_broadcast() 代替上述 pthread_cond_signal(),唤醒所有的等待线程
信号量(sem_t)是有一个整数值的对象,需要设置初始值
int sem_wait(sem_t * s){
信号量的值减1
若值为非负数,直接返回,否则等待
}
int sem_post(sem_t * s){
信号量的值加1
如果有等待线程,唤醒其中一个
}
由此可知,信号量值为负数时,表示等待线程的个数。
二值信号量(锁)
把临界区用一对sem_wait()/sem_post()环绕,并将信号量初始化为1,这样就能实现锁。
信号量用作条件变量
一个线程等待条件成立,另外一个线程修改条件并发信号给等待线程,从而唤醒等待线程。
生产者/消费者(有界缓冲区)问题
用两个信号量empty和full分别表示缓冲区空或者满,并用二值信号量加锁。
违反原子性缺陷:
代码段本意是原子的,但在执行中并没有强制实现原子性,案例:
Thread 1::
if (thd->proc_info) {
fputs(thd->proc_info, ...);
}
Thread 2::
thd->proc_info = NULL;
非空检查和fputs()是假设原子的,当假设不成立时,代码就出问题了。
错误顺序缺陷:
不同线程的内存访问的预期顺序被打破了。
死锁缺陷:
死锁的四个条件:
预防死锁:
避免死锁:可以通过处理器调度来避免死锁,如银行家算法。
检查和恢复:允许死锁偶尔发生,检查到死锁时再采取行动。
等待事件发生;当它发生时,检查事件类型,然后做出处理;这是最基础的事件循环:
while (1) {
events = getEvents();
for (e in events)
processEvent(e);
}
存在的问题:
典型系统架构:
总线分层设计,高性能设备离CPU更近,而外围总线可连接的设备更多。
一个标准设备可分为接口和内部结构两部分,接口通常由3个寄存器组成:
通过读写这些寄存器,操作系统可以控制设备的行为:
利用DMA进行高效数据传送:DMA引擎是一个特殊设备。操作系统告诉DMA数据在内存的位置,要拷贝的大小以及要拷贝到哪个设备。数据传输完成后,DMA会抛出中断通知操作系统。
两种设备交互方式:
设备驱动程序:
封装设备交互的细节,为操作系统提供抽象接口。
驱动程序占据了操作系统大部分的代码,且是系统崩溃的主要原因。