20.4 Biscuit

接下来我将对Biscuit稍作介绍,包括了Biscuit是如何工作的,以及在实现中遇到的问题。其中有些问题是预期内的,有些问题不在预期之内。

就像Linux和XV6一样,Biscuit是经典的monolithic kernel。所以它也有用户空间和内核空间,用户空间程序可能是你的编译器gcc,或者论文中主要用到的webserver。这里用户空间程序主要用C实现,尽管原则上它可以是任何编程语言实现的,但是因为这里只是性能测试,我们这里统一选用的是C版本的应用程序。大部分用户程序都是多线程的,所以不像在XV6中每个用户程序只有一个线程,在Biscuit中支持用户空间的多线程。基本上,对于每个用户空间线程,都有一个对应的位于内核的内核线程,这些内核线程是用Golang实现的,在Golang里面被称为goroutine。你可以认为goroutine就是普通的线程,就像XV6内核里的线程一样。区别在于,XV6中线程是由内核实现的,而这里的goroutine是由Go runtime提供。所以Go runtime调度了goroutine,Go runtime支持sleep/wakeup/conditional variable和同步机制以及许多其他特性,所以这些特性可以直接使用而不需要Biscuit再实现一遍。

Biscuit中的Go runtime直接运行在硬件上,稍后我将介绍更多这部分内容,但是你现在可以认为当机器启动之后,就会启动Go runtime。这里会稍微复杂,因为Go runtime通常是作为用户空间程序运行在用户空间,并且依赖内核提供服务,比如说为自己的heap向内核申请内存。所以Biscuit提供了一个中间层,使得即使Go runtime运行在裸机之上,它也认为自己运行在操作系统之上,这样才能让Go runtime启动起来。

Biscuit内核本身与XV6非常相似,除了它更加的复杂,性能更高。它有虚拟内存系统可以实现mmap,有更高性能的文件系统,有一些设备驱动,比如磁盘驱动,以及网络协议栈。所以Biscuit比XV6更加完整,它有58个系统调用,而XV6只有大概18-19个系统调用;它有28000行代码,而XV6我认为只有少于10000行代码。所以Biscuit有更多的功能。

学生提问:这里的接口与XV6类似对吧,所以进程需要存数据在寄存器中,进程也会调用ECALL。

Frans教授:我稍后会再做介绍,但是这里完全相同。

以上是Biscuit的特性,有些我已经提到过了。

  • 首先它支持多核CPU。Golang对于并发有很好的支持,所以Biscuit也支持多核CPU。类似的,XV6却只对多核CPU有有限的支持。所以在这里,我们相比XV6有更好的同步协调机制。

  • 它支持用户空间多线程,而XV6并没有。

  • 它有一个相比XV6更高性能的Journaled File System(注,Journaled就是指log,可以实现Crash Recovery)。如果你还记得EXT3论文,它与EXT3的Journaled File System有点类似。

  • 它有在合理范围内较为复杂的虚拟内存系统,使用了VMAs并且可以支持mmap和各种功能。

  • 它有一个完整的TCP/IP栈,可以与其他的服务器通过互联网连接在一起。

  • 它还有两个高性能的驱动,一个是Intel的10Gb网卡,以及一个非常复杂的磁盘驱动AHCI,这比virtIO磁盘驱动要复杂的多。

Biscuit支持的用户程序中:

  • 每个用户程序都有属于自己的Page Table。

  • 用户空间和内核空间的内存是由硬件隔离的,也就是通过PTE的User/Kernel bit来区分。

  • 每个用户线程都有一个对应的内核线程,这样当用户线程执行系统调用时,程序会在对应的内核线程上运行。如果系统调用阻塞了,那么同一个用户地址空间的另一个线程会被内核调度起来。

  • 如之前提到的,内核线程是由Go runtime提供的goroutine实现的。如果你曾经用Golang写过用户空间程序,其中你使用go关键字创建了一个goroutine,这个goroutine就是Biscuit内核用来实现内核线程的goroutine。

来看一下系统调用。就像刚刚的问题一样,这里的系统调用工作方式与XV6基本一致:

  • 用户线程将参数保存在寄存器中,通过一些小的库函数来使用系统调用接口。

  • 之后用户线程执行SYSENTER。现在Biscuit运行在x86而不是RISC处理器上,所以进入到系统内核的指令与RISC-V上略有不同。

  • 但是基本与RISC-V类似,控制权现在传给了内核线程。

  • 最后内核线程执行系统调用,并通过SYSEXIT返回到用户空间。

所以这里基本与XV6一致,这里也会构建trapframe和其他所有的内容。

学生提问:我认为Golang更希望你使用channel而不是锁,所以这里在实现的时候会通过channel取代之前需要锁的场景吗?

Frans教授:这是个好问题,我会稍后看这个问题,接下来我们有几页PPT会介绍我们在Biscuit中使用了Golang的什么特性,但是我们并没有使用太多的channel,大部分时候我们用的就是锁和conditional variable。所以某种程度上来说Biscuit与XV6的代码很像,而并没有使用channel。我们在文件系统中尝试过使用channel,但是结果并不好,相应的性能很差,所以我们切换回与XV6或者Linux类似的同步机制。

在实现Biscuit的时候有一些挑战:

  • 首先,我们需要让Go runtime运行在裸机之上。我们希望对于runtime不做任何修改或者尽可能少的修改,这样当Go发布了新的runtime,我们就可以直接使用。在我们开发Biscuit这几年,我们升级了Go runtime好几次,所以Go runtime直接运行在裸机之上是件好事。并且实际上也没有非常困难。Golang的设计都非常小心的不去依赖操作系统,因为Golang想要运行在多个操作系统之上,所以它并没有依赖太多的操作系统特性,我们只需要仿真所需要的特性。大部分这里的特性是为了让Go runtime能够运行起来,一旦启动之后,就不太需要这些特性了。

  • 我们需要安排goroutine去运行不同的应用程序。通常在Go程序中,只有一个应用程序,而这里我们要用goroutine去运行不同的用户应用程序,这些不同的用户应用程序需要使用不同的Page Table。这里困难的点在于,Biscuit并不控制调度器,因为我们使用的是未经修改过的Go runtime,我们使用的是Go runtime调度器,所以在调度器中我们没法切换Page Table。Biscuit采用与XV6类似的方式,它会在内核空间和用户空间之间切换时更新Page Table。所以当进入和退出内核时,我们会切换Page Table。这意味着像XV6一样,当你需要在用户空间和内核空间之间拷贝数据时,你需要使用copy-in和copy-out函数,这个函数在XV6中也有,它们基本上就是通过软件完成Page Table的翻译工作。

  • 另一个挑战就是设备驱动,Golang通常运行在用户空间,所以它并不能从硬件收到中断。但是现在我们在裸机上使用它,所以它现在会收到中断,比如说定时器中断,网卡中断,磁盘驱动中断等等,我们需要处理这些中断。然而在Golang里面并没有一个概念说是在持有锁的时候关闭中断,因为中断并不会出现在应用程序中,所以我们在实现设备驱动的时候要稍微小心。我们采取的措施是在设备驱动中不做任何事情,我们不会考虑锁,我们不会分配任何内存,我们唯一做的事情是向一个非中断程序发送一个标志,之后唤醒一个goroutine来处理中断。在那个goroutine中,你可以使用各种各样想要的Golang特性,因为它并没有运行在中断的context中,它只是运行在一个普通goroutine的context中。

  • 前三个挑战我们完全预料到了,我们知道在创造Biscuit的时候需要处理它们,而最难的一个挑战却不在我们的预料之中。这就是heap耗尽的问题。所以接下来我将讨论一下heap耗尽问题,它是什么,它怎么发生的,以及我们怎么解决的?

Last updated