8 Go语言中的内存管理
# 8 Go语言中的内存管理
内存管理对系统性能至关重要。充分利用计算机的内存空间,能让高性能程序常驻内存,这样就无需频繁因数据交换到磁盘而承受巨大的性能损失。能够有效地管理内存是编写高性能Go代码的核心原则。在本章中,我们将学习以下主题:
- 计算机内存
- 内存的分配方式
- Go语言如何高效利用内存
- 内存中对象的分配方式
- 针对内存有限的计算设备的策略
了解内存的使用方式有助于你在程序中高效地利用内存。内存是计算机中存储和处理数据速度最快的组件之一,因此,对内存进行高效管理将对代码质量产生深远影响。
# 理解现代计算机内存——入门知识
现代计算机配备随机存取存储器(Random Access Memory,RAM),用于存储机器代码和数据。RAM与CPU和硬盘协同工作,实现信息的存储和检索。使用CPU、RAM和硬盘在性能上需要权衡取舍。以撰写本文时的现代计算机为例,一些常见操作的大致耗时如下:
数据存储类型 | 耗时 |
---|---|
L1(处理器缓存)访问 | 1纳秒 |
L2(处理器缓存)访问 | 4纳秒 |
主内存访问 | 100纳秒 |
固态硬盘随机读取 | 16微秒 |
7200转/分钟的硬盘寻道 | 2毫秒 |
从表格中可以看出,现代计算机架构中不同存储类型的访问时间差异巨大。新计算机通常有KB级别的L1缓存、MB级别的L2缓存、GB级别的主内存以及TB级别的固态硬盘(SSD)/硬盘(HDD)。由于我们认识到这些不同类型的数据存储在成本和性能上差异显著,因此为了编写出高性能的代码,我们需要学习如何有效地使用它们。
# 内存分配
计算机的主内存用途广泛。内存管理单元(Memory Management Unit,MMU)是计算机硬件的一部分,负责在物理内存地址和虚拟内存地址之间进行转换。当CPU执行使用内存地址的指令时,MMU会将逻辑内存地址转换为物理内存地址。
这些内存地址是以页(page)为单位进行管理的,页通常以4KB为一个分段,并通过一个称为页表(page table)的表格进行管理。MMU还具备其他功能,包括使用诸如翻译后备缓冲器(Translation Lookaside Buffer,TLB)这样的缓冲区,来保存最近访问过的地址转换信息。
虚拟内存的作用体现在以下几个方面:
- 允许将硬件设备内存映射到地址空间
- 为特定内存区域设置访问权限(读、写、执行,即rwx)
- 让不同进程拥有独立的内存映射
- 便于内存的移动操作
- 便于将内存交换到磁盘
- 支持共享内存,即物理内存可同时映射到多个进程
在现代Linux操作系统中分配虚拟内存时,内核和用户空间进程都使用虚拟地址。这些虚拟地址通常被划分为两部分:虚拟地址空间的上半部分供内核和内核进程使用,下半部分则用于用户空间程序。
操作系统负责管理这些内存,它在内存和磁盘之间调度进程,以优化计算机资源的使用。计算机语言会利用其运行的底层操作系统的虚拟内存空间(Virtual Memory Space,VMS),Go语言也不例外。如果你有C语言编程经验,就会知道malloc
和free
这两个函数。而在Go语言中,并没有malloc
函数。并且,Go语言是一种具有垃圾回收机制的语言,所以我们无需考虑释放内存分配的问题。
在用户空间中,我们主要通过两个指标来衡量内存:虚拟内存大小(VSZ)和常驻集大小(RSS)。
# 认识VSZ和RSS
VSZ(Virtual Memory Size)即虚拟内存大小,指的是单个进程能够访问的所有内存,包括已交换到磁盘的内存。这是程序在初始执行时分配的内存大小,通常以KiB(千字节)为单位进行报告。
RSS(Resident Set Size)即常驻集大小,它表示特定进程在RAM中实际分配的内存大小,不包括已交换到磁盘的内存。RSS包括共享库内存(前提是该内存当前可用),也包括栈内存和堆内存。由于这些内存引用通常是共享的,所以RSS内存大小可能会超过系统中的可用总内存。RSS同样以千字节为单位进行报告。
当我们启动一个简单的HTTP服务器时,可以查看分配给该进程的VSZ和RSS,示例代码如下:
package main
import (
"io"
"net/http"
)
func main() {
Handler := func(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Memory Management Test")
}
http.HandleFunc("/", Handler)
http.ListenAndServe(":1234", nil)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
启动服务器后,我们可以查看其生成的进程ID,输出如下:
在此,我们可以看到server.go
进程的VSZ和RSS值。
如果我们希望减小Go二进制文件的构建大小,可以使用构建标志,在构建时不包含libc库,命令如下:
go build -ldflags '-libgcc=none' simpleServer.go
不包含libc库构建二进制文件后,示例服务器的内存占用会大幅降低,输出如下:
可以看到,VSZ和RSS的内存使用量都显著降低。在实际应用中,内存成本并不高,所以我们通常可以在Go二进制文件中保留libc库。libc库被许多标准库功能所依赖,包括用户和组解析,以及部分主机解析功能,这也是它在构建时被动态链接的原因。
构建好Go二进制文件后,它们会以一种容器格式存储。在Linux系统中,这种二进制文件的存储格式为ELF(Executable and Linkable Format,可执行与可链接格式)。Go的标准库提供了读取ELF文件的方法。我们可以查看之前生成的simpleServer
二进制文件,代码如下:
package main
import (
"debug/elf"
"fmt"
"log"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ./elfReader elf_file")
os.Exit(1)
}
elfFile, err := elf.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
for _, section := range elfFile.Sections {
fmt.Println(section)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
simpleServer
示例的输出结果如下:
还有其他一些Linux工具可用于研究这些ELF二进制文件。readelf
工具能够以更易读的格式打印ELF文件的信息。例如,我们可以使用它查看一个ELF文件,如下所示:
ELF文件具有特定的格式,具体如下:
文件布局部分 | 描述 |
---|---|
文件头 | 类别字段:分别将32位和64位地址定义为52字节或64字节长。 数据:定义小端序或大端序。 版本:存储ELF版本(目前只有一个版本,即01)。 操作系统/应用二进制接口(OS/ABI):定义操作系统和应用二进制接口。 机器:显示机器类型。 类型:表明该文件的类型;常见类型有CORE、DYN(用于共享对象)、EXEC(用于可执行文件)和REL(用于可重定位文件)。 |
程序头或段 | 包含在运行时创建进程或内存映像以供执行的指令。内核随后使用这些信息通过mmap 映射到虚拟地址空间。 |
节头或节 | .text :可执行代码(指令、静态常量、文字).data:受访问控制的已初始化数据 .rodata:只读数据 .bss:可读/写的未初始化数据 |
我们还可以编译该程序的32位版本,以查看其中的差异。正如第1章《Go语言性能导论》中提到的,我们可以为不同的架构构建Go二进制文件。使用以下构建参数,我们可以为i386 Linux系统构建二进制文件:
env GOOS=linux GOARCH=386 go build -o 386simpleServer simpleServer.go
构建完成后,我们可以检查生成的ELF文件,验证其与之前为x86_64计算机构建的ELF文件有所不同。为简洁起见,我们使用-h
标志仅查看每个文件的头部信息:
从输出结果中可以看出,这个二进制文件是为i386处理器生成的,与最初为x86_64生成的二进制文件不同:
了解系统的限制、架构以及内存限制,有助于你构建能够在主机上高效运行的Go程序。在本节中,我们将探讨内存的使用情况。
# 理解内存利用
有了初始的二进制文件后,我们基于对ELF格式的了解,进一步理解内存利用。文本段(text)、数据段(data)和未初始化数据段(bss)是堆(heap)和栈(stack)构建的基础。堆从.bss段和.data段的末尾开始,不断扩展以形成更大的内存地址。
栈是连续内存块的分配区域。这种分配在函数调用栈中自动进行。当调用一个函数时,其变量会在栈上分配内存。函数调用完成后,变量的内存会被释放。栈的大小是固定的,并且只能在编译时确定。从分配的角度来看,栈分配成本较低,因为它只需要在栈上进行压入和弹出操作来完成分配。
堆是可供分配和释放的内存集合。内存以随机顺序分配,由程序员手动操作。由于堆中的内存块不连续,从时间成本和访问速度来看,堆的开销更大且访问更慢。不过,堆中的元素大小是可以调整的。堆分配成本较高,因为malloc
函数需要搜索足够的内存来存储新数据。垃圾回收器在后续工作中,会扫描堆中不再被引用的对象并释放它们。这两个过程相比栈的分配和释放操作要昂贵得多。因此,Go语言更倾向于在栈上进行分配,而非堆。
我们可以使用-m
这个gcflag
标志来编译程序,从而查看Go编译器如何进行逃逸分析(编译器通过逃逸分析来确定在运行时初始化的变量是使用栈还是堆的过程)。
我们可以创建一个非常简单的程序,如下所示:
package main
import "fmt"
func main() {
greetingString := "Hello Gophers!"
fmt.Println(greetingString)
}
2
3
4
5
6
7
8
然后,我们使用逃逸分析标志编译程序,如下:
从输出结果中,我们可以看到简单的greetingString
被分配到了堆上。如果我们想要这个标志输出更多详细信息,可以传递多个m
值。在撰写本文时,最多传递5个-m
标志可以得到不同详细程度的输出。以下是传递3个-m
标志进行构建的截图(为简洁起见):
Go语言中静态分配的变量通常存放在栈上。指向内存的指针或接口类型上的方法这类元素往往是动态的,因此通常存放在堆上。
如果我们想在构建过程中查看更多可用的优化选项,可以使用以下命令:go tool compile -help
。
# Go运行时内存分配
正如我们在第3章“理解并发”中所学,Go运行时使用G
结构体来表示单个协程(goroutine)的栈参数。P
结构体管理用于执行的逻辑处理器。Go运行时使用的malloc
函数(定义在https://golang.org/src/runtime/malloc.go )做了大量工作。Go语言使用mmap
直接向底层操作系统请求内存。小内存分配(内存分配大小最大为32 kB及以下)与大内存分配的处理方式不同。
# 内存分配入门
让我们快速讨论一下与Go语言小对象内存分配相关的几个对象。
我们可以在https://golang.org/src/runtime/mheap.go中看到mheap
和mspan
结构体。
mheap
是主要的堆内存分配器(mallocheap)。它跟踪全局数据以及许多其他堆的详细信息。其中一些重要信息如下:
名称 | 描述 |
---|---|
lock | 一个互斥锁(mutex)锁定机制 |
free | 一个包含未清理内存块(spans)的mTreap (一种融合了树和堆的数据结构) |
scav | 一个包含已清理和空闲内存块的mTreap |
sweepgen | 一个用于跟踪内存块清理状态的整数 |
sweepdone | 跟踪所有内存块是否都已清理 |
sweepers | 当前活跃的sweepone 调用次数 |
mspan
是主要的内存块分配器(span malloc)。它跟踪所有可用的内存块。内存块是8K或更大的连续内存区域。它还保存了许多其他内存块的详细信息。需要注意的一些重要信息如下:
名称 | 描述 |
---|---|
next | 列表中的下一个内存块;如果不存在,则为nil |
previous | 列表中的前一个内存块;如果不存在,则为nil |
list | 用于调试的内存块列表 |
startAddr | 内存块的第一个字节 |
npages | 内存块中的页数 |
# 内存对象分配
内存对象分为三类:
- 微小对象(Tiny):大小小于16字节的对象。
- 小对象(Small):大小大于16字节且小于等于32 kB的对象。
- 大对象(Large):大小大于32 kB的对象。
Go语言中,微小对象的内存分配执行以下过程:
- 如果
P
的mcache
(每个逻辑处理器P
都有一个mcache
,用于缓存小对象分配)有空间,则使用该空间。 - 取出
mcache
中现有的子对象,并将其大小向上取整为8字节、4字节或2字节。 - 如果对象大小适合分配的空间,则将其放入内存。
Go语言中,小对象的内存分配遵循特定模式:
- 对象的大小会向上取整,并归类到https://golang.org/src/runtime/mksizeclasses.go中生成的小对象大小类别之一。在以下输出中,我们可以看到在我的x86_64机器上定义的
_NumSizeClasses
和class_to_size
变量分配情况。然后,这个值会用于在P
的mcache
中的mspan
里查找空闲位图,如果有可用的空闲内存槽,就会进行相应的分配。以下截图展示了这一过程:
- 如果
P
的mspan
中没有空闲位置,则从mcentral
的mspan
列表中获取一个有足够空间容纳新内存对象的新mspan
。 - 如果该列表为空,则从
mheap
中分配一组页面,为mspan
寻找空间。 - 如果上述操作失败(列表为空或没有足够大的页面可供分配),则从操作系统分配一组新的页面。这一操作成本较高,但至少会以1 MB的块为单位进行分配,这有助于降低与操作系统交互的成本。从
mspan
释放对象的过程与之类似:- 如果在响应分配操作时正在清理某个
mspan
,则将其返回给mcache
。 - 如果
mspan
中仍有已分配的对象,则将该mspan
放入mcentral
的空闲列表中进行释放。 - 如果
mspan
处于空闲状态(没有已分配的对象),则将其返回给mheap
。 - 一旦
mspan
在给定的时间间隔内一直处于空闲状态,这些页面将被返回给底层操作系统。
- 如果在响应分配操作时正在清理某个
大对象不使用mcache
或mcentral
,它们直接使用mheap
。
我们可以使用之前创建的HTTP服务器来查看一些内存统计信息。通过runtime
包,我们可以获取程序从操作系统获取的内存量,以及Go程序的堆分配情况。让我们逐步了解这是如何实现的:
- 首先,初始化包,进行导入,并设置第一个处理程序:
package main
import (
"fmt"
"io"
"net/http"
"runtime"
)
func main() {
Handler := func(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Memory Management Test")
}
2
3
4
5
6
7
8
9
10
11
12
13
- 然后,编写一个匿名函数来捕获运行时统计信息:
go func() {
for {
var r runtime.MemStats
runtime.ReadMemStats(&r)
fmt.Println("\nTime: ", time.Now())
fmt.Println("Runtime MemStats Sys: ", r.Sys)
fmt.Println("Runtime Heap Allocation: ", r.HeapAlloc)
fmt.Println("Runtime Heap Idle: ", r.HeapIdle)
fmt.Println("Runtime Head In Use: ", r.HeapInuse)
fmt.Println("Runtime Heap HeapObjects: ", r.HeapObjects)
fmt.Println("Runtime Heap Released: ", r.HeapReleased)
time.Sleep(5 * time.Second)
}
}()
http.HandleFunc("/", Handler)
http.ListenAndServe(":1234", nil)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 执行这个程序后,我们可以看到服务的内存分配情况。以下结果中的第一次打印显示了内存的初始分配情况:
第二次打印是在请求http://localhost:1234/
之后。可以看到,系统和堆的分配大致保持不变,并且随着Web请求的进行,空闲堆和正在使用的堆的利用率发生了变化。
Go语言的内存分配器最初源自TCMalloc(一种线程缓存的
malloc
)。有关TCMalloc的更多信息,可以在https://goog-perftools.sourceforge.net/doc/tcmalloc.html (opens new window)上找到。
Go分配器(Go memory allocator)使用线程本地缓存(thread - local cache)和8K或更大的连续内存区域(即内存块,spans)。这些8K区域通常有以下三种使用情况:
- 空闲(Idle):可重新用于堆/栈,或返回给操作系统的内存块。
- 正在使用(In use):当前在Go运行时中正在被使用的内存块。
- 栈(Stack):用于协程栈的内存块。
如果我们创建一个不使用共享库的程序,应该会发现程序的内存占用显著减少:
- 首先,初始化包并导入所需的库:
package main
import (
"fmt"
"runtime"
"time"
)
2
3
4
5
6
7
- 然后,执行与之前简单的HTTP服务器相同的操作,但这里仅使用
fmt
包打印一个字符串。之后让程序休眠,以便能够查看内存使用情况的输出:
func main() {
go func() {
for {
var r runtime.MemStats
runtime.ReadMemStats(&r)
fmt.Println("\nTime: ", time.Now())
fmt.Println("Runtime MemStats Sys: ", r.Sys)
fmt.Println("Runtime Heap Allocation: ", r.HeapAlloc)
fmt.Println("Runtime Heap Idle: ", r.HeapIdle)
fmt.Println("Runtime Heap In Use: ", r.HeapInuse)
fmt.Println("Runtime Heap HeapObjects: ", r.HeapObjects)
fmt.Println("Runtime Heap Released: ", r.HeapReleased)
time.Sleep(5 * time.Second)
}
}()
fmt.Println("Hello Gophers")
time.Sleep(11 * time.Second)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- 从这个程序的执行输出中可以看到,该可执行文件的堆分配比我们的简单HTTP服务器小得多:
但为什么会这样呢?我们可以使用goweight
库(https://github.com/jondot/goweight)来查看程序中依赖项的大小。我们只需要下载这个二进制文件:go get github.com/jondot/goweight
。
4. 然后可以确定Go程序中的大型依赖项:
可以看到,net/http
库占用了大量空间,runtime
和net
库也是如此。
相比之下,看看我们带有内存统计信息的简单程序:
可以看到,除了runtime
之外,我们这个程序中其他较大的部分都比net/http
和net
库小得多。准确了解资源的使用情况,对于生成更高效的二进制文件始终至关重要。
如果使用strace
查看操作系统级别的调用,接下来可以看到简单Web服务器和简单程序之间交互的差异。简单Web服务器的示例如下:
简单程序的示例如下:
从输出结果中,我们可以注意到以下几点:
- 简单Web服务器(simpleWebServer)的输出比简单程序(simpleProgram)长得多(在截图中已进行了截断处理,但如果完整生成输出,可以看到响应长度更长)。
- 简单Web服务器加载了更多的C库(从截图中的
strace
捕获信息中,我们可以看到ld.so.preload
、libpthread.so.0
和libc.so.6
)。 - 简单Web服务器的内存分配数量比简单程序的输出多得多。
我们可以查看这些内容是从哪里引入的。net/http
库本身没有任何C语言相关的引用,但它的父库net
有。在net
库的所有cgo
包中,相关文档告诉我们如何跳过使用底层的CGO
解析器来处理包:https://golang.org/pkg/net/#pkg-overview 。
该文档展示了我们如何使用Go语言的解析器和cgo
解析器:
export | GODEBUG=netdns=go | # 强制使用纯Go语言解析器 |
---|---|---|
export | GODEBUG=netdns=cgo | # 强制使用cgo解析器 |
让我们通过执行以下命令,在示例Web服务器中仅启用Go语言解析器:
export CGO_ENABLED=0
go build -tags netgo
2
在下面的截图中,我们可以看到未使用C语言解析器的simpleServer
的执行进程:
我们可以看到,此时的虚拟内存大小(VSZ)和常驻集大小(RSS)都比较低。与之对比,我们输入以下命令使用C语言解析器:
export CGO_ENABLED=1
go build -tags cgo
2
我们可以看到使用C语言解析器的simpleServer
的输出:
我们可以发现,未使用cgo
解析器编译的服务器,其VSZ显著更低。接下来,我们将讨论内存受限的情况,以及如何处理和构建相关程序。
# 内存受限情况简介
如果你在嵌入式设备或内存非常有限的设备上运行Go程序,有时了解运行时(runtime)的一些底层进程,有助于你对程序做出合理决策。Go语言的垃圾回收器优先考虑低延迟和简单性,它使用非分代并发三色标记 - 清除垃圾回收算法。默认情况下,它会自动管理内存分配。
Go语言的debug
标准库中有一个函数,它可以强制进行垃圾回收,并将内存返回给操作系统。Go语言的垃圾回收器会在5分钟后将未使用的内存返回给操作系统。如果你在内存较低的设备上运行程序,可以在以下位置找到这个函数FreeOSMemory()
:https://golang.org/pkg/runtime/debug/#FreeOSMemory 。
我们还可以使用GC()
函数,其位置为:https://golang.org/pkg/runtime/#GC 。
GC()
函数可能会阻塞整个程序。使用这两个函数时需自行承担风险,因为它们可能会导致意外的结果。
# 总结
在本章中,我们学习了Go语言如何分配堆内存和栈内存。我们还学习了如何有效地监控虚拟内存大小(VSZ)和常驻集大小(RSS),以及如何优化代码以更好地利用可用内存。掌握这些知识能让我们有效地利用现有资源进行扩展,在相同硬件条件下处理更多的并发请求。
在下一章中,我们将讨论Go语言中的GPU处理。