20.6 Heap exhaustion solution

Biscuit的解决方案非常直观,当应用程序执行系统调用,例如read,fork时,在系统调用的最开始,跳转到内核之前,它会先调用reserve函数,reserve函数会保留足够的内存以运行系统调用。所以reserve会保留足够这个系统调用使用的空闲内存,以使得系统调用总是能成功。所以一旦系统调用被执行,且保留了足够的内存,那么它就可以一直运行而不会有内存耗尽和heap exhaustion的问题。

如果reserve函数执行时没有足够的内存,那么程序会在这里等待。因为现在在系统调用的最开始,系统调用现在还没有持有任何的锁,也没有持有任何的资源,所以在这里等待完全没有问题,也不会有死锁的风险。当程序在等待的时候,内核可以撤回cache并尝试在heap增加空闲空间,比如说kill一个进程来迫使释放一些内存。一旦内存够用了,并且内核决定说是可以满足需要保留的内存,之后内核会让系统调用继续运行,然后执行系统调用需要的操作。

在最后,当系统调用完成的时候,所有之前保留的内存都返回到池子中,这样后续的系统调用可以继续使用。

这个方案中有一些很好的特性:

  • 在内核中没有检查。你不需要检查内存分配是否会失败,在我们的例子中这尤其得好,因为在Golang中内存分配不可能会失败。

  • 这里没有error handling代码。

  • 这里没有死锁的可能,因为你在最开始还没有持有锁的时候,就避免了程序继续执行。

当然,现在的问题是如何实现reserve函数,你如何计算运行一个系统调用会需要多少内存?

你保留的内存数量是重要的,你可以为每个系统调用保留一半的内存或者一些其他夸张的内存数量。但是这意味着你限制了可以并发执行的系统调用的个数,所以你这里尽量精确地计算一个系统调用的内存边界。

这里的解决方法是使用了高级编程语言的特性。Golang实际上非常容易做静态分析,Go runtime和Go生态里面有很多包可以用来分析代码,我们使用这些包来计算系统调用所需要的内存。所以你可以想象,如果你有一个read系统调用,我们可以通过系统调用的函数调用图查看比如函数f调用函数g调用函数h等等等等。我们可以做的是弄清楚这里调用的最大深度,对于最大的深度,计算这里每个函数需要的内存是多少。

比如说函数f调用了new,因为这是一个高级编程语言,我们知道new的对象类型,所以我们可以计算对象的大小。我们将所有的new所需要的内存加起来,得到了一个总和S,这就是这个调用图(或者说系统调用)任何时间可能需要的最大内存。

实际中并没有这么简单,会有点棘手。因为函数h可能会申请了一些内存,然后再回传给函数g。所以当h返回时,g会得到h申请的一些内存。这被称为escaping,内存从h函数escape到了函数g。

存在一些标准算法来完成这里的escape分析,以决定哪些变量escape到了函数调用者。当发生escape时,任何由函数h申请的内存并且还在函数g中存活,我们需要将它加到函数g的内存计数中,最后加到S中。

学生提问:某些函数会根据不同的工作负载申请不同量的内存,那么在计算函数消耗的内存时,会计算最差的情况吗?

Frans教授:是的。这里的工具会计算最深函数调用时最大可能使用的内存量。所以它会计算出每个系统调用可能使用的最多内存,虽然实际中系统调用可能只会使用少的多的内存。但是保险起见,我们会为最坏情况做准备。一些系统调用内的for循环依赖于传给系统调用的参数,所以你不能静态地分析出内存边界是什么。所以在一些场景下,我们会标注代码并规定好这是这个循环最大循环次数,并根据这个数字计算内存总量S。

类似的,如果有你有递归调用的函数,谁知道会递归多少次呢?或许也取决于一个动态变量或者系统调用的参数。实际中,我们在Biscuit中做了特殊处理以避免递归函数调用。所以最后,我们才可能完成这里的内存分析。

所以,这里的内存分析不是没有代价的,也不是完全自动的。这花费了Cody(论文一作)好几天检查代码,检查所有的循环并标注代码。还有一些其他的Golang特有的问题需要处理,例如,向Slice添加元素可能会使内存使用量扩大一倍,所以我们也给Slice标注了最大的容量。但是所有这些工作都是可完成的,在花费了几天时间之后,使用这里的内存分析工具,你可以得到对于系统调用使用的最大内存量的合理评估。以上基本就是Biscuit如何解决heap exhaustion问题。

学生提问:这里的静态内存分析工具,如果不是用来构建内核,它们通常会用来干嘛?

Frans教授:Go编译器内部使用它来完成各种各样的优化,并分析得出最优的编译方式。这里正好编译器使用了一个包,我们也可以使用同样的包。在后面你还可以看到,我们还将它用于一些其他特性,有这么一个包非常的方便。

Last updated