今天的文章来自于最近的 Go 代码测试。请看下面的基准测试代码。1

func BenchmarkSortStrings(b *testing.B) {s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}b.ReportAllocs()for i := 0; i < b.N; i++ {sort.Strings(s)}
}

作为 sort.Sort(sort.StringSlice(s)) 的一个封装,sort.Strings 对输入进行了原地排序,所以它不应该被分配内存(至少 43% 的 tweeps 回应者是这么认为的)。然而,事实证明至少在最近的 Go 版本中,benchmark 的每一次迭代都会导致一次堆内存分配。为什么会发生这种情况?

所有 Go 程序员都应该知道,接口是以两个变量的结构来实现的。每个接口值都包含一个保存接口内容类型的字段,以及一个指向接口内容的指针。2

用 Go 的伪代码来表述,一个接口可能看起来是这样的:

type interface struct {// the ordinal number for the type of the value// assigned to the interface type uintptr// (usually) a pointer to the value assigned to// the interfacedata uintptr
}

interface.data 在大多数情况下可以容纳一个 8 字节变量,但[]string 是 24 个字节;其中一个变量指向切片底层数组的指针;一个变量是长度;还有一个变量是底层数组的剩余容量,那么 Go 是如何将 24 个字节装入 8 个字节的呢?使用书中最古老的技巧 - 引用。例如,[]string 是 24 个字节,但 *[]string - 一个指向字符串切片的指针 - 只有 8 字节。

逃逸到堆

为了使这个例子更加明确,我们去掉了 sort.Strings 函数:

func BenchmarkSortStrings(b *testing.B) {s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}b.ReportAllocs()for i := 0; i < b.N; i++ {var ss sort.StringSlice = svar si sort.Interface = ss // allocationsort.Sort(si)}
}

为了使接口发挥作用,编译器将赋值改写为 var si sort.Interface = &ss ss 的地址被分配给了接口值。现在的情况就变成接口值持有一个指向 ss 的指针,但它指向哪里呢?ss 在内存中的什么位置呢?

从 benchmark 报告中来看,似乎 ss 被分配到了堆上。

Total:    296.01MB   296.01MB (flat, cum) 99.66%8            .          .           func BenchmarkSortStrings(b *testing.B) { 9            .          .            s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"} 10            .          .            b.ReportAllocs() 11            .          .            for i := 0; i < b.N; i++ { 12            .          .             var ss sort.StringSlice = s 13     296.01MB   296.01MB             var si sort.Interface = ss // allocation 14            .          .             sort.Sort(si) 15            .          .            } 16            .          .           }

发生分配的原因是编译器不能说服自己 sssi 存活时间长。Go 编译器的黑客们的普遍态度好像是:这一点可以改进,但这又是另外一个话题了。目前,ss 被分配在堆中。因此问题就变成了,每次迭代分配多少个字节?我们可以用 testing 来看看。

% go test -bench=. sort_test.go
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHz
BenchmarkSortStrings-4 12591951 91.36 ns/op 24 B/op 1 allocs/op
PASS
ok command-line-arguments 1.260s

在 amd64 平台上使用 Go 1.16 beta1,每次操作分配 24 个字节。4 然而,在同一平台上,之前的 Go 版本每次操作消耗 32 个字节。

% go1.15 test -bench=. sort_test.go
goos: darwin
goarch: amd64 BenchmarkSortStrings-4 11453016 96.4 ns/op 32 B/op 1 allocs/op
PASS
ok command-line-arguments 1.225s

这将把我们带回这篇文章的主题,即 Go 1.16 中的一个有趣的改进。但在谈论它之前,我需要讨论一下内存尺寸级别 (size classes)。

内存尺寸级别 - Size classes

要解释什么是 size classes,让我们思考一下 Go 运行时如何在堆上分配 24 字节。一个简单的方法是用一个指向堆上最后分配的字节的指针来跟踪到目前为止分配的所有内存。要分配 24 个字节,堆的指针则要增加 24,并将前一个值返回给调用者。只要请求 24 字节的代码不写超这个标记,这个机制就没有开销。遗憾的是,在现实生活中,内存分配器并不只是分配内存,有时他们还必须释放内存。

最终 Go 运行时将不得不释放这 24 个字节,但从运行时的角度来看,它知道的是它给调用者的起始地址。它不知道这个地址之后分配了多少字节。为了释放内存,我们假设的 Go 运行时分配器必须为堆上的每次分配记录其长度。这些长度的分配在哪里?当然是在堆上。

在我们的方案中,当运行时想分配内存时,它可以请求比被请求稍多一点的内存,并使用它来存储所请求的数量。对于我们的切片例子,当我们请求 24 字节时,需要消耗 24 字节再加上一些开销来存储数字 24。这个开销有多大?事实证明,最小的量是一个字面量。5

要记录一个 24 字节的分配,开销是 8 个字节。25% 不是很大,但也不小,随着分配大小的增加,开销会变得微不足道。然而,如果我们只想在堆上存储 1 个字节,会发生什么?开销是请求数量的八倍,是否有更有效的方法来解决堆上少量数据的分配?

如果所有相同大小的内存都存储在一起,而不是将长度与分配的内存存储在一起,会发生什么?如果所有长度为 24 字节的内存都存储在一起,那么运行时将自动知道它们有多大。运行时只需要一个位来指示一个 24 字节的区域是否被使用。在 Go 中,这些区域被称为 size classes,因为所有相同大小的内存都存储在一起(想想学校的班级–所有的学生都是同一年级的,而不是 C++ 班级)。当运行时需要分配少量内存时,它会使用能容纳请求内存的最小的 size classes 来执行操作。

不限大小的 size classes

现在我们知道了 size classes 的工作原理,还有一个问题是,它们被存储在哪里呢?毫不奇怪,size classes 的内存来自于堆。为了最大限度地减少开销,运行时从堆中分配一个较大的数量(通常是系统页大小的倍数),然后将该空间用于分配单一尺寸的内存。但是,有一个问题。

如果分配大小的数量是固定的(最好是小的),那么分配一个大区域来存储相同尺寸的东西是很好的,但是在通用语言中,程序可以在运行时分配任何尺寸。

例如,假设向运行时请求 9 字节。9 字节是一个不常见的大小,所以很可能需要为 9 字节大小的内存建立一个新的 size classes。由于 9 字节的情况并不常见,因此很可能会浪费剩余的 4 KB 或更大的分配空间。正因为如此,size classes 的集合是固定的。如果没有确切数额的 size classes 可用,则分配被四舍五入到下一个大小的 size classes。在我们的例子中,9 个字节可能被分配到 12 个字节的 size classes 中。3 字节的开销总比整个 size classes 分配了大部分未使用的字节好。

总结

这是最后的总结。Go 1.15 没有 24 字节大小的 size classes,所以 ss 的堆分配是在 32 字节大小的 size classes 中的。多亏了 Martin Möhrmann 的工作,Go 1.16 有了一个 24 字节大小的 size classes,这对分配给接口的 slice 值来说是非常合适的。

  1. 这不是对排序函数进行基准测试的正确方法,因为在第一次迭代之后,输入已经被排序。

  2. 这个声明的准确性取决于所使用的 Go 版本。例如,Go 1.15 增加了将一些整数直接存储在接口值中,省去了分配和引用的功能。然而,对于大多数的值,如果它不是已经有了指针类型,它的地址就会被存储在接口值中。

  3. 编译器在接口值的类型字段中会进行跟踪,所以它记得分配给 si 的类型是 sort.StringSlice,而不是 *sort.StringSlice。

  4. 在 32 位平台上,这个数字会减半,但是我们不回头看。

  5. 如果你将分配限制在 4 G 或 64 kb,你可以使用较少的内存来存储分配的大小,但这意味着分配的第一个字不是自然对齐的,所以在实践中,使用少于一个字来存储长度头并不会达到有效节省的效果。

  6. 将相同大小的东西存储在一起也是对抗内存碎片化的有效策略

  7. 这并不是一个牵强的场景,字符串有大小各不同,生成一个新大小的字符串可以像附加一个空格一样简单。

相关文章

  1. I’m talking about Go at DevFest Siberia 2017

  2. If aligned memory writes are atomic, why do we need the sync/atomic package?

  3. A real serial console for your Raspberry Pi

  4. Why is a Goroutine’s stack infinite ?

原文地址:

https://dave.cheney.net/2021/01/05/a-few-bytes-here-a-few-there-pretty-soon-youre-talking-real-memory

原文作者:dave

本文永久链接:https://github.com/gocn/translator/blob/master/2021/w38_memory_allocate.md

译者:咔叽咔叽

校对:Cluas

想要了解更多 Golang 相关的内容,欢迎扫描下方

『每周译Go』谈谈 Go 中的内存相关推荐

  1. 『每周译Go』Go 语言中的插件

    很多年以前我就开始写一系列关于插件的文章:介绍这些插件在不同的系统和编程语言下是如何设计和实现的.今天这篇文章,我打算把这个系列扩展下,讲讲 Go 语言中一些插件的例子. 需要提醒的是,本系列头几篇的 ...

  2. 『每周译Go』开启并发模式

    在这篇文章中,我将介绍在 Go 中使用基本并发模式和原生原语来构建并发应用程序的一些最佳实践.模式本身适用于任何语言,但对于这些示例,我们将使用 Go. 可以下载本文的源码配合阅读. git clon ...

  3. 『每周译Go』Uber 的 API 网关架构

    原文地址:https://eng.uber.com/architecture-api-gateway/ 原文作者:Madan Thangavelu, Abhishek Parwal, Rohit Pa ...

  4. 『每周译Go』Go 语言的 goroutine 性能分析

    本文档最后一次更新时所用的 Go 版本是 1.15.6,但是大多数情况下,新老版本都适用. 描述 Go 运行时在一个称为 allgs 简单切片追踪所有的 goroutines.这里面包含了活跃的和死亡 ...

  5. 『每周译Go』Go sync map 的内部实现

    目录 引言 a. 简单介绍并发性及其在此上下文中的应用 sync.RWMutex 和 map 一起使用的问题 介绍 sync.Map a. 在哪些场景使用 sync.Map? sync.Map 实现细 ...

  6. 『每周译Go』那些年我使用Go语言犯的错

    原文地址:https://henvic.dev/posts/my-go-mistakes/ 原文作者:Henrique Vicente 本文永久链接:https://github.com/gocn/t ...

  7. 『每周译Go』写了 50 万行 Go 代码后,我明白这些道理

    原文地址:https://blog.khanacademy.org/half-a-million-lines-of-go/ 原文作者:Kevin Dangoor 本文永久链接:https://gith ...

  8. 『每周译Go』GitHub 为 Go 社区带来供应链安全功能

    Go 国际社区从一开始就拥抱 GitHub ( GitHub 即是 Go 代码协作的地方也是发布包的地方) 使得 Go 成为 如今 GitHub 上排名前 15 的编程语言.我们很高兴地宣布 GitH ...

  9. 『每周译Go』手把手教你用 Go 实现一个 mTLS

    想知道什么是 mTLS(双向 TLS)?来吧,让我们用 Golang 和 OpenSSL 实现一个 mTLS. 介绍 TLS(安全传输层协议简称)为网络通信的应用程序提供必要的加密.HTTPS (超文 ...

最新文章

  1. UVA11400 照明系统设计 Lighting System Design(线性DP)
  2. 打印http地址打印双斜杠
  3. webservice 基本要点
  4. 数据结构之单向环形列表解决josef问题
  5. c语言结构体数组存入文件_c语言怎么用文件保存和读取 结构体数组/
  6. 每天都用微信聊天,但你可能不知道它还隐藏着这些超实用的功能
  7. shell脚本修改文本中匹配行之前的行的方法
  8. delphi7 dbgrid缓存模式下怎么判断输入重复记录_互联网公司的架构设计要怎么落地?| 技术头条...
  9. 数据结构课程设计(学生选课管理系统)链表实现
  10. less最后一页 linux_linux中less命令使用
  11. 人工晶状体在线公式A常数优化——多线程
  12. Pokémon Army (easyversion) -每天一把CF - 20201007
  13. 【官方教程】使用Quick-Cocos2d-x搭建一个横版过关游戏(六)
  14. 微信公众号网页授权40029错误,小程序微信支付前后端逻辑? (微信授权支付之 (篇一))
  15. Java web框架
  16. redis同城双机房容灾
  17. 店铺综合分中有关排序和等份的问题
  18. 无法实例化xxx对象
  19. 华为HG522无线路由猫破解开启路由功能
  20. 【Amber】分子动力学结果分析(二)PMF

热门文章

  1. linux如何下载github脚本,在Linux系统中下载及安装GitHub Atom code editor的方法
  2. 众创模式如何改造硬件创业?
  3. 您还能想起小学同学的名字吗?
  4. ​这款「咒语」优化工具,功能有多强大?#Prompt Perfect
  5. Javascript Base64加密与解密
  6. 洛谷p-1522又是Floyd
  7. 分享你一份超详细的公众号文章制作流程,注意查收
  8. killall 后面信号_Linux命令之killall
  9. 公司win7电脑安装wireshark新版本后无法上网
  10. 什么Leader值得追随?