# 18.5 Improving IPC by Kernel Design

接下来我们讨论微内核里面一个非常重要的问题：IPC的速度。首先让我展示一个非常简单，但是也非常慢的设计。这个设计基于Unix Pipe。我之所以介绍这种方法，是因为一些早期的微内核以一种类似的方式实现的IPC，而这种方式实际上很慢。

假设我们有两个进程，P1和P2，P1想要给P2发送消息。这里该怎么工作呢？一种可能是使用send系统调用，传入你想将消息发送到的线程的ID，以及你想发送消息的指针。这个系统调用会跳到内核中，假设我们是基于XV6的pipe来实现，那么这里会有一个缓存。或许P2正在做一些其他的事情，并没有准备好处理P1的消息，所以消息会被先送到内核的缓存中。所以当你调用send系统调用，它会将你的消息追加到一个缓存中等待P2来接收它。在实际中，几乎很少情况你会只想要发送一个消息，你几乎总是想要能再得到一个回复。所以P1在调用完send系统调用之后，会立即调用recv来获取回复。但是现在让我们先假设我们发送的就是单向的IPC消息，send会将你的消息追加到位于内核的缓存中，我们需要从用户空间将消息逐字节地拷贝到内核的缓存中。之后再返回，这样P1可以做一些其他的事情，或许是做好准备去接受回复消息。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXagfTLxHWC0mhPNg5S%2F-MXb4THOG9IFV2gx_n7y%2Fimage.png?alt=media\&token=013f10d3-9f32-42a0-8c5f-9f7e4b20d558)

过了一会，P2可以接收消息了，它会调用recv系统调用，这个系统调用会返回发送消息线程的ID，并将消息从内核拷贝到P2的内存中。所以这里会从内核缓存中取出最前的消息，并拷贝到P2的内存中，之后再返回。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXagfTLxHWC0mhPNg5S%2F-MXb58rm-JLSUcmR3OvX%2Fimage.png?alt=media\&token=1e09704a-885f-4df4-a452-ccc9356c251d)

这种方式被称为异步传输，因为P1发完消息之后，只是向缓存队列中追加了一条消息，并没有做任何等待就返回了。同时这样的系统这也被称作是buffered system，因为在发送消息时，内核将每条消息都拷贝到了内部的缓存中，之后当接收消息时，又从buffer中将消息拷贝到了目标线程。所以这种方法是异步buffered。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXagfTLxHWC0mhPNg5S%2F-MXb7MaGVYdO0PbfOOsQ%2Fimage.png?alt=media\&token=4eac02d2-1b89-4cb2-b7c9-170d99fadc56)

如果P1要完成一次完整的消息发送和接收，那么可以假设有两个buffer，一个用来发送消息，一个用来接收消息。P1会先调用send，send返回之后。之后P1会立即调用recv，recv会等待接收消息的buffer出现数据，所以P1会出让CPU。在一个单CPU的系统中，只有当P1出让了CPU，P2才可以运行。论文中的讨论是基于单CPU系统，所以P1先执行，之后P1不再执行，出让CPU并等待回复消息。这时，P2才会被调度，之后P2调用recv，拷贝消息。之后P2自己再调用send将回复消息追加到buffer，之后P2的send系统调用返回。假设在某个时间，或许因为定时器中断触发导致P2出让CPU，这时P1可以恢复运行，内核发现在接收消息buffer有了一条消息，会返回到用户空间的P1进程。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXagfTLxHWC0mhPNg5S%2F-MXbAdzYVCKjwImrf-6q%2Fimage.png?alt=media\&token=566d8c3e-00f6-4c6b-ae8f-7a678da704cb)

这意味着在这个慢的设计中，为了让消息能够发送和回复，将要包含：

* 4个系统调用，两个send，两个recv
* 对应8次用户空间内核空间之间的切换，而每一次切换明显都会很慢
* 在recv的时候，需要通过sleep来等待数据出现
* 并且需要至少一次线程调度和context switching来从P1切换到P2

每一次用户空间和内核空间之间的切换和context switching都很费时，因为每次切换，都需要切换Page Table，进而清空TLB，也就是虚拟内存的查找缓存，这些操作很费时。所以这是一种非常慢的实现方式，它包含了大量的用户空间和内核空间之间的切换、消息的拷贝、缓存的分配等等。实际中，对于这里的场景：发送一个消息并期待收到回复，你可以抛开这种方法并获得简单的多的设计，L4就是采用了后者。

有关简单的设计在一篇著名的论文中有提到，论文是[Improving IPC by Kernel Design](https://www.cse.unsw.edu.au/~cs9242/19/papers/Liedtke_93.pdf)，这篇论文在今天要讨论的论文前几年发布。相比上面的慢设计，它有几点不同：

* 其中一点是，它是同步的（Synchronized）。所以这里不会丢下消息并等待另一个进程去获取消息，这里的send会等待消息被接收，并且recv会等待回复消息被发送。如果我是进程P1，我想要发送消息，我会调用send。send并不会拷贝我的消息到内核的缓存中，P1的send会等待P2调用recv。P2要么已经在内核中等待接收消息，要么P1的send就要等P2下一次调用recv。当P1和P2都到达了内核中，也就是P1因为调用send进入内核，P2因为调用recv进入内核，这时才会发生一些事情。这种方式快的一个原因是，如果P2已经在recv中，P1在内核中执行send可以直接跳回到P2的用户空间，从P2的角度来看，就像是从recv中返回一样，这样就不需要context switching或者线程调度。相比保存寄存器，出让CPU，通过线程调度找到一个新的进程来运行，这是一种快得多的方式。P1的send知道有一个正在等待的recv，它会立即跳转到P2，就像P2从自己的recv系统调用返回一样。这种方式也被称为unbuffered。它不需要buffer一部分原因是因为它是同步的。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXagfTLxHWC0mhPNg5S%2F-MXbHYvr-Y5wGhNMOBxA%2Fimage.png?alt=media\&token=841fcc19-cdd7-4cf9-985e-63f22db08649)

* 当send和recv都在内核中时，内核可以直接将消息从用户空间P1拷贝到用户空间P2，而不用先拷贝到内核中，再从内核中拷出来。因为现在消息收发的两端都在等待另一端系统调用，这意味着它们消息收发两端的指针都是确定的。recv会指定它想要消息被投递的位置，所以在这个时间点，我们知道两端的数据内存地址，内核可以直接拷贝消息，而不是需要先拷贝到内核。
* 如果消息超级小，比如说只有几十个字节，它可以在寄存器中传递，而不需要拷贝，你可以称之为Zero Copy。前面说过，发送方只会在P2进入到recv时继续执行，之后发送方P1会直接跳转到P2进程中。从P1进入到内核的过程中保存P1的用户寄存器，这意味着，如果P1要发送的消息很短，它可以将消息存放到特定的寄存器中。当内核返回到P2进程的用户空间时，会恢复保存了的寄存器，这意味着当内核从recv系统调用返回时，特定寄存器的内容就是消息的内容，因此完全不需要从内存拷贝到内存，也不需要移动数据，消息就存放在寄存器中，可以非常快的访问到。当然，这只对短的消息生效。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXbHvjpTsFr28yDE4a9%2F-MXgJ60wirrdZcEEzMy1%2Fimage.png?alt=media\&token=50e304bb-cfba-4310-b729-4a0f8abb38f0)

* 对于非常长的消息，L4可以在一个IPC消息中携带一个Page映射，所以对于巨大的消息，比如说从一个文件读取数据，你可以发送一个物理内存Page，这个Page会被再次映射到目标Task地址空间，这里也没有拷贝。这里提供的是共享Page的权限。所以短的消息很快，非常长的消息也非常快。对于长的消息，你需要调整目的Task的Page Table，但是这仍然比拷贝快的多。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXbHvjpTsFr28yDE4a9%2F-MXgKbLEILFmniweH2jF%2Fimage.png?alt=media\&token=bd91a556-0e08-4a69-902f-eb17d124aa35)

* 最后一个L4使用的技巧是，如果它发现这是个RPC，有request和response，并且有非常标准的系统调用包括了send和recv，你或许会结合这两个系统调用，以减少用户态和内核态的切换。所以对于RPC这种特别的场景，同时也是人们使用IPC的一个常见场景，有一个call系统调用，它基本上结合了send和recv，区别是这里不会像两个独立的系统调用一样，先返回到用户空间，再次进入到内核空间。在消息的接收端，会有一个sendrecv系统调用将回复发出，之后等待来自任何人的request消息。这里基本是发送一个回复再加上等待接收下一个request，这样可以减少一半的内核态和用户态切换。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXbHvjpTsFr28yDE4a9%2F-MXgS5nz_-IqkIniNx3l%2Fimage.png?alt=media\&token=3210a679-43ca-40d4-8a07-aeb434690e2e)

实际中，所有的这些优化，对于短的RPC请求这样一个典型的场景，可以导致20倍速度的提升。这是论文中给出的对比之前慢设计提升的性能倍数。这个数字很了不起。Improving IPC by Kernel Design这篇论文是由今天这篇论文的同一个作者在前几年发表的，因为现在IPC可以变得非常的快，它使得人们可以更加认同微内核。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MXbHvjpTsFr28yDE4a9%2F-MXgTV2ldpCNR3Aqitf4%2Fimage.png?alt=media\&token=df6a63f7-2487-49ae-8013-e9655abbf9d9)

> 学生提问：当使用这些系统调用时，进程是什么时候发送和接收消息的？
>
> Robert教授：对于包含request和response的RPC，进程使用call和sendrecv这一对系统调用，而不是send和recv。对于call，你会传入两个参数，你想要发送的消息，以及你要存放回复消息的位置，这个系统调用在内核中会结合发送和接收两个功能。你可以认为这是一种hack，因为IPC使用的是如此频繁，它值得一些hack来使得它变得更快。
>
> 学生提问：在上面的图中，P2会调用recv系统调用，P2怎么知道应该去调用这个系统调用？
>
> Robert教授：在RPC的世界中，我们有client会发送request到server，server会做一些事情并返回。因为P2是一个server，我们会假设P2会一直在一个while循环中，它随时准备从任何client接收消息，做一些数据处理工作，比如在数据库中查找数据，之后再发送回复，然后再回到循环的最开始再等待接收消息。所以我们期望P2将所有时间都花费在等待从任何一个客户端接收消息上。前面讨论的设计需要依赖P2进程在暂停运行时，一直位于内核的recv系统调用中，并等待下一个request。这样，下一个request才可以直接从这个系统调用返回，这种快速路径在这里的设计中超级有效率。
>
> 学生提问：这里提到从P1返回到P2，为了能返回到P1，需要P2发送response吗？
>
> Robert教授：是的，我们期望P2发送一个response，发送response与发送request是同一个代码路径，只是方向相反（之前是P1到P2现在是P2到P1），所以当P2发送一个response，这会导致返回到P1。P1实际调用的是call系统调用，通过从call系统调用返回到P1，会将P2的response送到P1。
>
> 这里与你们以为的通常的设置略有不同，通常情况下，你从P1通过系统调用进入到内核，在内核中执行系统调用然后再返回，所有的工作都在P1这边，这也是pipe的read/write的工作方式。在这里，P1进入到内核，但是却返回到了P2。所以这里有点奇怪，但是却非常的快。
