本文基于OSDI18论文《The benefits and costs of writing a POSIX kernel in a high-level language》的理解整理而成。从第1节到4节沿着论文作者思路介绍a)为什么要使用高级语言编写Kernel;b)使用Go编写Biscuit在开发效率,系统安全性和性能有哪些优缺点;c)最终Biscuit与Linux Kernel的性能对比分析。最后第5节是本文作者对论文的总结:基于C vs Go编写Kernel,结合安全性、性能和复杂性3方面数据,在追求安全性和系统创新前提下,Rust语言比Go更有优势。

论文:https://www.usenix.org/system/files/osdi18-cutler.pdf
ppt: https://www.usenix.org/sites/default/files/conference/protected-files/osdi18_slides_cutler.pdf
github: https://github.com/mit-pdos/biscuit

1. 问题背景

最初编写OS的语言只有汇编语言,但随着OS的复杂性增加,汇编语言慢慢暴露出它的不足。丹尼斯·里奇开发C语言,并编写Unix操作系统。从此C一战成名,几乎成为OS领域的唯一编程语言。
 
随着计算机硬件发展,CPU性能变强,内存容量极大提升,人们对性能的诉求通过硬件高速发展得到弥补,研究人员开发反过来思考是否可以使用安全的、高级编程语言开发操作系统。牺牲高性能,换取系统安全性和更好的开发效率。
 
论文作者使用Go语言设计Biscuit kernel(一个POSIX Kernel)作为案例,对比Go和C编写Kernel的开发效率,安全性和性能。

2. 动机:为什么使用高级语言编写kernel

高级语言提供较好的语法抽象、运行抽象,kernel开发更方便。高级语言比C更接近人类思考方式,易于理解。提供自动内存管理机制,让开发人员不用考虑复杂的内存管理,特别在多线程场景下对象的同步释放工作,也能避免释放后再使这个安全问题。语言支持多线程机制,让并行编程和同步更简单。
高级语言能减少CVE安全漏洞。C语言宪章思想是把自由交给程序员,相信他们知道自己在做什么事情。然而即使是非常有经验的老手,都无法避免C语言典型的缓冲区溢出,释放后使用,任意的类型强转等各种各样的问题。CVE数据库披露Linux Kernel在2017年有40个关于代码执行的安全漏洞,如果用高级语言则能彻底避免,或者部分减轻问题的影响。
高级语言让并发编程更容易。高级编程语言提供垃圾回收机制,并行编程时,无需考虑生命周期结束后多个线程之间的同步释放。
高级语言的缺点。垃圾回收减轻开发人员负担,但它不是没有代价,垃圾回收机制引入的开销和时延抖动。语言的安全性,自然在运行时增加安全检测,CPU运行开销增加。高级语言提供的runtime自身已引入一些关键性机制,比如内存管理,线程调度机制,开发人员在使用它开发kernel时,必须与这些机制兼容,造成kernel方案设计可选余地变少。

3. Biscuit设计与实现

从分析高级编程语言对Kernel的影响来说,不需要深入分析Biscuit设计与实现。所以这里只是粗略介绍Biscuit设计方案,重点体现为什么会有这样的选择,以及Go语言对Biscuit设计的影响。

3.1 Biscuit整体结构

Biscuit首先是个宏内核,提供部分POSIX接口,它架整与传统的POSIX宏内存没有太多区别,它的结构图如下:

Biscuit是POSIX内核,支持多进程和线程。Kernel核心部分是Biscuit组件,由于采用Go语言编写,所以它之下还有Go runtime。而Go runtime原来是运行在Linux用户态,为了弥补底层功能的缺失,论文作者实现一个比较轻量的shim层,满足Go runtime的功能依赖。

下面简单介绍Biscuit进程、线程模型,中断模型,文件系统,网络协议栈,垃圾回收器的实现方案。

3.2 进程和kernel Goroutine

Biscuit进程模型与其它POSIX内核没有太大的差异,支持fork, execve系统调用,每进程有单独的地址空间,通过硬件页表隔离,进程可以有一个或多个线程。

Biscuit用户态线程与内核态线程采用1:1的模型,即每个用户态线程都对应一个内核Goroutine(在Go运行环境和术语中,routine也称为线程)。用户态线程执行系统调用,或产生page fault后,都在陷入内核,委托对应的kernel Goroutine执行相应的系统调用和异常处理逻辑。

Biscuit的线程调度完全用go routine调度器掌管,在go runtime看来,用户态线程只是Goroutine跑在user mode而已。Go runtime的调度机制采用预抢占调度机制,在编译阶段预先生成抢占点,当Kernel Goroutine运到该抢点占时,就会发生调度。

Go提供Goroutine和调度功能,Biscuit无法逃脱Goroutine的约束。优点是快速开发Kernel功能,缺点是无法做精细化的调用策略控制和理论创新。

3.3 中断

Go runtime原先的设计是运行在用户态,没有中断概念一说,所以它在运行过程中不会关中断。所以Biscuit中断处理函数不能太复杂,比如加锁则会造成死锁。因此,Biscuit中断方案是中断线程化,设备中断触发后只是对中断线程做个标记,中断完成后才唤醒对应的中断线程(Goroutine),然后在进程上下文执行中断例程代码。

3.4 文件系统

Biscuit实现主要的POSIX文件系统访问接口,实现日志型文件系统,提供批处理能力,文件可靠性和性能有一定保证。实现ACHI磁盘驱动,该驱动使用DMA和MSI机制。

3.5 协议栈

实现TCP/IP协议栈,一款Intel PCIE网卡驱动,支持DMA和MSI机制,同时提供POSIX的socket接口。

3.6 垃圾回收

Biscuit复用Go runtime提供的垃圾回收功能,这意味着只要将一块内存给Go runtime管理,Biscuit直接向Go runtime申请各类kernel对象即可,也无须释放,只要无指针引用,垃圾回收器会在适当的时机对它做回收。借用Go语言的内存管理机制,大大简化了Biscuit的实现和代码量。

Biscuit利用Go 1.0 runtime提供的多核并行“标志-清除”垃圾回收器,减少垃圾回收过程对业务暂停时间。该时间与系统存活对象数量成正比,而与回收周期成反比。

3.7 Biscuit代码和实现

Biscuit kernel基本由Go语言编写,Go语言有27,583 行,汇编有1546,完全没有C代码,下图展示各组件的代码构成。

Biscuit实现58个系统调用,对于Linux程序nginx和redis已经够用。Biscuit实现磁盘驱动和网卡驱动,需要访问硬件DMA地址空间,需要使用unsafe.Pointer 访问寄存器,网络报文,物理页内存,用户态内存,使用atomic package控制内存访问顺序。

Biscuit的一个设计原则是尽管不修改Go runtime代码,而runtime在运行过程加锁时并没有关闭中断,因此为了避免死锁,Biscuit在中断只是打上标记,中断退出后,再唤醒中断服务线程。

Biscuit的调度也完全由Go runtime掌管,无法实现优先级调用策略。在Goroutine上下文切换时,无法更换硬件页表,只能在返回用户前切换页表。线程在切换后,返回户用态前需要访问用户态内存时,使用软件查页表找到物理地址,然后软件权限检查,最后才内存访问。

4. 性能评估

论文作者开篇就提出使用高级语言开发kernel的好处和代价,设计Biscuit仅仅是作为实验从各维度评估Go vs C开发Kernel的好处和代价。评估分为以下几个方面:

  1. Biscuit横向与其它项目相比,使用Go语言的特性分析,想说明使用高级语言能给Kernel提高开发效率
  2. 安全性,高级语言编写kernel能有效减少安全漏洞
  3. Go语言高级特性给开发kernel带来怎样的性能耗损

4.1 Go特性在Biscuit使用情况

论文作者横向对比3个采用Go语言开发项目:Biscuit, Golang和Moby,分析项目中每1000行代码使用的特性数,结果如下图:

从图上可以看到3个项目较多使用Go的内存管理机制,避去复杂的对象生命周期管理。使用Slice,String,Multi-return和Closure,更多像语法糖,提升代码生产率,以及减少编码出错;Defer方便处理资源释放。Channel用于Goroutine线程同步数据。

4.2 Biscuit能减少哪些CVE漏洞

CVE数据库披露Linux Kernel社区在2017年一共有65个公共漏洞,其中11个bug不确定在Biscuit是否能避免,还有14个bug属于逻辑问题,在Biscuit也会出现。最后剩下的40个bug与内存访问相关,有释放后使用,重复释放,下标溢出访问这3类。相比之下,Biscuit比Linux Kernel有更好的安全性,因为Go语言能在运行防止不安全内存访问,即时发生运行错误,避免进一步攻击。

4.3 Biscuit性能分析

论文作者将Linux Kernel不太相关的功能组件关闭(比如cgroup,随机地址化,透明大页,零拷贝,ftrace, kprobe),让Biscuit和Linux Kernel执行路径大致相同。运行nginx和redis两个服务程序进行性能对比,结果如下:
client和server采用ping-pong测试模式时,C语言比Go性能高15%,专门针对Page fault做性能测试时,发现C比Go性能高5%。对Go语言来说,由于垃圾回收的存在,对性能影响也不可忽略,垃圾回收占CPU开销的1%~3%,致命的是它影响业务时延,造成业务单次请求的最大时延在574ms。

5. 思考和借鉴

论文作者使用Go语言开发Biscuit仅仅做为一个实验,帮助OS设计者去理解、剖析高级语言编写Kernel需要考虑哪些维度。高级编程语言,提供更好的抽象能力,类型安全和内存安全,此外像Go语言还提供很多非常强大的功能:协程、垃圾回收、多线程同步原言,大大简化kernel开发工作量。Biscuit项目引入Go语言,因其类型安全和内存安全,系统运行更安全,性能下降15%,用安全性换取性能,在安全优先的场景下是值得的。此外,Go语言提供Goroutine机制和带垃圾回收器的内存管理方式,却让Biscuit kernel在线程调度上强依赖于Go runtime的调度机制,无法做调度算法上的创新,垃圾回收的内存管理方式,让Biscuit在内核内存耗尽情况下,也大费力气解决,完全无法掌整个kernel的核心设计,这是Go语言开发kernel的不足

是不是所有高级语言都与Go语言大同小异的,其实还不是,Rust语言是最近系统级编程语言的新星。Rust与Go不同,它不支持垃圾回收机制,它的设计哲学就认为垃圾回收很难做到高性能。Rust没有垃圾回收功能,并不意味内存就不安全了,它提供所有权,借用和引用机制,让开发人员编写内存安全的程序,并支多线程并发安全访问。Rust的好处是提供类型安全和内存安全,多线程内存安全访问机制,但没有了Go的runtime的约束,让Kernel开发人员聚焦OS架构和方案创新

采用C语言开发Kernel,尽管可以获得很高的性能,但不可避免地引入很多安全问题。而采用像Go一样,带垃圾回收,协程调度等厚重的语言机制,尽管可以高效编写kernel,但也需要为这些机制付出代价。而Rust这类的高级语言,提供类型安全和内存安全机制,并没有runtime的束缚,让Kernel开发有更大创新空间。