OSTEP笔记
CPU虚拟化
1. 系统调用和过程调用之间的区别:系统调用将控制转移到OS,同时提高硬件特权级别。在发起系统调用时(通常通过一个称为trap的特殊硬件指令),硬件将控制权转移到预先指定的trap hanler并同时将特权级别提升到内核模式。当操作系统完成请求的服务时,它通过特殊的return-from-trap指令将控制权交还给用户,返回用户模式,将控制权交还给应用程序,回到应用离开的地方。
2. 系统调用在C库中通常是用汇编编写的,因为它们需要按照约定正确地处理参数和返回值,以及硬件特定的trap指令。
3. 系统在启动时会通过一些特殊的指令设置trap table,从而硬件能够在系统调用或者其他异常发生的时候,知道应该跳到何处执行代码。
4. 操作系统如何从用户态程序中夺回控制权?显然期望用户态程序执行系统调用或者发生异常是不现实的,一般操作系统都会在启动时利用特殊指令开启时钟,然后利用时钟中断夺回控制权,在时钟中断函数中执行调度等操作。
5. 当操作系统切换进程时,例如从进程A切换至进程B,存在如下几次寄存器的save/restore操作:1). 当时钟中断触发时,硬件自动将A的user registers存放至A的kernel stack中,2). 当内核决定将切换进程时,将A的kernel registers保存至A的进程结构中,再从B的进程结构中加载kernel registers,再切换到进程B的kernel stack,执行return-from-trap,从时间中断处理程序中退出,硬件自动实现从B的kernel stack加载user registers,从而实现切换到进程B。
6. 系统调用或者上下文切换的开销,在2~3GHz的处理器下大概是半微秒的时间。事实上研究表明,很多OS operations都是内存敏感的,因此更好的CPU可能并不能让OS得到理想中的速度提升。
7. 在多处理器系统中存在缓存一致性问题,程序在CPU之间进行调度时,各个CPU的缓存以及内存之间可能存在数据不一致,不过这最终都会由硬件帮助解决。
8. multiprocessor scheduler在做调度决定的时候应该考虑cache affinity,如果可能的话,尽量将进程调度到同一个CPU
内存管理
9. Virtual Memory的三个目标:1. transparency:应用程序不应该感知到内存是被虚拟化的,所有的内存映射工作都由OS完成,2. efficiency:不应该应为虚拟内存让程序运行变得太慢,同时支持虚拟内存所需的数据结构不应该占据过多的内存空间,3. protection:进程之间应该相互隔离,不能访问其他进程或者OS的内存
10. 释放内存两次带来的结果是不确定的,不过一般都是程序崩溃了
11. 一旦程序退出,操作系统会回收该程序占用的所有内存
12. MMU是CPU的一部分(也就是说每个CPU都会有一个),用于将虚拟地址转换为物理地址并且判断转换得到的物理地址是否合法,随着内存管理模型的复杂化,MMU也会变得更为复杂
13. 如果将整个进程的地址空间映射到物理内存中,会造成stack和heap中间的内存空间的浪费,为了解决这个问题,提出了segment的概念,将code, stack, heap作为三个segment分别映射到物理内存中,每个segment都有base和bound的register,从而不会有过多的内存空间的浪费。但是这样就会让segment大小变得不再统一,从而会造成碎片的问题,需要使用一些算法或者压缩来缓解碎片问题。同时在segment内部也可能存在碎片,特别是heap,占用的物理内存可能比实际需要的内存要大。
14. 有了segment之后,实际上还能实现对segment的共享,尤其是code segment,只需要在硬件中设置Protection标志,将code segment设置为Read-Execute,则可以将code segment的物理内存映射到不同程序的虚拟内存中并且在对该segment进行访问时,还需要检查Protection的权限是否符合。
15. page table用来存储虚拟地址到物理地址之间的映射信息并且每个进程都有一个page table,具体的结构在早期是由硬件决定的,不过现在变得更加灵活,可以由OS管理。
16. page table中的每个元素,即PTE(Page Table Entry)除了包含物理页号(PFN),还包含一些标志位,例如:present bit用来表示该页是否在内存中,R/W表示该页是否可写,dirty bit表示该页是否被修改。需要注意的是present bit为1时,表示页面处于内存中,而present bit为0时,会产生trap,OS需要一些额外的数据结构用于确定该页是被swap到硬盘,还是原本就不合法。
17. 通过page table映射需要额外增加一次内存访问,因为首先需要通过访问page table来解析得到物理地址,同时也有可能产生缺页异常需要处理。
18. TLB(translation-lookaside buffer)是MMU(Memory Management unit)的一部分,用于缓存虚拟地址到物理地址之间的映射,硬件首先会确认要访问的虚拟地址在TLB中是否有缓存,如果没有的话,才会再去查询page table,从而大大提升了查询效率。
19. 既然缓存如此有用,为什么不把所有东西都放在缓存里呢?这其实是一些物理原因决定的,缓存正因为比较小,所以它会运行得很快,一旦变大之后,速度就下降了。
20. 对于TLB miss有两种处理方式,一种是完全由硬件处理,另一种是发生miss时,触发一个exception由OS处理,这时需要注意的是,此处的return-from-trap之后执行的不是通常的发生异常的指令的下一条指令,而是发生异常的该条指令。同时需要注意不能造成infinite TLB miss,这可以通过将TLB miss handler的所有代码的地址映射写死到TLB中等方法实现。
21. TLB是一个fully associative cache,即硬件会对缓存中的所有条目同时进行匹配并且每隔虚拟地址的映射都可以放在缓存中的任何地方。
22. 当发生进程上下文切换时,对于TLB有两种处理方式,一种是直接将前一个进程的TLB清空,另一种是在TLB条目中增加一个标志位,用于识别该条目属于哪个进程,从而避免全局刷新TLB带来的效率损失。
23. 如果程序在短时间内访问了超过TLB缓存数的页面,这可能会造成大量的TLB miss,从而超过了TLB coverage,对于数据库管理系统这样的程序,这种问题是比较常见的,因为它们的数据结构往往很大而且访问随机,解决这个问题的方法就是支持大页。
24. 为了减少linear page table占用的内存空间,可以试着将segment和page的机制结合,最主要的不同是每个segment都有一个page table,每个page table有一个bound register从而能够限制page table的大小。但是这种方法也不是没有问题,首先有segment机制本身的问题,例如很大但是很稀疏的heap,这样的话,page table仍然将占用很多的空间,同时,page table也不是固定大小的了,因此同样会产生碎片问题。
25. Multi-Level page table和Linear page table相比也不是没有缺点,例如在TLB Miss的时候,需要从内存加载两次,一次为Page Directory,另一次为Page Table,事实上是一种time-space trade-off,即时间换空间的思路。同时,Multi-Level page table也增加了系统的复杂度。
26. 事实上,page table仅仅只是一种数据结构,我们也可以使用inverted page tables,它的特点是,它是被系统的所有进程共享的,它为没一个physical page维护了一个entry,它会告诉你哪个process使用了这个page,以及映射到这个physical address的virtual address是什么。对于page table,我们可以用很多种方法去实现,multi-level page tables和inverted page tables只是其中的两种。
27. 不同的数据结构事实上没有好坏,要根据实际的环境决定,例如对于memory-constrained环境,显然multi-level page table更好,但是如果系统内存充裕,则反而可能是linear page table更好,因为它能减小TLB miss带来的延时。
28. 为了支持swapping,page table entry中需要增加一个present bit,如果这个bit的值为0,则说明该page被swap到磁盘中了,会触发硬件产生page fault,之后OS的page fault handler会对此进行处理。
29. 在page fault handler中,OS会从对应的page table entry中获取对应的磁盘的地址并对其发送请求,将对应的页从磁盘加载回内存,当磁盘IO完成之后,OS会更新PTE中的present bit以及PFN用来重新标记该页的物理内存的地址并且更新TLB,从而能够正确访问该页的内容。当IO正在进行的时候,对应的进程其实是阻塞的,OS就能调度其他ready的进程运行。
30. 为什么page fault handler是由OS而不是由硬件处理?首先,磁盘操作很慢,相比之下运行软件执行就没有那么慢了。另外,处理page fault硬件还需要知道swap space,如何发送IO请求到磁盘以及其他一系列知识。因此为了性能以及simplicity,最后由OS处理page fault。
31. 当内存已经用尽时,如果需要从swap space调入页面,则需要先将内存中的某些页面调出,这种选择页面的策略叫做page-replacement policy。
32. 事实上,系统不可能等到物理内存耗尽才去做page replacement,一般系统都会有一个high watermark(HW)和low watermark(LW),当系统可用的物理内存数量少于LW时,后台的swap daemon就会进行swap操作,从而让可用物理内存数量大于HW。其实这种批量的swap操作,可用集中进行磁盘写,有助于提高性能。
33. 在实际的地址空间中,page 0通常是不映射物理地址的,因为它要用于触发对于Null pointer引用的错误。
34. 内核地址空间被映射到每个进程的相同地址空间中,这样能够让kernel与进程之间传递数据更为方便,而且即使发生进程切换之后,内核地址空间部分的内容无需切换。
35. 在Linux中,Kernel Virtual Address分为两种:kernel logical address和kernel virtual address。其中kernel logical address通过kmallo()获取,大多数kernel的数据结构都位于这种类型的地址中并且它们不能被swap到磁盘。另外,kernel logical address和物理地址是直接映射的,例如kernel logical address 0xC0000000直接映射到物理地址0x0000000,kernel logical address 0xC0000FFF直接映射到物理地址0x00000FFF。这能够让物理地址和逻辑机制直接转换,同时在kernel logical address连续的地址空间在物理地址空间中也是连续的。这能够方便需要连续地址空间的操作,例如DMA。kernel virtual address通过vmalloc()获取,它的物理地址不连续,这也让它更容易获取,因为获取大的,连续的物理内存相对不是很容易,另外kernel virtual address也让kernel能够获取超过1G的内存,在32位机器上。
36. 随着对于更好的TLB性能愈发强烈,Linux开发者增加了透明的huge page support。当这个特性开启时,OS会自动找机会分配huge pages(通常是2MB,但是有的系统是1GB),而无须对应用进行修改。
37. 如果访问memory-mapped file中没有被加载到内存的部分会触发page faults,从而OS会加载相应的内容并且更新进程的page table。事实上,所有的Linux进程都会使用memory-mapped file,因为Linux就是通过这种方式从可执行文件和共享库中加载代码的。
并发
38. 我们不能期望所有操作都有硬件支持的原子指令(例如原子更新一棵B树),但是通过硬件提供的synchronization primitives以及操作系统的辅助,就能让多线程以同步并且可控的方式访问critical sections。
39. 对于锁的设计,首先要考虑它的基本功能,即能否实现互斥,其次要保证公平性,例如是否会有线程饥饿,永远得不到锁,最后要保证性能,例如只有一个线程运行时获取释放锁的损耗,多个线程在单个CPU上运行以及多个线程在多个CPU上运行?
40. 在单处理器系统中,我们可以将所有中断屏蔽作为锁的最简单实现,但是这种方法会有很多问题。比如,如何防止持有锁的线程恶意占用CPU,关闭中断很长时间会导致很多中断丢失,造成很多系统问题,最后,关闭中断的操作并不高效。
41. 硬件只要支持Test-And-Set指令即可实现同步原语:
int TestAndSet(int *old_ptr, int new) { int old = *old_ptr; *old_ptr = new; return old; } typedef struct __lock_t { int flag; } lock_t; void init(lock_t *lock) { // 0: lock is available, 1: lock is held lock->flag = 0; } void lock(lock_t *lock) { while (TestAndSet(&lock->flag, 1) == 1) ; // spin-wait (do nothing) } void unlock(lock_t *lock) { lock->flag = 0; }
42. 基于Test-And-Set的自旋锁在正确性上是没有问题的,但是不能保证公平性,同时在单核条件下性能很差,但是在多核条件下,性能还是可以的,因为通常临界区都很小,即使另一个进程在另一个CPU上自旋,我们也认为它很快就能拿到锁。
43. 为了避免自旋锁的开销,可以让没有获得锁的进程直接放弃CPU,让OS重新调度,虽然这种方法相比自旋能够更加节省资源,但是开销仍然很大,因为上下文切换的开销很大,而且这种方法还是不能解决饥饿问题。
44. 基于select()等函数构建的Event-based concurrency,对于单CPU的情况下,可以极大地减少因为并行带来的问题。但是要注意的是,在event handler中不能有block system call,例如open(),read()等系统调用,因此需要使用asynchronous IO,同时需要进行状态管理,从而在异步IO结束之后能够结束该事件。但是基于event的处理还是会有问题,比如在多核条件下,就会并行运行多个event handler,这时就不可避免还是会引入锁,其次虽然能够通过异步IO避免显式的阻塞,但是对于page fault这种隐式的阻塞却很难避免。最后,event-based code最后会变得非常难以维护。
持久化
45. 一般对性能要求越高的设备离CPU越近,这是由物理性质和成本决定的,一般memory和graphic是离CPU最近的,用PCI总线连接,其余外围设备,例如磁盘用SATA总线连接,而鼠标键盘等慢速设备则用USB连接。
46. 硬件设备一般由两部分组成:hardware interface,用于和系统软件进行交互的界面,一般包含三类寄存器:status registers,command registers以及data registers。另一部分是internal structure,即设备的内部实现,简单的设备可能只有一些芯片,复杂的设备可能里面还会包含CPU,内存等等。
47. 中断并不一定总是比轮询更好,对于速度较快的设备,轮询的效率更高,因为进程切换的消耗是很大的,而对于运行速度未知的设备,则中断和轮询两者的结合会更好,先轮询一段,如果设备一直没有结束,则转成中断。特别是对于网络设备,如果网络流量很大,则最后让OS进入livelock状态,忙于处理每个数据包产生的中断,而无暇真正对它们进行处理。此时,轮询往往是更好的机制。
48. OS与设备交互有两种方式:一种是用特定的IO指令,例如x86下的,in和out指令,指定寄存器以及port(即设备)即可,一般这种指令都要求有高的权限。另一种方式是memory-mapped IO,这种方法能让硬件寄存器像内存一样被访问,为了访问特定寄存器,OS可以load或者store对应的地址,硬件会负责将load/store路由到对应的设备而不是内存。
49. 系统调用write()的真正含义是告诉文件系统在未来某个时刻会将内容写入磁盘
50. 读取文件需要文件系统找到相应的inode,读取block再更新inode中的last-access-time。一般的写入操作则包含五次IO:读写data bitmap(用于记录哪些data block被使用),读写inode(inode使用哪些data block),最后将真正将数据写入block。如果要创建新文件,则需要包含更多IO操作,比如读写inode map,初始化inode,数据写入文件所在的目录,以及对于文件所在目录inode的读写。