go 的 runtime 有有哪些功能

编译过程

词法分析

  • 由词法分析器(lexer)将源代码文件转换成 Token 序列
  • 每一个 Token 表示一个词法单元,比如标识符,关键字,常量等等
    • Token 分成四类
      • 特殊类型
      • 基础类型
      • 运算符
      • 关键字
go 复制代码
func main() {
  fset := token.NewFileSet()
  var s scanner.Scanner
  file, err := os.Open("main2.go")
  if err != nil {
    panic(err)
  }

  content, err := io.ReadAll(file)
  if err != nil {
    panic(err)
  }
  fileInfo, _ := file.Stat()
  s.Init(fset.AddFile("main2.go", fset.Base(), int(fileInfo.Size())), content, nil, scanner.ScanComments)
  log.Println("token sequence")
  for {
    pos, tok, lit := s.Scan()
    if tok == token.EOF {
      break
    }
    fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
  }
}

语法分析

  • token 序列转换为抽象语法树 ASTAST 是用来表示程序的语法结构)
  • 抽象语法树的每个节点表示一个语法元素,比如常量,表达式,语句,函数等等
    • 每一个抽象都对应一个单独的 go 语言文件
  • go 语言在解析的过程中发生的任何语法错误都会被语法解析器发现,并且将这些信息输出到标准输出上,整个编译过程也会随着错误的出现而终止
go 复制代码
func main() {
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, "main2.go", nil, parser.ParseComments)
  if err != nil {
    panic(err)
  }
  log.Println("Abstract syntax tree")
  ast.Print(fset, f)
}

类型检查(语义分析)

  • AST 的每个节点进行类型检查
    • 语法分析只能检测到括号或者操作符是否匹配,这些常规的语法问题,并不能确定语句的具体含义,所以还需要对抽象语法树的每个节点进行类型检查
  • 需要遍历抽象语法树来识别节点的类型,包括需要推断出来的类型

中间码生成

  • 将源码翻译成介于源代码和目标机器码中间的中间代码(Intermediate Representation
    • 编译前端完成由源代码到 Token 再到 AST 的翻译工作
    • 中间代码生成阶段,完成 AST 到基于 ASTIR Tree 的翻译工作

为什么要生成中间代码

  1. 便于对代码的优化
  2. 解耦合复用

编译器面临的场景是很复杂的,很多编译器都需要将源码翻译成各种机器码

将编程语言到成机器码的生成,拆成中间代码的生成和机器码的生成,可以简化机器码生成的过程

中间代码是一种更接近机器语言的表达形式,对中间代码的优化和分析,相比直接分析高级语言更加容易

我们知道编译后端最终是生成不同 cpu 架构上的机器码,而在生成机器码之前,我们还需要对代码进行优化,这些优化不是针对某个 cpu 架构的,而是跟编译环境无关的一些优化,我们将跟生成机器码之前的这些步骤都耦合在一起,那随着对不同架构 cpu 的支持,这些工作也得在新的 cpu 架构上实现一遍

所以将一部分不依赖环境的部分独立出来,先生成中间代码,这样即使调整了一些语法特性,只要在编译前端生成相同的中间代码,编译后端就不需要改动

代码优化

  • 死代码消除,函数内联,逃逸分析,闭包重写,循环不变量外提等
    • 死代码消除
      • 作用
        • 去除一些无用代码,以减少程序的体积大小
        • 避免程序在执行过程中进行一些不必要的运算行为
      • 包括:
        • 程序执行不到的代码
        • 不会影响函数执行结果的变量
      • 分析工具:
        • GOSSAFUNC=main go build main.go
    • 函数内联
      • 作用:
        • 用函数体替换函数调用来减少因函数调用而造成的额外上下文切换开销
      • 分析工具:
        • go build -gcflags="-m -m" main.go-m 越多,分析越详细
        • 通过注释 //go:noinline 告诉编译器不要内联(必须按照这个格式,不能多空格)
    • 逃逸分析
      • 作用:
        • 自动决定将变量分配到 goroutine 的栈内存或者堆内存上
      • 分析工具:
        • go build -gcflags="-m -m" main.go-m 越多,分析越详细
  • 变量捕获
    • 针对协程启动的闭包函数中使用闭包外的变量的情况

      go 复制代码
      func main() {
        x, y := 1, 2
        go func() {
          println(x, y) // 使用了外部变量 x, y
        }()
        x = 2 // x 被修改, y 没有修改
      }
      • 使用工具 go build -gcflags="-m -m -m -m" main.go 查看变量逃逸情况

        bash 复制代码
        ./main.go:4:2: main capturing by ref: x (addr=false assign=true width=8)
        ./main.go:4:2: x escapes to heap:
        ./main.go:4:2:   flow: {storage for func literal} = &x:
        ./main.go:4:2:     from x (captured by a closure) at ./main.go:6:11
        ./main.go:4:2:     from x (reference) at ./main.go:6:11
        ./main.go:4:5: main capturing by value: y (addr=false assign=false width=8)
        ./main.go:4:2: moved to heap: x
        ./main.go:5:5: func literal escapes to heap
        • x 不是地址类型,因为 addr=false,但后面有一个 assign=true,就会以地址的形式进行传参 flow: {storage for func literal} = &x:
        • y 没有在闭包外进行重新赋值,所以它的 assign=false
        • 所以最终输出时,取的 x 的是 &x 的值
  • SSA 生成,静态单赋值(Static Single Assignment)
    • IR 将被转换为静态单赋值形式
    • 可以更轻松地实现优化并最终生成机器码

机器码生成

  • 针对具体目标架构,通过 SSA 进行多轮转换来执行代码优化
  • 多轮转换后得到 genssa 再翻译成汇编代码
  • 调用汇编器将他们转换成机器码并生成最终的目标文件

内存

所有高级编程语言的内存管理机制都是建立在操作系统的内存之上的,编程语言的内存管理的目的是尽可能的发挥系统层面的优势,同时考虑开发人员编写代码过程中的便利性,屏蔽了内存的申请和释放这些操作的细节

  • 可以把内存看成一个数组,每个元素的大小为一个字节(也就是 8 位),内存地址可以看成是数组的下标
  • CPU 在执行指令时,通过内存地址将物理内存上的数据载入到寄存器执行机器指令
    • 对于频繁访问的指令会缓存到 CPU 的缓存中

寄存器

  • 寄存器是计算机中用来存储数据的一种硬件设备
  • 可以直接被中央处理器 CPU 访问
  • 通常是在 CPU 内部或与 CPU 紧密相连的芯片上,速度极快

CPU 缓存

  • 一种高速缓存,用于存储 CPU 频繁访问的数据和指令
  • 分为三级:
    • 一级缓存
      • 位于 CPU 内部
      • 容量很小,通常只有几十 KB
      • 用于存储 CPU 频繁访问的数据和指令
    • 二级缓存
      • 位于 CPU 内部,离一级缓存远一点
      • 容量比一级缓存要大,速度比一级缓存慢,通常几百 KB 到几 MB
      • 由静态随机存储器组成
      • 用于缓存一级缓存中没有命中的数据和指令,以减少数据读取过程中的延迟
    • 三级缓存
      • 位于 CPU 内部,离二级缓存远一点
      • 容量比二级缓存要大,速度比二级缓存慢,通常几 MB 到几十 MB
      • 用于缓存一级、二级缓存中没有命中的数据和指令

虚拟内存

直接使用物理内存会导致内存利用率低

假设只有物理内存,程序直接向物理内存申请空间,由于操作系统上会运行多个程序,而这些程序都有可能像操作系统申请内存

CPU 在运算的过程中,如果需要获取内存中的数据,就需要通过物理地址来获取,而内存地址是连续的,可以通过基准地址和偏移量来获取

而程序运行中需要的内存地址并不是固定的,这是为了确保物理内存能够被不同的程序使用,所以操作系统就需要分配这些程序他们所需要的最大内存

多个程序同时运行,如果都这样分配内存,那么内存的利用率就会很低

如果操作系统允许不同的程序使用同一块内存空间,那么就会出现操作内存时冲突的问题

虚拟内存就是为了解决这个问题,虚拟内存是程序和物理内存交互的桥梁

用户程序只能通过虚拟地址来获取内存数据,操作系统会将虚拟地址映射到实际的物理地址

从程序的角度看,是独享了一整块内存,不需要考虑内存冲突的问题了

虚拟内存本质上将磁盘当做最终的存储介质,物理内存则作为缓存

程序可以从虚拟内存中申请很大的内存空间,操作系统并不会立马从物理内存开辟这么大一块空间,而是先开辟一小块空间给程序使用,当程序使用更多空间时,操作系统会根据能否映射到物理地址上,当物理地址不够用时,操作系统会将虚拟地址映射到磁盘上,这样对用户程序而言它只需要跟虚拟内存打交道,而操作系统主动将数据在主存和磁盘之间进行交换

实现

  • 虚拟内存一般是通过页表来实现
    • 操作系统会将虚拟内存分成一页一页来管理
    • 通常每页大小为 4KB
  • 磁盘和主存之间的置换也是以页为单位来操作的
  • 虚拟地址到物理地址的映射关系由页表(Page Table)记录
    • 每个条目是 Page Tabe Entry,由有效位和 n 位地址构成,有效位表示这个物理地址能否分配内存
    • 页表被操作系统放在物理地址的指定位置
    • CPU 会把虚拟地址给 CPU 管理单元,CPU 管理单元会去物理内存中查询页表,得到实际的物理地址
    • 由于内存不是最快的,所以页表也会在 CPU 中进行缓存

我们在写代码时,很少会去利用这些地址,都是通过变量名来访问数据的,编译器会自动将这些变量名转成真正的虚拟地址

而操作系统已经将一整块内存给划分好了区域,每个区域可以用来做不同的事情

下面就是一个程序的内存布局

  • 代码区:为了避免堆栈溢出,放在了最下面,正常情况下,不允许进程对代码区的数据进行修改,也就是说代码区是只读的,但可以被多个进程共享
  • 数据区和未初始化数据区
    • 都是静态分配
    • 这个区域内的数据在编译阶段就确定了大小,并且不会被释放
  • 内核区
    • 用来存放操作系统的内核代码和数据
    • 内核空间:虚拟地址 0xC00000000xFFFFFFFF
    • 用户空间:虚拟地址 0x000000000xBFFFFFFF
    • 这个区域是操作系统独占的,用户程序不能访问
    • linux 内核由系统内的所有进程共享,每个进程有各自的私有用户空间 (0 ~ 3G)
    • 内核空间与用户空间隔离以确保内核程序的安全稳定

go 内存

go 内置运行时(runtime) 实现了自己的管理方式

go 运行时的内存分配算法主要源自于 TCMalloc 算法。它的核心算法是把内存分为多级管理,从而降低锁的粒度,将可用的内存采用多级管理,每个线程都会自行维护一个的内存池,进行内存分配时优先从自己的内存池中分配,如果自己的内存池不够用,再从全局内存池中分配,以避免不同线程之间的锁竞争

频繁申请很小的内存空间,容易出现大量的内存碎片。系统调用会导致程序进入内核态,内核需要查询页表完成虚拟内存地址到物理地址的映射

go 内存分配器是三级管理结构:

  • mcache
    • go 运行时会为每个逻辑处理 P 提供一个本地 span 缓存(mspan)
    • 协程需要内存可以从 mcache 中获取
    • mcache 包含所有大小规格的 mspan,但每种规格只包含一个
  • mcentral
    • mcentral 对象收集所有 sizeclassspan
    • sizeclass 相同的 span 会以链表的形式组织在一起
    • mcentral 是被所有逻辑处理 P 共享的
  • mheap
    • 所有级别的这些 mcentral,其实是一个数组,由 mheap 进行管理
    • 另外大对象也会直接通过 mheap 进行分配
      • span 的最大只能 32KB,对于超过 32KB 的大对象,会直接从 mheap 中分配
    • mheap 是管理内存的最核心单元

GMP 调度

在单进程时代,只能运行一个进程,通常一个程序就是一个进程,这样所有的程序通过串行的方式来执行

这种方式有两个问题:

  • 进程阻塞会带来严重的 CPU 时间浪费,导致程序运行效率低下
  • 执行流程单一,无法实现复杂的异步交互能力

多进程或者多线程的操作系统,就是解决这个问题,允许 CPU 在阻塞时切换到其他进程,而且 CPU 的调度算法确保所有进程都可以分配到 CPU 运行时间片

这个时间片在某一个时间内,还是只有一个进程在运行,但由于时间片很短,所以看起来就像多个进程同时运行

时间片:

  • 时间片时由操作系统内核的调度程序分配给每个进程
  • 内核会给每个进程分配相等的初始时间片
  • 每个进程轮番执行相应的时间,时间片耗尽时由内核重新计算分配

linux 系统的线程分为:用户态线程和内核态线程

  • 操纵系统层面的线程就是内核态线程
  • 在同一个内核线程上执行多个任务的线程都可以成为用户态线程
  • 用户态线程本质上是把内核态线程在用户态实现了一遍

用户态中的所有东西,内核态都可以看到,对内核而言,用户态只是一堆数据,一个用户态线程必须绑定一个内核态线程,但是 CPU 并不知道用户态的存在,它只知道它运行的是一个内核态线程

1:1 模型

假设一个内核态线程绑定一个协程

实现最为简单,但是无法避免协程的穿件、删除和切换的 CPU 开销

缺点:切换会带来 CPU 时间的损耗,这种和多进程或者多线程遇到的 CPU 开销的问题是一样的,都是没办反避免的

n:1 模型

假设一个内核线程绑定多个协程

可以在用户态实现协程之间的切换(协程在用户态由调度器分配给内核线程的)

不需要内核态去完成内核线程的切换带来 CPU 时间的损耗

问题:

  • 线程一旦阻塞会导致与之绑定的全部协程都无法执行
  • 单线程的绑定无法发挥多核 CPU 的优势

n:m 模型

假设有 n 个协程绑定到 m 个线程上,多个协程依然是通过一个调度器,调度给多个内核线程,这个调度实现很复杂,但可以解决上面的问题,调度器下面绑定的是多个 CPU 资源,可以利用多核 CPU 的优势

线程是由 CPU 调度的,是抢占式的,协程是由用户态调度的,它是协作式的,所以协程器的调度和优化,直接关系到程序的执行效率

goroutine 的本质是让一组可复用的函数运行在一组线程上

早期的 GM 调度模型:

  • 所有的协程 G 都会被放到一个全局的 go 协程队列中,这个队列是被多个 M 共享的
  • M 想要执行、方会队列的操作就需要加锁保证互斥或者同步

GM 调度模型的问题:

  • 创建、销毁、调度 G 都需要每个 M 获取锁,容易形成激烈的锁竞争
  • 调度实现的局部性差,而导致额外的资源消耗
    • M 线程创建了 G2,但此时 M 线程需要执行 G1,就有可能导致 G2 被调度给了其他线程,造成了局部性很差
  • MCPU 切换频繁,还有优化空间

局部性是计算机程序中内存系统性能优化的原则之一,良好的局部性可以显著的提升程序的执行效率和性能,减少缓存未命中的次数,降低数据访问的延时和数据传输的开销

局部性有两种:

  • 空间局部性(Spatial Locality

    • 程序在一段时间内访问的数据地址之间有较近的关联性,如果程序在执行的过程中访问的物理地址是相邻的,那它就有较好的空间局部性
    • 比如数组数据的连续访问,局部变量的频繁使用
  • 时间局部性(Timporal Locality

    • 程序在短时间内多次访问相同的地址
    • 比如在循环体内重复访问相同的变量,或者局部变量的反复使用
    • 局部性差会导致缓存未命中的概率增加,使得处理器不得不频繁的从主存中获取数据,增加了数据的开销和延迟,降低了程序的执行效率

P 是什么?

P 的数量默认是逻辑 CPU 的核心数,Pgo 协程的本地队列,存放的也是等待运行的 gouroutine,每个 P 最多存放 256 个等待运行的 gouroutine,分担 G 的操作,减少锁竞争

  • 多个本地队列 P 来分担 G 的操作,减少锁竞争,多核 CPU 的并行优势
  • 为了平很多个 P 队列的任务,实现了 Work Stealing 算法(也就是任务窃取),如果 P 的本地队列为空,就会从全局队列(或者从其他的 P 队列)中窃取,通常是偷一半的 goroutine 过来
  • 实现了 handoff 机制
    • 某个线程 M 因为 goroutine 调度阻塞时,线程就会顺势绑定 P,把这个 P 转给其他空旋的 M 执行
    • 当发生上下文切换时,需要对执行现场进行保护,以便下次被调度执行,对现场的恢复,go 调度器 M 的栈保存在 goroutine 上只需要将所需要的寄存器保存在 goroutine 上,就可以实现现场的保护

为什么要有 P

  • M 作为工作线程,M 最多能创建 1w 个,当然这些需要操作系统内核的支持,当 M 阻塞,又没有其他 M 可用时,M 的数量就会增加,增加到 1w 个程序就会崩溃,当然如果有不用的 M,程序也会进行回收
  • MP 的执行机制不同,从降低模块耦合度的角度看应该将 PM 解耦
    • M 被阻塞之后,我们希望把现有没有执行完的任务,分配给其他的线程继续运行,而不是一阻塞就全部停止
    • P 的加入就是将 G 调度到其他的空闲的 M 上执行

P 的数量由 GOMAXPROCS 设置,或者 runtime.GOMAXPROCS 设置,在 go 1.5 版本之后默认和逻辑 CPU 的核心数是一致

I/O 密集的时候设置 P 的数量大于 CPU 的核数是有好处的,因为涉及到 I/O 操作时,单纯的计算能力并不是瓶颈,而 I/O 操作导致的延迟,PG 的数量不会超过 256

M 工作线程在执行完 61 个本地 goroutine 之后,会去全局队列中获取 goroutine,避免了全局队列中的 goroutine 长时间得不到执行

GMP 调度模型的一些细节:

  • 极可能复用系统线程 M,避免频繁的线程创建和销毁
  • 利用多核并行能力,让同时处理的任务队列数量等于 CPU 的核数
  • 任务窃取机制,M 可以从其他 M 绑定的 P 的运行队列偷取 G 执行
  • Hand off 交接机制,M 阻塞时会将 M 上的 P 的运行队列交给其他 M 执行
  • 基于协作的抢占机制
  • GO 1.14 之后引入了基于信号的抢占式调度解决了 GC 和栈扫描时无法被抢占问题
相关推荐
Dear.爬虫3 分钟前
Golang中逃逸现象, 变量“何时栈?何时堆?”
开发语言·后端·golang
努力的小郑1 小时前
MySQL索引(三):字符串索引优化之前缀索引
后端·mysql·性能优化
IT_陈寒1 小时前
🔥3分钟掌握JavaScript性能优化:从V8引擎原理到5个实战提速技巧
前端·人工智能·后端
程序员清风2 小时前
贝壳一面:年轻代回收频率太高,如何定位?
java·后端·面试
考虑考虑2 小时前
Java实现字节转bcd编码
java·后端·java ee
AAA修煤气灶刘哥2 小时前
ES 聚合爽到飞起!从分桶到 Java 实操,再也不用翻烂文档
后端·elasticsearch·面试
爱读源码的大都督3 小时前
Java已死?别慌,看我如何用Java手写一个Qwen Code Agent,拯救Java
java·人工智能·后端
星辰大海的精灵3 小时前
SpringBoot与Quartz整合,实现订单自动取消功能
java·后端·算法
天天摸鱼的java工程师3 小时前
RestTemplate 如何优化连接池?—— 八年 Java 开发的踩坑与优化指南
java·后端
一乐小哥3 小时前
一口气同步10年豆瓣记录———豆瓣书影音同步 Notion分享 🚀
后端·python