Go 内存管理和垃圾回收机制

本文尝试用从操作系统底层原理把操作系统进程管理、内存管理讲解清楚,然后回归到 GO 应用程序上面,分析 GO 应用程序是如何进行内存管理(包含内存分配、垃圾回收机制等),同时附带对应的图示方便理解,希望将这块知识系统串联起来,搭建起 Go 应用程序运行的系统观。

操作系统原理篇 - 进程管理

程序编译

进程

进程加载

进程是计算机中程序执行的实体,每个进程都有自己独立的虚拟地址空间、程序代码、数据、栈等资源。在操作系统中,进程的创建和加载过程包括以下几个步骤:

  1. 分配进程控制块(PCB):进程控制块是操作系统对每个进程进行管理的数据结构,用于保存进程的状态信息、进程号、进程权限等内容。在进程创建时,首先需要分配一块内存作为该进程的进程控制块,并在内核中记录该进程的信息。

  2. 加载程序代码: 程序代码是进程执行的核心,通常被保存在文件系统中。在进程加载时,操作系统需要将程序代码从文件系统中读入内存。其中,可以使用基于文件的映像或者基于共享内存的加载机制。加载程序代码的过程通常包括以下几个步骤:

    • 加载节数和程序长度
    • 分配内存空间
    • 读入程序代码
    • 建立用户态地址映射
  3. 加载数据和栈: 进程需要的数据和栈同样要在进程启动时加载到内存中。和程序代码一样,数据和栈也需要被分配内存空间,并被载入内存。通常,数据和栈的加载会在程序代码加载完之后进行。

  4. 分配其他资源: 一些进程需要额外的资源,例如打开的文件、网络连接等。这些资源都需要在进程启动时由操作系统进行分配和初始化。

  5. 设置进程上下文: 当操作系统加载完进程所需的代码、数据、栈等资源后,需要设置进程的上下文(也称上下文切换)。上下文切换是指操作系统将 CPU 的技术环境从一个进程切换到另一个进程的过程,通常包括程序计数器、CPU 寄存器、内存页表等状态。

  6. 运行进程:进程准备就绪后,操作系统将其添加到进程调度队列中,并开始执行进程的程序代码。进程的运行过程通常包括多次的上下文切换、I/O 操作、资源共享等操作,直至进程完成任务或中止。

总之,进程的加载和启动是操作系统的重要功能之一。在进程创建和加载过程中,操作系统需要完成程序代码、数据、栈等资源的分配和载入,设置进程上下文以及其他资源的分配等操作。通常,操作系统会为每个进程分配独立的资源,并增强安全性以提高系统的健壮性。

进程状态变迁

在操作系统中,进程在其生命周期中可能会经历如下几个状态:

  1. 创建状态(New State) 进程创建和分配资源时处于该状态。在这个状态下,操作系统为进程分配进程控制块(PCB)和其他必要的资源。
  2. 就绪状态(Ready State) 进程已准备好运行但等待分配 CPU 资源时处于该状态。在这个状态下,进程已经获得了所需要的资源,只是在等待系统调度器为其分配 CPU 资源。
  3. 运行状态(Running State) 进程正在运行时处于该状态。在这个状态下,进程正在使用 CPU 和其他资源进行计算和操作。
  4. 阻塞状态(Blocked State) 当进程正在等待某个事件(如 I/O 操作完成)时,处于该状态。在这个状态下,进程无法继续运行,直到等待的事件发生。
  5. 挂起状态(Suspended State) 当进程暂时被挂起时处于该状态,例如进程等待其他进程协同工作的时候白日。在这个状态下,进程被暂停执行,但 PCB 和其他资源未被释放。
  6. 结束状态(Terminated State) 进程执行完成或因某种原因退出时处于该状态。在这个状态下,进程占用的所有资源都被释放。

进程状态的变化通常会受到以下条件或场景的影响:

  • 进程创建: 当进程创建时,它处于新状态。
  • 进程就绪: 当进程所需要的资源都准备好时处于就绪状态
  • 进程被调度: 当系统调度程序为进程分配 CPU 时间时,它会从就绪状态转换为运行状态。
  • 阻塞等待某些事件的完成: 当进程等待一些事件(如 I/O 操作)的完成时,它会从运行状态转换为阻塞状态(等待挂起)
  • 事件完成: 当进程等待的事件完成时,它会从阻塞状态回到就绪状态,等待操作系统的重新调度 CPU
  • 进程终止: 当进程执行完或因其他原因退出时,它会从运行状态转换到结束状态。
  • 等待挂起/继续: 在某些情况下,如进程间通信等,操作系统可能会将进程挂起,以便其他进程协同工作(等待挂起、就绪挂起)

等待挂起和就绪挂起区别:

  • 等待挂起: 当一个进程等待某些事件的发生,如在等待某些资源时,可以将进程从运行状态挂起到等待挂起状态,这时进程不再占用 CPU,但仍占用内存资源以及其他必要的资源

  • 就绪挂起: 当一个进程等待某些资源,如 I/O 操作完成、内存资源,进程不再占用 CPU,但是进程不退出等待状态,而是进入就绪队列等待 CPU 分配

    两者之间的区别在于,等待挂起的进程已经放弃了 CPU 的控制,处于等待特定事件的状态,无法进入进程调度队列中,等待结束才能重新进入调度队列;而就绪挂起的进程,虽然被暂停,但其仍然保持在就绪队列中等待 CPU 分配,它们可以在 CPU 时间分配到达时立即开始运行。

进程调度

操作系统原理篇 - 内存管理

进程虚拟地址空间

总体来说,虚拟地址空间是操作系统中一个基本的概念。它为进程提供了一种抽象的地址空间,隐藏了物理地址空间的底层细节,提高了操作系统的安全性和稳定性。

概念 Per Process (抽象、隔离)

计算机中的虚拟地址空间(Virtual Address Space)是为了方便进程访问内存而设计出来的一种地址映射技术

在操作系统中,每个进程在运行时都会有一个独立的虚拟地址空间,该虚拟地址空间为进程提供了一种抽象的地址空间,进程可以把它看做是自己独占的物理地址空间。

虚拟地址空间具有以下特点:

  1. 它是一个逻辑地址空间,为进程提供了一种抽象的地址空间,可以把物理地址空间映射到虚拟地址空间上。
  2. 允许进程使用连续的虚拟地址来顺序寻址,不管实际物理地址是什么样子的。
  3. 隔离了进程的虚拟地址空间和物理地址空间之间的差异。进程不需要关心物理地址空间的布局和管理方式,只需要与虚拟地址空间进行交互。
  4. 它使不同进程互相隔离,进程之间不能直接访问彼此的虚拟地址空间,提高了操作系统的安全性。

实现虚拟地址映射

在使用虚拟地址空间时,操作系统将虚拟地址映射到物理地址。虚拟地址空间通常被划分为多个大小相同的页,每个页通常包含 4KB ~ 8KB 字节的数据。当进程访问虚拟地址时,操作系统会将虚拟地址转换为物理地址,在计算机的内存中查找相应的数据,操作系统将这个过程称为地址映射。

地址映射可以通过硬件和软件来实现。硬件通过内存管理单元(MMU)来实现地址映射。MMU 位于 CPU 内部,负责将虚拟地址转换成物理地址。软件也可以通过调用操作系统提供的系统调用来实现地址映射。

虚拟地址空间构成

进程在操作系统中运行时,会被分配一个独立的虚拟地址空间,该地址空间由多个部分构成,包括以下几个部分:

  1. 代码段: 代码段存储了进程的可执行代码,也就是进程的程序代码。代码段通常是只读的,不允许被修改。
  2. 数据段: 数据段存储了进程需要的静态或全局变量、常量、数组等数据。数据段常常被分为可读、可写和只读三个部分,以保证程序可以正常访问各种类型的数据。
  3. : 堆用于动态内存分配(malloc),程序可以在堆中申请任意大小的内存空间,并在程序运行时进行动态管理。堆通常对应着物理内存中的非连续空间
  4. : 栈用于存储函数调用时的局部变量、参数、返回地址等信息。通常在程序运行时由系统自动分配和管理,具有后进先出的特性。
  5. 共享库: 共享库是被多个进程可共享的代码和数据(mmap),被从物理内存中独立出来以保证它们在多个进程的虚拟地址空间中只有一份拷贝,节省了空间,加快了程序的启动速度。
  6. 内核空间: 内核空间是操作系统内核所使用的地址空间,这部分空间为操作系统内核所独占,包括内核代码和各种数据结构。

进程虚拟地址空间中存储的内容包括代码和数据部分,以及程序代码所需要的向量表描述符表页表等数据结构,这些数据结构用于存储程序运行时的上下文信息,例如进程的状态、寄存器信息、文件描述符、页表和中断向量等。

总之,进程虚拟地址空间是操作系统中最重要的概念之一,它允许操作系统为每个进程提供独立的地址空间,保护每个进程的私有数据不被其他进程访问,同时使得进程可以在不干扰其他进程的情况下进行内存的动态分配和管理(资源隔离)。

内存分配过程

页表

页表是一种数据结构,用于操作系统将虚拟地址映射到物理地址。页表每行代表一个虚拟地址页和一个物理地址页的映射关系,用于记录进程的虚拟地址空间对应的物理地址空间的情况。操作系统通过页表来实现虚拟内存的地址转换,将进程访问的虚拟地址映射到对应的物理地址上。页表内部通常由许多页表项(Page Table Entry,PTE)组成,每个页表项包含了一些元信息,如物理页帧号、权限位和缺页信息等。

在操作系统中,每个进程都有自己的页表当进程访问虚拟地址时,操作系统会根据虚拟地址的高位来寻找页表项,从而确定该虚拟地址所属的物理页帧号,然后将虚拟地址中的偏移量与物理页帧号拼接起来获得实际物理地址。

页表的作用在于将虚拟地址空间和物理地址空间进行映射,操作系统可以通过页表将不连续的物理地址映射到连续的虚拟地址空间上,为进程提供了一个完整的、独立的虚拟地址空间,使每个进程都能够独立地访问内存,共享物理地址空间中的资源。

不过,页表的查询是一个耗时的操作,如果数组太大或者需要进行多次页表查询,将会缩慢系统的速度,因此一些常见的优化方式涉及利用多级页表和 TLB(页表缓冲),通过减小页表访问的次数和复杂度来提高效率。

TLB(Translation Lookaside Buffer)

TLB(Translation Lookaside Buffer)是一种硬件缓存,用于加速虚拟地址到物理地址的转换过程。在计算机系统中,CPU 通过虚拟地址来访问内存中的数据和指令,而这些虚拟地址需要经过翻译成对应的物理地址才能被实际访问。这个翻译过程由操作系统负责完成,并且会使用到页表等数据结构。

为了提高翻译效率,TLB 被引入到 CPU 中。它可以缓存最近使用的一部分页表项,以便下次再访问相同的虚拟页面时可以直接从 TLB 中获取对应的物理页面信息,避免了频繁地查询页表带来的性能损失。

不同架构和型号的 CPU 具有不同大小和组织方式的 TLB。通常情况下,在处理器芯片上集成一个小型、快速但容量较小(几百至几千条目) 的 TLB 与一个大型、慢速但容量更大(数万至数十万条目) 的 TLB 组合起来使用。

缺页异常(PageFault)

在操作系统中,虚拟地址空间通常被划分为多个大小相等的页。页表是一种数据结构,用于将虚拟地址转换成物理地址。它包含了虚拟页号和物理页号的映射关系。当进程需要访问某个虚拟地址时,操作系统会将虚拟地址的高位作为索引,在页表中查找对应的物理页号和物理页内偏移量,然后将物理页号和偏移量拼接在一起,得到对应的物理地址。

然而,并不是所有的虚拟页号都会被映射到物理页号上。有一部分的虚拟页号可能尚未分配,或者已经释放,它们并没有一个有效的物理页号与之对应。在这种情况下,当进程访问这些虚拟地址时,系统会触发一个页错误(Page Fault),操作系统需要通过合适的错误处理程序来处理这个错误(缺页中断服务例程)。

Go 应用程序开发

资源对象池化,降低 GC 开销

因为在长时间运行的服务中,如果频繁创建和销毁只需单个指针的结构体,会产生大量的垃圾对象,最终会导致垃圾回收器的压力增大,影响程序的性能。

最常见优化方式一直就是复用对象(特别是那些创建非常耗时的昂贵资源,比如 TCP 连接、可以复用的对象),下面举一个池化的示例:

1
2
3
4
5
// 链表节点
type ListNode struct {
    Val int
    Next *ListNode
}

在某些情况下,需要频繁地创建和销毁这样的链表节点。如果使用 pre := &ListNode{}pre := new(ListNode) 的方式来创建链表节点,每次创建时都会在堆上分配一块新内存,随着时间的推移,会出现大量的垃圾对象,导致垃圾回收器的压力增大,影响程序的性能。

如果预先分配足够的空间来缓存链表节点,每次需要新节点时就从缓存取出一个空闲节点,避免频繁的内存分配和回收操作,可以提升程序的性能。

下面是一个使用预分配池缓存链表节点的示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type ListNode struct {
    Val int
    Next *ListNode
}

type ListNodePool struct {
    buf chan *ListNode
}

func NewListNodePool(size int) *ListNodePool {
    return &ListNodePool{
        buf: make(chan *ListNode, size),
    }
}

func (p *ListNodePool) Get() *ListNode {
    var n *ListNode
    select {
    case n = <-p.buf:
    default:
        n = &ListNode{}
    }
    return n
}

func (p *ListNodePool) Put(n *ListNode) {
    select {
    case p.buf <- n:
    default:
    }
}

// 在程序启动时初始化链表节点池
var nodePool = NewListNodePool(1000)

func main() {
    // 从节点池中获取节点
    node := nodePool.Get()
    // 释放节点到节点池中
    nodePool.Put(node)
}

在这个示例中,我们创建了一个 ListNodePool 类型来管理缓存的链表节点。在初始化 ListNodePool 时,我们指定了池的大小为 1000。在 Get 方法中,我们首先尝试从通道 buf 中获取可用的节点,如果通道为空则创建一个新节点。在 Put 方法中,我们将节点释放到 buf 中,如果通道已满则直接丢弃节点。

使用 nodePool.Get() 和 nodePool.Put(node) 的方式获取和释放链表节点,可以避免频繁的内存分配和回收操作,提高程序的性能。