CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go系统接口编程
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go系统接口编程
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • Go系统接口编程 前言
  • 第一部分:引言
  • 第1章 为什么选择Go语言?
  • 第2章 重温并发与并行
  • 第3章 理解系统调用
  • 第二部分:与操作系统交互
  • 第4章 文件和目录操作
  • 第5章 处理系统事件
  • 第6章 理解进程间通信中的管道
  • 第7章 Unix套接字
  • 第8章 内存管理
    • 垃圾回收
      • 栈和堆内存分配
      • 垃圾回收算法
      • 标记阶段
      • 清除阶段
      • GOGC
      • Go环境变量
      • 垃圾回收调度器(GC pacer)
      • GODEBUG
    • 内存压载
    • GOMEMLIMIT
    • 内存竞技场
      • 使用内存竞技场
      • 新解决方案,老问题
      • 机会
      • 指南
    • 总结
  • 第三部分:性能
  • 第9章 性能分析
  • 第10章 网络编程
  • 第四部分:连接的应用程序
  • 第11章 遥测技术
  • 第12章 分布式部署你的应用程序
  • 第五部分:深入探索
  • 第13章 顶点项目——分布式缓存
  • 第14章 高效编码实践
  • 第15章 精通系统编程
目录

第8章 内存管理

# 第8章 内存管理

在本章中,我们将深入探讨Go语言中的内存管理世界,重点关注垃圾回收的机制和策略。在我们学习垃圾回收的概念,包括其在Go语言中的演进、栈内存和堆内存分配的区别,以及有效管理内存的高级技术的过程中,你将理解Go语言内存管理系统的内部工作原理。

本章将涵盖以下主要内容:

  • 垃圾回收
  • 内存区域

在本章结束时,你应该能够优化代码以减少内存使用,最小化垃圾回收开销,并最终提高应用程序的可扩展性和响应性。

# 垃圾回收

在有垃圾回收机制的编程语言出现之前,我们需要手动处理内存管理。尽管我们努力避免,但仍然难以避开内存泄漏、悬空指针和重复释放等主要问题。

Go语言的垃圾回收器承担了一些工作以避免这些常见错误:它跟踪堆上的内存分配,释放不需要的分配,并保留正在使用的分配。在学术界,这些工作通常被称为内存推断,即 “我应该释放哪些内存?”。处理内存推断的两种主要策略是追踪和引用计数。

Go语言使用追踪式垃圾回收器(简称为GC),这意味着垃圾回收器会追踪从 “根” 对象通过引用链可达的对象,将其余对象视为 “垃圾” 并进行回收。Go语言的垃圾回收器经历了漫长的优化和发展过程。你可以在Go开发团队的这篇博客文章中找到其发展至今的完整历程:https://go.dev/blog/ismmkeynote (opens new window) 。

在这篇博客文章中,Go团队公布了巨大的改进成果。例如,一次垃圾回收周期的时间从Go 1.0版本的300毫秒大幅缩短至最新版本的0.5毫秒。

你肯定至少在技术社区中听过这样的话:“Go语言中的垃圾回收是自动的,所以你可以不用管内存管理了。” 要是你信了这话,那我还有月球上的优质土地想卖给你呢。相信这种说法,就好比因为家里有扫地机器人,就认为房子会自己变干净一样。在Go语言中,理解垃圾回收可不是锦上添花的事,而是编写高效、高性能代码的关键。所以,系好安全带,我们即将进入一个 “自动” 并不意味着 “神奇” 的世界。

想象一下,有一个软件开发团队因为有代码检查工具(linter),就从不进行代码审查。这和一些人对待Go语言垃圾回收器的方式很相似。这就好比把整个代码库的质量都寄托在一个检查多余空格的程序上。当然,Go语言的垃圾回收器就像一个尽职尽责的小清洁工,不知疲倦地清理内存垃圾。但是,误解它的工作方式,就好比认为代码检查工具能把混乱的代码重构得像米其林星级美食一样精美。

为了进一步深入了解垃圾回收的知识,我们首先需要了解两个内存区域:栈和堆。

# 栈和堆内存分配

Go语言中的栈内存分配用于生命周期可预测且与创建它们的函数调用相关联的变量。这些变量包括局部变量、函数参数和返回值。栈具有后进先出(Last In, First Out,LIFO)的特性,这使得它的效率非常高。在栈上分配和释放内存只需要移动栈指针即可。这种简单性使得栈内存分配速度很快,但它也有局限性。栈的大小相对较小,如果在栈上存放过多内容,可能会导致可怕的栈溢出错误。

相比之下,堆内存分配用于生命周期较难预测且与创建位置没有严格关联的变量。这些变量通常是那些在创建它们的函数作用域之外仍然需要存在的变量。堆是一个更灵活、动态的空间,其中的变量可以被全局访问。然而,这种灵活性是有代价的。在堆上分配内存较慢,因为需要更复杂的簿记操作,并且管理这些内存的责任落在了垃圾回收器身上,这增加了额外开销 。

Go语言的编译器会执行一种巧妙的操作,称为 “逃逸分析”(我们将在第9章 “性能分析” 中详细讨论这个主题),以决定一个变量应该在栈上还是堆上分配。如果编译器确定一个变量的生命周期不会超出其所在的函数,那么它就会被分配到栈上。但是,如果变量的引用在函数外部传递或被返回,那么它就会 “逃逸” 到堆上。

这种自动决策过程对开发者来说是一大福音,因为它无需手动干预就能优化内存使用和性能。栈内存和堆内存分配的区别对性能有着重大影响。由于栈内存分配和释放机制简单,栈上分配的内存往往能带来更好的性能。

虽然堆上分配的内存对于更复杂和动态的数据是必要的,但由于垃圾回收的开销,它会带来性能成本。作为一名Go语言开发者,留意变量的分配方式有助于编写更高效的代码。尽管Go语言抽象了大部分内存管理的复杂性,但深入理解堆内存和栈内存的分配方式,会极大地影响应用程序的性能。

一般来说,尽量将变量的作用域限制得越窄越好,并谨慎使用可能导致不必要堆内存分配的指针和引用。

好了,让我们深入技术细节。Go语言的垃圾回收基于一种并发的三色标记 - 清除算法。在你看得一头雾水之前,让我们详细解释一下。

# 垃圾回收算法

“并发” 意味着垃圾回收器与你的程序同时运行,而不是暂停一切来进行清理。这对于性能至关重要,特别是在实时系统中,暂停进行内存清理就像在产品发布日屏幕冻结一样让人讨厌。

“三色” 指的是垃圾回收器看待对象的方式。可以把它想象成内存的交通信号灯:绿色表示 “正在使用”,红色表示 “准备删除”,黄色表示 “待定”。

最后一部分 “标记和清除” 定义了这个过程的两个主要阶段。简单来说:在 “标记” 阶段,垃圾回收器扫描对象,根据可访问性改变它们的颜色。在 “清除” 阶段,它清理 “垃圾”,也就是那些红色的对象。这个两步过程有助于在不影响程序运行的情况下高效管理内存。了解了整体情况后,我们就可以轻松探索这两个阶段的细节了。

# 标记阶段

“标记” 阶段分为两个部分。在初始部分,垃圾回收器会短暂暂停程序(不到0.3毫秒),可以把这想象成潜水前的快速吸气。在这个被称为 “停止世界”(stop-the-world,STW)的暂停阶段,垃圾回收器识别根集合。这些根本质上是可以从栈、全局变量和其他特殊位置直接访问的变量。换句话说,这是垃圾回收器开始搜索以确定哪些对象正在使用、哪些对象不再使用的时刻。

识别完根集合后,垃圾回收器开始实际的标记过程,这个过程与程序的执行是并发进行的。这就是 “三色” 比喻发挥作用的地方。对象最初被标记为 “白色”,这意味着它们的命运尚未确定。当垃圾回收器从根开始遍历遇到这些对象时,会将它们标记为 “灰色”,表示需要进一步探索,最终在完全处理后将它们标记为 “黑色”,表示它们正在被使用。这种颜色编码系统确保垃圾回收器全面评估每个对象的可访问性。

这个过程还有更多关键细节值得深入探讨。因为我们想要创建高性能的系统,所以需要掌握垃圾回收的知识,而不仅仅停留在理论层面。

在标记阶段,Go运行时会特意分配约25% 的可用CPU资源给垃圾回收器。这是一个经过权衡的决策,确保垃圾回收器在有效控制内存使用的同时,不会给系统带来太大负担。这就像一个杂耍演员,要确保每个球都有足够的飞行时间,但又不会抢占太多注意力,是一种平衡的艺术。这25% 的资源分配对于保持垃圾回收器稳定且不干扰程序运行至关重要。

除了标准的CPU资源分配,还有额外5% 的CPU资源可通过标记辅助(mark assists)来使用。当程序在垃圾回收周期内进行内存分配时,就会触发标记辅助。如果垃圾回收器落后了,正在进行内存分配的goroutine会伸出援手(这里指贡献一些CPU周期)来协助标记过程。这额外的5% 可以看作是一支后备力量,在需要时投入使用,确保垃圾回收器能跟上内存分配的速度。

# 清除阶段

进入清除阶段,内存释放开始发挥作用。在标记阶段确定了哪些对象不再需要(那些仍然标记为 “白色” 的对象)之后,清除阶段就开始释放这些内存。这个阶段至关重要,因为实际的内存回收就在此发生,为未来的内存分配腾出空间。这个阶段的效率直接影响应用程序的内存占用和整体性能。但这并不总是一帆风顺的。垃圾回收器仍然可能导致性能问题,比如延迟峰值,特别是在处理大堆内存或内存需求大的应用程序时。了解如何优化代码以更好地配合垃圾回收器工作是一门艺术。这涉及深入研究指针管理、避免内存泄漏,有时甚至要知道何时说:“嘿,垃圾回收器,你可以休息一下了,我来处理。”

# GOGC

Go语言中的GOGC环境变量是垃圾回收器的调节旋钮。它就像家里供暖系统的恒温器,控制着房间的温度。在Go语言的环境中,GOGC决定了垃圾回收过程的积极程度。它决定了在垃圾回收器触发下一个周期之前,允许新分配多少内存。理解并调整这个变量会显著影响Go应用程序的内存使用和性能。其默认值是100,这意味着垃圾回收器在完成一次新的垃圾回收周期后,会尽量使堆内存保持至少初始大小的100% 可用。调整GOGC的值可以让你根据应用程序的特定需求来定制垃圾回收。

# Go环境变量

GOGC是一个影响垃圾回收器的环境变量,但它不是Go工具链或编译器特有的配置选项。

将GOGC设置为较低的值,比如50,意味着垃圾回收器会更频繁地运行,这会使堆大小保持较小,但会消耗更多的CPU时间。相反,将其设置为较高的值,比如200,意味着垃圾回收器运行频率会降低,允许更多的内存分配,但可能会导致不必要的内存使用增加。

GOGC变量可以取任何大于0的整数值。将其设置为非常低的值可能会因为垃圾回收器过于频繁地运行而导致性能下降,就像一个清洁工不停地打扫,最后反而成了麻烦。相反,将其设置得过高会使应用程序使用比必要更多的内存,这在内存受限的环境中可能并不理想。找到适合你应用程序内存和性能特点的最佳值非常重要。

GOGC还有一些特殊值。将其设置为off会完全禁用自动垃圾回收。在程序生命周期较短、不值得为垃圾回收付出额外开销的场景中,这可能会有用。然而,能力越大,责任越大;禁用垃圾回收可能会导致内存不受控制地增长。这有点像关掉家里的自动恒温器,在某些情况下可能有用,但需要更多关注以避免出现问题。

在实践中,调整GOGC需要了解应用程序的内存使用情况和性能需求。这需要仔细的实验和监控。调整这个变量可以显著提升性能,特别是在有大堆内存或实时性要求的系统中。

# 垃圾回收调度器(GC pacer)

Go语言中的垃圾回收调度器就像管弦乐队的指挥,确保每个部分在正确的时间进入,共同演奏出和谐的乐章。它的工作是调节垃圾回收周期的时间,平衡回收内存的需求和保持程序高效运行的需求。调度器的决策基于当前堆大小、内存分配速率以及维持程序性能的目标。

调度器的主要职责是确定何时开始一个新的垃圾回收周期。它通过监控内存分配速率和活动堆大小(由GOGC暗示),即正在使用且无法回收的内存,来做出决策。调度器的策略是在程序分配过多内存之前触发垃圾回收周期,因为过多的内存分配可能会导致延迟增加或内存压力增大。这是一种预防措施,就像在汽车机油出现大问题之前更换机油一样。

垃圾回收调度器的一个关键特性是它的自适应性。它会根据应用程序的行为不断调整阈值。如果一个应用程序快速分配内存,调度器会更频繁地触发垃圾回收周期以跟上节奏。相反,如果应用程序的分配速率减慢,调度器会在启动垃圾回收周期之前允许分配更多内存。这种自适应性确保调度器的行为与应用程序当前的需求保持一致。

调度器与GOGC环境变量协同工作。GOGC设置了在触发垃圾回收周期之前允许堆内存增长的百分比。调度器将这个值作为指导来确定自己的阈值。

垃圾回收调度器的有效性直接影响应用程序的性能。一个调优良好的调度器能确保垃圾回收顺利进行,不会导致明显的暂停或延迟峰值。然而,如果调度器的阈值与应用程序的行为不匹配,可能会导致垃圾回收周期过多(这会降低性能),或者回收延迟(这会增加内存使用)。这就像找到巡航控制的合适速度,太快或太慢都会让驾驶体验不舒服。

Go语言中的垃圾回收调度器是确保垃圾回收过程高效的关键组件。编写代码不仅仅是编写代码本身,还包括理解代码运行的环境,而垃圾回收调度器就是这个环境的重要组成部分。

# GODEBUG

Go语言中的GODEBUG环境变量是开发者的强大工具,它能深入了解Go运行时的内部工作原理。具体来说,GODEBUG=gctrace=1这个设置常用于获取关于垃圾回收过程的详细信息。让我们深入探讨一下。

Go语言中的GODEBUG就像是汽车的诊断工具包。就像你可能会插入一个诊断工具来了解汽车引擎盖下发生了什么一样,GODEBUG能让你深入了解Go运行时。在它的众多功能中,最常用的功能之一是gctrace。当设置为1(GODEBUG=gctrace=1)时,它会启用垃圾回收活动的追踪,为你提供一个窗口,让你了解Go应用程序中垃圾回收的发生方式和时间。

将gctrace设置为1会为每个垃圾回收周期输出详细信息,包括开始时间、持续时间、回收前后的堆大小以及回收的内存量。这些数据对于理解垃圾回收对应用程序性能的影响非常宝贵。这就像有一个关于垃圾回收器如何管理内存的实时解说,对于性能调优至关重要。

gctrace=1的输出可能会很密集,一开始可能会让人望而生畏。它包含几个指标,比如STW时间,它表示在垃圾回收期间应用程序暂停的时长。其他细节包括正在运行的goroutine数量、堆大小以及垃圾回收周期的数量。解读这个输出就像解读藏宝图一样,一旦你理解了其中的符号和数字,它就能揭示关于应用程序性能可以在哪些方面改进的宝贵信息。以这个输出为例:

gc 1 @0.019s 2%: 0.015+2.5+0.003 ms clock, 0.061+0.5/2.0/3.0+0.012 ms cpu, 4 ->4 ->1 MB, 5 MB goal, 4 P
1

让我们分解一下这个输出:

  • gc 1:表示垃圾回收周期的序号。
  • @0.019s:表示这个垃圾回收周期开始时,程序启动后的时间(以秒为单位)。
  • 2%:表示垃圾回收占用的总程序运行时间的百分比。
  • 0.015+2.5+0.003 ms clock:垃圾回收周期时间的细分。
    • 0.015 ms:停止世界清除终止阶段的时间。
    • 2.5 ms:并发标记和扫描阶段的时间。
    • 0.003 ms:停止世界标记终止阶段的时间。
  • 0.061+0.5/2.0/3.0+0.012 ms cpu:垃圾回收周期的CPU时间。
    • 0.061 ms:停止世界清除终止阶段的CPU时间。
    • 0.5/2.0/3.0:并发阶段(标记/扫描、辅助、后台)的CPU时间。
    • 0.012 ms:停止世界标记终止阶段的CPU时间。
  • 4->4->1 MB:垃圾回收开始、中间和结束时的堆大小。
  • 5 MB goal:下一个垃圾回收周期的目标堆大小。
  • 4 P:使用的处理器数量。

通过这些数据我们可以观察到:

  • 频繁的高占比:如果垃圾回收占用的时间百分比很高且频繁出现,这可能表明存在性能问题。
  • 停止世界时间:较长的停止世界时间可能表明需要进行优化以减少垃圾回收暂停时间。
  • 堆大小趋势:如果在垃圾回收周期后堆大小持续增长而没有相应减少,可能表明存在内存泄漏。
  • CPU时间:较高的CPU时间可能表明垃圾回收器比预期更忙碌,这可能是由于内存使用效率低下造成的。

设置GODEBUG=gctrace=1在怀疑存在内存泄漏,或者试图优化内存使用和垃圾回收开销的场景中特别有用。例如,如果你观察到较长的停止世界时间,这可能表明你的应用程序在垃圾回收上花费了太多时间,从而导致性能瓶颈。同样,如果堆大小持续增长,这可能是内存泄漏的迹象。这种深入的洞察对于做出关于代码优化和内存管理的明智决策至关重要。然而,就像任何强大的工具一样,使用时需要理解其原理并谨慎操作。通过利用gctrace,开发者可以显著提高Go应用程序的效率和性能。

# 内存压载

在Go语言中,内存压载(Memory ballast)本质上就像是在汽车后备箱里放一个沉重的行李箱,防止车子因为太轻而在冰面上打滑。在Go语言的环境里,内存压载是指分配一大块从未使用的内存,其作用是影响垃圾回收器(garbage collector)的行为。

传统上,Go语言的垃圾回收器会在堆大小达到上一次垃圾回收结束时堆大小的两倍时触发(GOGC=100)。在堆大小较大的应用程序中,这可能会导致垃圾回收周期间隔很长,而一旦触发回收,就会进行大规模且具有破坏性的回收操作。

开发人员使用内存压载作为一种缓冲手段,人为地增加堆大小,促使垃圾回收周期更频繁,但每次回收的规模更小、破坏性更低。这是一种手动调优方法,用于优化性能,在高吞吐量、低延迟的系统中尤为重要。这项技术是由流媒体公司Twitch在2019年开发的,相关内容发布在他们的文章《How I learnt to stop worrying and love the heap》(https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/ (opens new window) )中。

Twitch有一个名为Visage的服务,它作为API前端,是所有外部发起的API流量的中央网关。该服务用Go语言构建,运行在AWS EC2上。他们在处理大量流量高峰时遇到了挑战,尤其是在 “刷新风暴” 期间,当一位热门主播的直播中断并重新开始时,会导致观众反复刷新页面。Visage应用程序每秒会触发大量的垃圾回收周期,这在高峰负载期间消耗了大量的CPU周期,增加了API延迟。该应用程序的堆大小相对较小,在流量高峰时,垃圾回收周期的数量会增加,进而进一步降低性能。

当他们引入内存压载后,增大了堆的基础大小,延迟了垃圾回收的触发,并随着时间的推移减少了垃圾回收周期的数量。这是通过分配一个非常大的字节数组实现的,由于应用程序仍然引用着这个数组,所以它不会被当作垃圾回收。创建这个数组的代码片段如下:

ballast := make([]byte, 10<<30)
1

非常简单但却很有效,对吧?对他们来说,结果如下:

  • 引入内存压载后,垃圾回收周期减少了约99%。
  • API前端服务器的CPU利用率降低了约30%,高峰负载期间整体第99百分位数的API延迟降低了约45%。
  • 内存压载有效地使堆在触发垃圾回收之前能够增长得更大,这提高了每台主机的吞吐量,并在负载情况下提供了更可靠的单请求处理能力。
  • 内存压载分配的内存大多驻留在虚拟内存中,使其成为一种具有成本效益的解决方案。

尽管内存压载技术在Twitch这样的特定场景中非常有效,但它并非普遍适用,在以下几种情况下应避免使用或谨慎使用:

  • 内存敏感型应用程序:在内存是稀缺资源的环境中,分配一大块内存作为压载可能不可行。对于在内存有限的硬件上运行的应用程序,或者在内存开销是关键因素的密集型容器化环境中运行的应用程序来说,尤其如此。
  • 内存使用动态变化的应用程序:如果一个应用程序的内存使用高度动态且不可预测,设置固定大小的内存压载可能会导致内存利用效率低下。
  • 低延迟系统:虽然内存压载可以减少垃圾回收的频率,从而提高吞吐量,但它并不总是对低延迟系统有益,因为在低延迟系统中,垃圾回收暂停的可预测性更为关键。内存压载技术主要是为了优化吞吐量,这可能会以垃圾回收触发前堆大小增大导致延迟增加为代价。
  • 堆占用空间小的应用程序:本身堆占用空间就小的应用程序可能无法从内存压载中获益。在这种情况下,管理一大块未使用内存分配的开销可能会超过减少垃圾回收频率带来的好处。
  • 垃圾回收器调优已足够的情况:有时,调整垃圾回收器的参数(例如GOGC环境变量)就可以在不需要内存压载的情况下实现所需的性能提升。这种方法应该首先考虑,因为它是优化垃圾回收行为的侵入性较小的方式。
  • 内存压载掩盖潜在性能问题的情况:使用内存压载来提高性能可能会掩盖应用程序代码或架构中潜在的低效率问题。直接解决这些根本问题,而不是依赖内存压载作为一种变通方法,这一点很重要。

内存压载是管理这些关键场景的绝佳选择,但它只适用于Go 1.19及之前的版本。从Go 1.20版本开始,有一种标准化的方法,即使用GOMEMLIMIT环境变量来设置应用程序的 “软” 内存限制。

# GOMEMLIMIT

使用GOMEMLIMIT,你可以为Go运行时的内存使用设置一个软上限,这个上限涵盖堆内存和运行时管理的其他内存。这个上限就像是在告诉你的应用程序:“这是你的内存预算,要明智地使用它。”

自Go 1.20起,策略重点已从像内存压载这样的手动调整,转向利用内置的运行时特性进行内存管理。GOMEMLIMIT为限制内存使用提供了一种更直接、更易于管理的方法。

GOMEMLIMIT变量用于为运行时设置软内存限制。这个限制涵盖Go语言的堆内存以及运行时管理的所有其他内存,但不包括外部内存源,如二进制文件的映射、其他语言管理的内存,或操作系统为Go程序管理的内存。GOMEMLIMIT是一个以字节为单位的数值,也可以添加单位后缀以更清晰地表示。支持的后缀包括B、KiB、MiB、GiB和TiB,遵循IEC 80000 - 13标准。这些后缀表示基于2的幂次方的字节数量;例如,KiB表示2的10次方字节,MiB表示2的20次方字节,依此类推。默认情况下,GOMEMLIMIT被设置为math.MaxInt64,这实际上是禁用了内存限制。不过,你可以在运行时使用runtime/debug.SetMemoryLimit来更改这个限制。理解GOMEMLIMIT的关键在于它的 “软上限” 特性。与硬限制不同,硬限制会严格限制内存使用,而软上限则更灵活。GOMEMLIMIT会影响垃圾回收器的行为,当内存使用接近设置的限制时,会促使垃圾回收器更积极地运行。然而,这并不意味着绝对防止内存使用超过限制。这就好比道路上的速度警示标志,它提示了一个安全速度,但并不能实际让你的汽车减速。

为什么不两者都用呢?
同时使用内存压载和GOMEMLIMIT可能是多余的,就像戴两块手表看时间一样。内存压载用于人为增大堆大小以改变垃圾回收行为,而使用GOMEMLIMIT时,你已经定义了堆的上限。

# 内存竞技场

Go 1.20版本引入了一个实验性的arena包,它提供了内存竞技场(Memory arenas)功能。这些内存竞技场可以通过减少运行时需要进行的内存分配和释放操作的数量来提高性能。

内存竞技场是一种有用的工具,用于从连续的内存区域分配对象,并在一次操作中释放所有对象,同时将内存管理或垃圾回收的开销降至最低。在那些需要分配大量对象、对其进行长时间处理,然后在最后释放所有对象的函数中,内存竞技场特别有用。需要注意的是,内存竞技场是一个实验性特性,仅在设置了GOEXPERIMENT=arenas环境变量的Go 1.20版本中可用。

警告
Go团队不为内存竞技场的API和实现提供支持或兼容性保证,它可能不会在未来的版本中存在。

# 使用内存竞技场

一旦设置了GOEXPERIMENT=arenas环境变量,我们就可以导入arena包:

import "arena"
1

要创建一个新的内存竞技场,我们可以使用NewArena()函数,它会返回新的内存竞技场引用:

mem := arena.NewArena()
1

有了可用的内存竞技场后,我们就可以为我们的类型请求新的引用。在下面的代码片段中,我们在内存竞技场中为Person结构体类型创建一个新的引用:

mem := arena.NewArena()
p := arena.New[Person](mem)
1
2

这与正常的内存分配流程有重要区别。我们不是创建新的引用然后放入内存竞技场,而是向内存竞技场请求新的引用。

arena包中引入了一些新的API,比如MakeSlice,它用于为内存竞技场请求一个预先确定容量的切片。如果我们想请求一个新的受内存竞技场约束的切片,可以使用如下代码:

mem := arena.NewArena()
slice := arena.MakeSlice[string](mem, 100, 100)
1
2

我们可以重复这个过程并正常操作对象,但是当我们使用完内存竞技场后,可以调用Free()函数:

mem := arena.NewArena()
p := arena.New[Person](mem)
// other set of arena related operations
mem.Free()
1
2
3
4

请记住,释放内存竞技场会一次性释放所有对象,而不是像Go语言垃圾回收的正常流程那样,在清理阶段分散地进行释放操作。

有时,我们想在释放内存竞技场中的所有对象之前,将在内存竞技场中创建的一些对象转移到堆(进行垃圾回收)中。这可以通过使用Clone函数来实现:

mem := arena.NewArena()
p1 := arena.New[Person](mem) // arena-allocated
p2 := arena.Clone(p1) // heap-allocated
1
2
3

在这个代码片段中,p1是在内存竞技场中分配的,而p2是在堆中分配的。

# 新解决方案,老问题

因为我们需要主动释放内存竞技场,所以在开发过程中这一新步骤可能容易出错。最常见的问题是在释放内存竞技场后还使用其中的对象。为了简化操作,Go工具链在程序执行时有一个标志,可以激活地址消毒剂(address sanitizer,asan)。

考虑以下程序:

type T struct {
    Num int
}

func main() {
    mem := arena.NewArena()
    o := arena.New[T](mem)
    mem.Free()
    o.Num = 123 // <- this is a problem
}
1
2
3
4
5
6
7
8
9
10

因此,我们可以使用地址消毒剂来执行这个程序:

go run -asan main.go
1

输出将如预期那样显示问题:

accessed data from freed user arena 0x40c0007ff7f8
1

# 机会

Go语言开发的几个领域可能会从内存竞技场中受益。最典型的例子是gRPC。每次程序处理RPC请求时,在消息的编码和解码过程中会分配许多对象。正如我们之前看到的,这往往会给垃圾回收器带来更大压力。这一策略在一定程度上证明了它对性能的影响,因为gRPC的C++实现已经使用了内存竞技场的概念(https://protobuf.dev/reference/cpp/arenas/ (opens new window))。另一个使用内存竞技场(作为一个概念)来提高性能的例子是JSON序列化过程。fastjson项目(https://github.com/valyala/fastjson (opens new window))使用内存竞技场来处理序列化,据说比Go标准库快15倍。

# 指南

在项目中引入内存竞技场之前,你可以问自己几个问题。 我有关于我怀疑的问题的数据吗? 如果你没有数据,那你只是在猜测。 我有大量的内存分配操作,还是只有少数几个? 如果你没有大量的内存分配操作,引入内存竞技场会使你的程序使用更多的内存。一般来说,一个内存竞技场的大小是8MB。 这些分配的对象结构都很小且相似吗? 也许你找错工具了。可以考虑使用sync.Pool。 这是我程序的关键路径吗? 这可能是过早的优化。在考虑使用内存竞技场之前,可以尝试多种垃圾回收器和GOMEMLIMIT的组合。

是时候总结一下我们对内存管理的认识了。

# 总结

我们探讨了垃圾回收(GC)、栈内存分配和堆内存分配的区别,以及优化内存使用以提高性能的方法。我们还了解了Go语言垃圾回收器的发展及其方法,包括三色标记法、并发标记清除法等高级主题。

我们还讨论了一些实用方法,比如使用GOGC等环境变量对垃圾回收进行微调,以及采用内存压载和GOMEMLIMIT等技术来帮助垃圾回收器管理程序内存。

在本章中,你可能会问自己:通过调整垃圾回收器和运行时参数,并结合这些技术,我们能获得多少性能提升呢?

答案很简单:性能不是猜谜游戏,我们应该进行测量。

在下一章(关于性能分析)中,我们将探讨如何从内存、CPU、内存分配等多个方面对应用程序进行性能分析。

第7章 Unix套接字
第三部分:性能

← 第7章 Unix套接字 第三部分:性能→

最近更新
01
第一章 auto与类型推导
03-27
02
第二章 关键字static及其不同用法
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式