Go语言的手工内存管理

pykde 9年前

介绍

注:如果您对这篇文章有不同观点,欢迎指正 - 我并不是这方面的专家。

我们从大量的 go 用户中收集了有关于使用 defer 和 panic 的性能统计。不像其它的 APM/error 记录公司,我们的重点不是告诉你有一个问题,而是实际解决问题的方法。这就是为什么我们要使用 go。

这使我们看出对大家来说什么是好的,坏的和丑的。

当你的项目中隐藏了一个大问题时,如果你只是来回查看通常是不能发现它的,直到你对它进行测试。

许多性能问题可能已经存在了数周。

通常这些问题大部分可以简单的通过 pprof 等方法分析目标程序来解决掉。

一旦你怀疑一个地方是错误的,你就应该先看看这个(pprof)。

然而,有时候,在经过几天都不能找到问题源头的时候,程序猿就会开始抱怨一切。

是数据库的问题!

框架的问题!

是 cat(计算机辅助程序)的问题!(猫?。。。)

是垃圾回收器的问题!

每当这个点上,团队里就会有个家伙跳出来说,他使用的某某语言更好(-_-!,那样如样你不是个软件工程师,你将被他搞得晕头转向。

我这里不讨论关于语言、cats(又是猫?。。)或者框架。

我只是想引导大家讨论更有效率的方案。

我确实想说说垃圾回收器。它是在大多数语言中常常被提及的性能问题,但是,如果问题随手就可以解决掉,那它也就不会引起人们的关注了。

反对垃圾回收器的争论,在相当长的一段时间内,有快速赶超硬编码汇编的争论。当然,如果你了解需要知道的一切,关于目标编译器,目标架构,目标操作系统等,那么你选择一两项内容做优化是说得通的,但是,这基本上是说,你比一些最好的编译器作者还要更胜一筹。你可能具备这样的能力。

然而, 这是相当高的要求。

实际上,有许多人们觉得相当不错的垃圾回收器,如 azul 的内存回收器.

背景

“C程序员认为内存管理太重要了,不能由电脑自己处理。 Lisp 的程序员认为内存管理太重要了,不能由用户来处理。“ —— Bjarne Stroustrup

要开始使用 - 大部分现代编程语言都使用自动化的垃圾收集机制。

无论是 Java 或是 C# 都有垃圾收集器。并且,一些解释型语言也有。

Go语言的手工内存管理

约翰·麦卡锡(John McCarthy)1958年在归途中发明了一种叫“垃圾回收器”的东西。这是在成功发射 Sputnik 的第二年。之后他还发明了别的东西。

所以,这个概念已经有相当长的时间了,但一些开发者似乎仍然对这个概念感到不安。为什么呢?

要回答这个问题,我们应该看看各种方法的内存管理。

内存管理的方法

这里有一些内存管理内存的方法,你可以:

* 在编译时分配所有的内存

* 手动管理内存

* 自动管理内存

对于我们的定义,我们认为垃圾是使用完毕的已分配内存。同时,也为了这篇博客,唯一的差别在于,垃圾可以由程序员手动清理(或者不清理),或者由编程语言自动清理。

在管理内存的过程中,存在许多问题。其中,有些问题是显而易见的,有些问题则不容易发觉;有些问题不容易解决,而另一些问题甚至无人意识到它的存在。

这通常只是想当然,因为--你通常拥有一个垃圾回收器!!:)

管理内存中的问题:

* 引用不再使用的内存

* 使用未分配内存的指针

* 分配内存但从不释放

* 使用释放了内存的指针

* 没有分配足够的内存

* 分配内存大小错误

* 分配或者释放内存太快或者太慢

* 在内存中存储数据之前加载内存

* 安全的从内存中删除敏感数据

* 内部碎片

* 外部碎片

实际上--安全行业统计过,关于内存管理的错误使用,涉及到数十亿美元。这显然是一个问题。

基本的内存管理操作

内存管理在计算机科学中有它的独特之处,但是,这里有两种最常用和最重要的操作:

分配:这是分配一块内存给指定请求的操作,同时保证分配器不会将此内存分配给其它地方,除非你说不。

释放/重用:这就是当你说--这块内存我已经使用完了,你可以按照你觉得合适的方式,自由的处理它了。内存并未“删除”。内存也并未“释放”到空中,或者其它地方。所做的,只是内存做了标记,这样,下一次请求的时候,它就可以重新使用了。

关于内存管理的错误假设

预留 vs. 分配

许多人困惑的一件事情是,分配内存和预留内存的差别。Go 语言 一开始就预留了一大块内存(reserves a large chunk of memory right off the bat) 。这样做有很多原因--其中之一是,连续vs.非连续的内存映射。你希望尽可能的减少碎片。

对于我们而言,我们定义预留是对 malloc 函数的一次调用,然而,当我们决定使用这片内存的时候,内存分配才真正发生。只要存在未预留的内存空间,我们就可以随心所欲的调用 malloc 函数。但是,当我们使用内存,并消耗完它的时候,我们就会得到内存不足(OOM)的错误信息。

操作系统中断

开发者认为如果他们可以分配和释放内存,他们的程序就不会因 GC 调用而中断和变慢。

如果上述假设是真实的,那么这种逻辑的问题所在是他们忘记了操作系统本身可以阻塞内存的分配,调度程序切换到其他任务, 而且所有的设备驱动程序都有中断处理。你的代码不是孤立存在的,有太多的因素公导致它产生暂停。

内存释放不一定是免费的

使用一个好的 GC 确实可以减少内存分配时间。同时释放内存并不意味着立即释放内存块,通过你用的任何版本的 malloc 都会把它放在一个释放列表里 -  这样可以帮助阻止碎片化。 

当然,释放内存有时间并不是免费的,它可以是随机的,你需要分页,导致糟糕的 IO。试想做大量的小内存分配,你会导致大量的碎片,为了避免崩溃不得不重新分配堆栈大小。

碎片

我已经提到碎片好几次了 - 让我告诉你一个简单的例子。

比方说,我们有一个地方可以动态分配内存。有4个我们可以访问的槽。但是,每次我们请求内存时,我们只能获取2个插槽。
Go语言的手工内存管理

我们第一次分配,我们抢到了前两个槽。然后,我们断言我们之后不再需要第一个槽,所以我们“删除”了。好吧,太棒了。现在看起来是这样的。
Go语言的手工内存管理

现在一个大的数据库作业来了,我们还需要一个槽。太糟糕了,我们的分配机制只允许我们一次获取2个槽 - 所以会发生什么?我们得到了接下来的两个槽。现在,我们已经分配了四分之三的,即使我们只用了其中的2个。这样继续下去,直到剩下的全都是我们不能夹这些已经分配的槽中间使用很多的空闲缝隙。

这是让我们抓狂的碎片。

配置策略

一般来说经典的分配策略可以有两种形式:{单个列表或列表的数组}:
我不会介绍阵数组实现,也不会去谈现有的那些更加奇特的策略。
我们说一个与上面的那个类似的链表。
现在有几个关于在单空闲单链表找到一个合适的地址(槽)的策略方式供选择:
首次适应(first fit):我们通过扫描整个链表,寻找第一个能够符合我们的分配请求的位置。
循环首次适应(next fit):与首次适应类似,但我们追踪上衣一次访问的地方,然后从这里开始寻找下一个符合请求要求的位置,所以你没必要每次都从链表的头开始扫描。
最佳策略(best fit):寻找内存中符合要求的最小的块。
最坏策略(worst fit):寻找内存中符合要求的最大的块。

Go的垃圾回收

要立刻实现此种垃圾回收方式,go还是一种很年轻的语言。毫无疑问在未来会有诸多改进,但这将是一个不平凡的任务,并且需要有人来为此努力。我确信并非所有人拥有这样的才能。
两种不同的方法在垃圾回收方面占有很多大优势:{跟踪,引用计数}
1.4 经典的STW垃圾回收器。
1.5 引入了一个并发收集器。
这两者都是标记和清除,这是跟踪收集器的一个实现。
他们是非移动的,这意味着垃圾回收器将不会像可压缩的收集器那样移动引用。
您可以通过设置环境变量关闭垃圾回收器:

GOGC=off ./myproggie

然而,这一点毫无意义,除非你要手动运行收集器 - 并且运行环境自己分配内存 - 所以你产生的有垃圾会有人来处理它。你每天都拖垃圾到某处倾倒?

当然不,你有更重要的事情要做。

至于为什么go有一个垃圾收集器 - 我不认为这点足够驱使人们不那么做。其中这个语言的一个主要卖点是并发。传统的内存管理已经很难实现来了,更别说现在要在一个并发的环境实现垃圾回收了。
请看horse’s mouth

反模式

总算切入正题了……

很多开发者都选择使用高级语言来管理内存事务。的许多开发人员经常碰到内存问题,因为所有的内存管理是从他们隐藏。这并不意味因为大部分内存都被影藏了。

但这不意味着他们是拙劣的开发者,只是这些内存事务不在他们的思考范畴内。

这也不应该是他们在开发自己的代码钱所必须修完的功课。

我猜想,我们所看到的用户出现的各种问题大部分来自于一些简单但却常常被忽略的模式,如下:

字符串连接

在 java 中有一个众所周知的反模式,我们可以使用在一个环结构中链接字符串而不是使用字符串生成器。好的,go 也有自己的反模式。

需要知晓的事情是,当你 concat 字符串的时候,go 分配到了一个新地址给新的值。因此,事情变得如此简单:

  blah := "stuff" + "more stuff" + "even more stuff"    for  i:=0; i<10*10*10; i++ {      blah += "and more stuff... " + "ad infinitum"    }

多劳者多得。

关于这种方式可以参考 stdlib @strings.Join 。你也可以使用 bytes.Buffer.WriteString

手动管理内存用例

你还是要手动管理?好吧。

一些真实用例可能包含以下:

* 真正的大型堆 - 假设大型内存数据库操作;

* 管理相同大小同一类型的对象;

* 实时系统 - 这不是一个好用例,因为这不是go的特长,你最好选择一个完全不同的语言。

自己写或者使用它来做

老实讲,恕我直言,除非你是这方面的专家(我肯定不是),这儿没有太多好的用例。如果你是,我可能会建议你继续使用go工作。

大部分人不会写底层的设备指令或者实时软件。我不是讲为实时而生的node.js/websockets 和别的类似软件,我们讲的是特定的操作系统,存在于你手机而不是Linux的第二操作系统。 

如果你必须这样做:

* 你的磁盘有大量碎片;

* 你的磁盘不是线程安全;

* 你觉得垃圾回收器比较混乱,所以你可以关闭它,我们已经提到过这会有别的问题。

还是要自己管理?
好了,你可以采用 hacky,管理它可能不像在 C 语言里那么有效,仅仅使用 CGO 链接到它。

你可以使用 STDLIB 的东西:
sync.Pool

你也可以使用缓冲通道。
此外,随着有了越来越多参考资料,看看couchbase 和cloudflare如何实现它们。
监控
还记得在本文的前面时,我说,大多数人都没有意识到一个问题,直到它变成一个很大的问题或者你应该权衡的事情?
我们只是碰巧如此做,对于 go 来说是主动地:

Go语言的手工内存管理

我们甚至可以提醒你之前,事情变得无法忍受。

Go语言的手工内存管理

此外,由于我们只支持 go,我们工具是为 go 定制的,除了洗碗槽。(译者补充:“洗碗槽”典故来自二战时期的成语"everything but the kitchen sink" ,当时是指敌人炮火猛烈(除了洗碗槽外,各式各样的炮弹齐发),现在指太多的东西)



包装

go 语言有很多方法手动管理内存的方式,单着似乎无法解决我们需要解决的问题。

我认为我们应该关注一下像 Blade paper 提出的真正意义上的垃圾回收的问题的解决方法。

然而你真的没有必要求助于这些工具。如果你发现自己在抱怨垃圾回收器,那么你应该立刻拿出 pprof并责难那该死的垃圾回收器。如果真的是这样,你确定要考虑改进它吗?