高级操作系统
随着硬件的发展,过去的OS并不能很好的适应新硬件的速度,必须修改内核,以充分发挥硬件的性能。
硬件的改变
OS的实现依赖于
- 下层的硬件
- 上层的软件
设计目标
OS
基础:
- 干净的虚拟环境
- 独立硬件
- 资源共享和管理
- 持久化存储
- 安全
- 实时性
- 并行性
- 用户接口
挑战:
- 高性能
- 容易维护
- 高效利用多核
内存管理
基础:
- 提供虚存
- 提供进程换出和换入
- 页表管理
- 缓存和缓冲,加速IO操作
挑战:
- 在内存非常充足时,是否要考虑简化内存管理,以更高效的访问内存
- 如何跨机器来管理共享内存
进程管理
基础:
- 提供单核多处理能力
- 进程上下文切换
挑战:
- 如何同时满足公平,高吞吐,高响应
- 如何减少或者避免上下文切换(由于每次上下文切换,都涉及CPU环境的保存,进程空间的置换等操作,如果过多的切换,会引起很大的性能问题,因此在涉及OS时,应该尽量的减少不必要的上下文切换,把CPU性能发挥的更大)
同步互斥
基础:
- 提供正确的同步互斥原语
挑战:
- 防止死锁
- 如何在多核情况下,更有效的在OS内核层利用共享资源。例如fd的计数,文件系统的设计等。
文件系统
基础:
- 文件组织
- 文件命名
- 文件访问
- 同步
- 安全,保护
挑战:
- 如何兼容不同的文件系统
- 如何提供一致,高可用,可靠的副本在不同的机器上
- 如何处理大容量的文件系统
IO
基础:
- 提供缓存/缓冲
挑战:
- 如何同步内存与文件的内容,来保证一致性和高性能
挑战
性能
随着CPU技术的高速发展,目前已经有在单台机器上,运行1k+多的cores了。
然而对于现有的操作系统,例如Linux/FreeBSD等,在处理多核时,并不如人意。并且随着核数的增加,性能出现不升反降的现象。
原因
在现有的内核中,对共享资源会使用锁操作,来完成并发。这在核数比较少的情况下,可以很好的工作,但是当核数多起来,会导致CPU大部分时间都在抢占锁的session中,导致性能不升反降。
OS架构
单体结构
特点
- 内核某一模块崩溃会引起整个内核不可用
- 高性能,在处理模块之间的调度,可以在内核里完成
设计
- 用户态如果需要执行特权态指令,需要通过系统调用,陷入内核,完成特权指令的操作
- 用户态的进程间需要通讯,通过文件描述符来通讯
微内核结构
特点
- 内核只有少数必要的功能,例如进程间通讯,中断,进程调度
- 大部分功能移到用户态执行,导致低性能,由于多次的上下文切换导致
- 某个用户态的子系统崩溃,系统依然可用
- 好处是灵活,安全,模块化
例子
- Mach是通用微内核结构,可以看到IPC的消息传递非常慢,这是由于多次的上下文切换导致的
- L4也是微内核结构,但是它的体积非常小,仅用于嵌入式设备,通过引入异步的IPC机制,并且针对硬件做特殊化处理,来加速IPC的消息传递。可以看到L4的性能损耗并不大
外核结构
特点
- 通过定制化的内核,实现高性能
- 定制化内核会导致大量的编码实现,需要找到工作量和性能之间的平衡
设计
- 通过直接暴露底层硬件接口给lib,大大的加快了程序运行的效率
- 越多的抽象,会带来越低的性能。因此通过特定的应用定制化的实现,减少不必要的抽象,可以带来更高的性能
- 通过下载用户态程序到内核态运行,减少上下文切换,加快运行效率
挑战
- 安全性如何保证
- 硬件资源如何管理
比较
- 对于通用的getpid,由于通用性和非常高层次的抽象,导致执行getpid的路径非常长,直接引起了性能问题
- Aegis通过定制化实现,减少不必要的代码,再加上缓存技术,大大加快了getpid的执行效率
- 对于通用的exception处理,会引起上下文切换,带来开销
- Aegis由于可以通过用户进程直接访问硬件或者可以下载用户态程序到内核态运行,因此可以减少上下文切换带来的开销,所以运行效率更高
- MPF和PATHFINDER都是高效的TCP处理例程,但由于上下文的切换带来了不必要的开销
- DPF是外核通过下载例程到内核态执行,减少了系统调用的上下文切换,因此加快了运行速度
- 可以看到它们之间并没有太大的区别,这是由于在内存不缺页的情况下,运行效率一样
- 如果内存经常发生缺页,会导致通用OS发生缺页异常处理,引起上下文的切换,进而影响效率。而外核不会出现这种情况
虚拟机
特点
- 在大型机里,通过虚拟技术,充分利用机器的性能
- 虚拟机之间安全隔离
- 有可能有性能问题,依赖于不同的虚拟技术的实现
设计
VM的种类有,系统级别的VM,也有进程级别的VM,而它们又分VM的指令是否与host指令一致。
虚拟VM可以在不同层面作出虚拟,例如进程级别的VM是通过DLL来抽象出来的;
kvm是通过OS层面抽象出来的;
还可以直接通过抽象hardware来直接提供虚拟化技术
要素
- VM运行速度要大大高于仿真的运行
- 资源需要的带安全的隔离
OS的运行原理
- 程序运行在用户态
- 内核运行在内核态
- 程序的普通指令直接交给CPU运行
- 程序的特权指令通过系统调用,由内核完成
下图为系统调用的过程
- 系统调用发生两次上下文切换
VMM工作原理
CPU
- app运行在用户态
- guest OS运行在特权态
- VMM运行在内核态
- app的普通指令直接交给CPUzhixing
- app的特权指令会先陷入VMM,VMM判断是否可以执行,再切换到guest OS,guest os执行完,原路返回。如下图
- 可以看到,系统调用发生了四次上下文切换
内存
- app进程维护一张连续的页表
- guest os维持一张shawor物理内存表
- vmm维持一张真实的物理内存表
- 当app发生缺页时,app会发生缺页异常,vmm会捕获指令并转发给guest os。guest更新tlb表,并被vmm再次捕获,vmm会修改真实的tlb,并原路返回app
IO
OS运行原理
- IO通过中断/DMA等操作来把数据拷贝到内存
VMM工作原理
- 通过软件仿真,vmm把输出从Device Model拷到内存,然后通知App的device,app的device再从vmm的内存拷贝到app的内存
- 通过增加共享内存,减少device的拷贝
虚拟化优化
问题:
- 系统调用带来双倍的上下文切换带来开销
- 缺页异常带来双倍的上下文开销(可以使用大页)
使用更小体积的VM
研究发现linux一般的体积有1G。vm在启动一个1G的Linux的时候,非常慢。使用lightVm(8M)一个体积非常小的os,可以大大加速vmm的处理时间。
减少不必要的系统调用
可以看出,VM的性能下降,更多是因为系统调用发生的双倍上下文切换引起的。那么如果通过设计vmm把硬件的特权指令直接交给进程去执行,就可以减少系统调用,进而实现优化的目的
OS API设计
随着新应用和新场景的出现,会导致某些api变得不常用。新的需求,又会引发需要增加api接口来实现新功能的支持。
对多核的支持,现存的linux api支持的并不好。
例如fd总是返回最小的一个,会导致在分配fd时必须加自旋锁,来保证分配fd的唯一性
OS开发语言
C的缺陷
- 容易发生死锁,例如在屏蔽中断的时候,调用kmalloc(一个会睡眠的指令),导致系统死锁
- kmalloc返回Null,而没有判空,导致空指针异常
- free掉的指针,依然继续使用,野指针
- 数组容易越界
- 数组固定大小分配,导致很难扩展
由于C的这些特点,使得Linux内核的bug数量一直维持在高水平
当然C可以作为内核语言,它的优势是足够灵活和高效,几乎可以使用所有场景
Go语言
作为高级语言,它可以有效的解决C带来的缺陷,但同时,为了解决这些缺陷,也带来了性能的开销。
它的垃圾回收机制,也同时引起了性能的问题。
但是bug的数量会大大减少。
因此在考虑linux内核的语言时,看性能和安全的取舍。
Rust语言
它同时具备高性能和高安全的特点。但是学习曲线比较高
小结
在现有几乎所有的linux都是使用C来编写,即使高级语言有很多好处,但是替换C不是一朝一夕的事情,很多问题有待实现和观察。
内存模型
多核处理器
由于单核CPU受限于功耗和设计复杂度等因素,导致很难再在单核上优化CPU,因此多核成为了加快CPU的一种途径。
处理器缓存结构
对于单个CPU,内部会有多个核组成。每个核拥有自己的L1 cache。而对于L2 cache,可以是共享和单独占有两种类型。对于共享cache的好处是数据一致性处理更简单,但是会有空间冲突的不好;对于独占cache的好处是不会有冲突,但是在保证一致性方面需要进一步的处理。
NUMA架构
NUMA是由多个CPU组成的,而每个CPU拥有多个核,每个CPU有自己的cache,不同的CPU拥有不同的时钟周期。
对于每个cache,是全局可见的,由于时钟周期不一样,这导致在跨CPU访问cache会有更大的开销
OS在NUMA上的表现
在实践测试的过程中发现,在核数增长到一定程度,机器的性能会出现不升反降的现象。原因是OS内部,对共享资源有大量的同步互斥原语的控制,这直接造成了OS无法做到水平扩展。
cache一致性
每个CPU拥有自己的cache,这导致在多线程处理同一个变量的时候,这个变量同时位于不同的CPU的cache中,如果此时一个线程改变了这个共享变量的值,那么此时就必须通知另外一个cache,来更新这个值,来保证一致性。
解决方法一
共用cache:可以解决数据同步的问题。但是这导致一个时钟周期只有一个CPU可以操作cache,这会带来严重的性能问题。
解决方法二
加入同步方案
同步准则
- 单写者,多读者
- 每次读的值都是跟最后一次写的值一致
MSI一致性协议
- I是invalid,数据失效
- S是shared,数据读共享
- M是Modified,数据独占修改
I->S:CPU请求读,数据来源内存或者其他cache,占用总线
S->M:CPU请求写,发送数据失效Invalid到其他cache,令其他cache S->I,占用总线
M->S:CPU写完成
S->I:其他CPU请求写
MESI一致性协议
从MSI可以看到,如果一个cache从I->M(失效->修改)的转变需要占用总线两次,这在总线占用的开销来说比较昂贵。
MESI在MSI的基础上增加了E(独占)状态
I->E:数据总线发送invalid,占用总线
E->M:修改数据,不发送任何消息
来实现I->M只占用总线一次来加快效率
cache store buffer
由于CPU的速度远远高于cache,在CPU触发多次写的时候,cache可能来不及接收修改的内容
这是就引入了store buffer来解决这个问题。
但同时Store buffer也带来了不一致性,需要增加在同步数据的时候增加读Store buffer的步骤来解决数据一致性的问题。
Invalidate Queue
由于当CPU需要修改cache,会发送Invalid消息到其他CPU,按照MSI协议,CPU需要等待其他CPU返回ACK才开始操作cache。但由于考虑到执行效率的情况,CPU并不会等待ACK,而是马上执行修改cache的操作。
这就导致CPU的执行有可能出现数据不一致的问题。
memory barrier
为了解决Invalid queue引发的不一致性问题,CPU引入barrier的操作,对于特殊变量,可以通过barrier执行清空queue和store buffer,来严格保证数据一致性。
但同时barrier会带来性能下降的问题。
因此只有在特殊情况下才使用barrier,来保证特殊变量的一致性
其他情况下CPU允许不一致和局部乱序的执行代码。
- (x, y) == (0, 0) 不可能出现
- delete fence
- (x, y) == (0, 0) 是有可能出现的,由于x,y位于不同的cache,如果invalid queue没来得及处理,r1,r2就已经赋值了,就会出现都等于0的情况
编译优化
对于上述代码,即使在单核处理器上,assert也有可能是false。
这是由于在编译优化的过程中,编译器会判断程序的两条指令是否有数值关联,如果没有,就会根据运行效率,来调整两条语句的顺序,来达到优化的目的。
通过加入fence操作,解决了上述问题
no fence共享变量
对于两个线程的共享变量,如果没有加fence操作,有可能总线并不足够在一次传送可以送完整个变量的值,从而导致数据错乱的情况。
通过加入fence操作,保证了变量的一致性
Release-Acquire
通过加入原子操作,使得两个线程可以按顺序的执行
MCS lock
普通的spin lock,由于在多个CPU共享同一个atomit变量,造成,在fence同步时,开销非常大,MCS lock通过把atomit变量分散到不同cache里面,来解决这个问题。
每个lock维持自己的一个自旋变量,当上一个自旋锁释放时,会更新链表的下一个元素的自旋变量,来把下一个锁打开。
RCU
OS内存在大量链表操作,在修改链表时,如果每次都把整个链表锁了,会引发非常大的开销。
通过RCU可以有效的解决这个问题。
- 把需要修改的node拷贝一份出来
- 修改拷贝的变量
- 置换链表指针
- 等所有读者读完,删除旧的node。