设为首页 加入收藏

TOP

高性能go服务之高效内存分配(一)
2019-09-03 03:39:54 】 浏览:131
Tags:高性能 服务 高效 内存 分配

高性能go服务之高效内存分配

手动内存管理真的很坑爹(如C C++),好在我们有强大的自动化系统能够管理内存分配和生命周期,从而解放我们的双手。

但是呢,如果你想通过调整JVM垃圾回收器参数或者是优化go代码的内存分配模式话来解决问题的话,这是远远不够的。自动化的内存管理帮我们规避了大部分的错误,但这只是故事的一半。我们必须要合理有效构建我们的软件,这样垃圾回收系统可以有效工作。

在构建高性能go服务Centrifuge时我们学习到的内存相关的东西,在这里进行分享。Centrifuge每秒钟可以处理成百上千的事件。Centrifuge是Segment公司基础设施的关键部分。一致性、行为可预测是必须的。整洁、高效和精确的使用内存是实现一致性的重要部分。

这篇文章,我们将介绍导致低效率和与内存分配相关的生产意外的常见模式,以及消除这些问题的实用方法。我们会专注于分配器的核心机制,为广大开发人员提供一种处理内存使用的方法。

使用工具

首先我们建议的是避免过早进行优化。Go提供了出色的分析工具,能够直接指向内存分配密集的代码部分。没有必要重新造轮子,我们直接参考Go官方这篇文章即可。它为使用pprof进行CPU和分配分析提供了可靠的demo。我们在Segment中用于查找生产Go代码中的瓶颈的工具就是它,学会使用pprof是基本要求。

另外,使用数据去推动你的优化。

逃逸分析

Go能够自动管理内存分配。这可以防止一大类潜在错误,但是不能说完全不去了解分配的机制。

首先要记住一点:栈分配是很廉价的而堆分配代价是昂贵的。我们来看一下具体含义。

Go在两个地方分配内存:用于动态分配的全局堆,以及用于每个goroutine的局部栈。Go偏向于在栈中分配----大多数go程序的分配都是在栈上面的。栈分配很廉价,因为它只需要两个CPU指令:一个是分配入栈,另一个是栈内释放。

但是不幸的是,不是所有数据都能使用栈上分配的内存。栈分配要求可以在编译时确定变量的生存期和内存占用量。然而堆上的动态分配发生在运行时。malloc必须去找一块儿足够大的空闲内存来保存新值。然后垃圾收集器扫描堆以查找不再引用的对象。毫无疑问,它比堆栈分配使用的两条指令要贵得多。

编译器使用逃逸分析技术去选择堆或者栈。基本思想是在编译时期进行垃圾收集工作。编译器追踪代码域变量的作用范围。它使用追踪数据来检查哪些变量的生命周期是完全可知的。如果变量通过这些检查,则可以在栈上进行分配。如果没通过,也就是所说的逃逸,则必须在堆上分配。

go语言里没有明确说明逃逸分析规则。对于Go程序员来说,最直接去了解规则的方式就是去实验。通过构建时候加上go build -gcflags '-m',可以看到逃逸分析结果。我们看一个例子。

package main

import "fmt" func main() { x := 42 fmt.Println(x) } 
$ go build -gcflags '-m' ./main.go # command-line-arguments ./main.go:7: x escapes to heap ./main.go:7: main ... argument does not escape 

我们这里看到变量x“逃逸到堆上”,因为它是在运行时期动态在堆上分配的。这个例子可能有点困惑。我们肉眼看上去,显然x变量在main()方法上不会逃逸。编译器输出并没有解释为什么它会认为变量逃逸了。为了看到更多细节,再加上一个-m参数,可以看到更多输出

$ go build -gcflags '-m -m' ./main.go # command-line-arguments ./main.go:5: cannot inline main: non-leaf function ./main.go:7: x escapes to heap ./main.go:7: from ... argument (arg to ...) at ./main.go:7 ./main.go:7: from *(... argument) (indirection) at ./main.go:7 ./main.go:7: from ... argument (passed to call[argument content escapes]) at ./main.go:7 ./main.go:7: main ... argument does not escape 

这说明,x逃逸是因为它被传入一个方法参数里,这个方法参数自己逃逸了。后面可以看到更多这种情况。

规则可能看上去是随意的,经过工具的尝试,一些规律显现出来。这里列出了一些典型的导致逃逸的情况:

  • 发送指针或者是带有指针的值到channel里。编译时期没有办法知道哪个goroutine会受到channel中的数据。因此编译器无法确定这个数据什么时候不再被引用到。
  • 在slice中存储指针或者是带有指针的值。这种情况的一个例子是[]*string。它总会导致slice中的内容逃逸。尽管切片底层的数组还是在堆上,但是引用的数据逃逸到堆上了。
  • slice底层数组由于append操作超过了它的容量,它会重新分片内存。如果在编译时期知道切片的初始大小,则它会在栈上分配。如果切片的底层存储必须被扩展,数据在运行时才获取到。则它将在堆上分配。
  • 在接口类型上调用方法。对接口类型的方法调用是动态调用--接口的具体实现只有在运行时期才能确定。考虑一个接口类型为io.Reader的变量r。对r.Read(b)的调用将导致r的值和byte slice b的底层数组都逃逸,因此在堆上进行分配。

以我们的经验来讲,这四种情况是Go程序中最常见的动态分配情况。对于这些情况还是有一些解决方案的。接下来,我们将深入探讨如何解决生产软件中内存低效问题的一些具体示例。

指针相关

经验法则是:指针指向堆上分配的数据。 因此,减少程序中指针的数量会减少堆分配的数量。 这不是公理,但我们发现它是现实世界Go程序中的常见情况。

我们直觉上得出的一个常见的假设是这样的:“复制值代价是昂贵的,所以我会使用指针。”然而在许多情况下,复制值比使用指针的开销要便宜的多。你可能会问这是为什么。

  • 在解引用一个指针的时候,编译器会生成检查。它的目的是,如果指针是nil的话,通过运行panic()来避免内存损坏。这部分额外代码必须在运行时去运行。如果数据按值传递,它不会是nil。
  • 指针通常具有较差的引用局部性。函数中使用的所有值都在并置在堆栈内存中。引用局部性是代码高效的一个重要方面。它极大增加了变量在CPU caches中变热的可能性,并降低了预取时候未命中风险。
  • 复制缓存行中的对象大致相当于复制单个指针。 CPU在缓存层和主存在常量大小的缓存行上之间移动内存。 在x86上,cache行是64个字节。 此外,Go使用一种名为Duff`s devices的技术,使拷贝等常见内存操作非常高效。

指针应主要用于反映成员所有关系以及可变性。实际中,使用指针避免复制应该是不常见的。不要陷入过早优化陷阱。按值传递数据习惯是好的,只有在必要的时候才去使用指针传递数据。另外,值传递消除了nil从而增加了安全性。

减少程序中指针的数量可以产生另一个有用的结果,因为垃圾收集器将跳过不包含指针的内存区域。例如,根本不扫描

首页 上一页 1 2 3 4 下一页 尾页 1/4/4
】【打印繁体】【投稿】【收藏】 【推荐】【举报】【评论】 【关闭】 【返回顶部
上一篇prisma反向代理 下一篇GO指南练习:切片

最新文章

热门文章

Hot 文章

Python

C 语言

C++基础

大数据基础

linux编程基础

C/C++面试题目