发布于 

Go 底层原理与源码初探 👾

学习 Go 的底层原理

参考资料

重新认识 Go

  • C/C++
    • C 语言不是面向对象
    • 直接编译为机器码,不需要执行环境
    • 一次编码只能适用一种平台(不同平台源代码不一样)
    • 需要自己处理垃圾回收(GC)问题
  • Java
    • 编译为中间码(字节码)
    • 需要特定执行环境(JVM)
    • 一次编译多处执行
    • 有虚拟化损失
  • JavaScript
    • 不需要编译,直接解释执行
    • 需要执行环境(浏览器)
    • 有虚拟化损失
  • Go
    • 直接编译为二进制,没有虚拟化损失
    • 自带运行环境,无需处理 GC 问题
    • 一次编码可以适用多种平台(不同平台源代码一样)
    • 超强的并发支持能力与并发易用性
一次编码 一次编译 不需要运行环境 没有虚拟化损失 不需要自行处理GC 面向对象 非常易用的并发能力
C ✔️ ✔️ ✔️
C++ ✔️ ✔️ ✔️ ✔️
Java ✔️ ✔️ ✔️
JavaScript ✔️ ✔️ ✔️
Go ✔️ ✔️ ✔️ ✔️ ✔️
  • 什么是 Runtime

    • Runtime 就是程序的运行环境
    • Java:JVM
    • JavaScript:浏览器内核
  • Go 的 Runtime

    • Go 没有虚拟机的概念
    • Runtime 作为程序的一部分打包进二进制产物
    • Runtime 随用户程序一起运行
    • Runtime 与用户程序没有明显界限,直接通过函数调用
  • Go Runtime 的能力

    • 内存管理能力
    • 垃圾回收能力
    • 超强的并发能力(协程调度)
    • Runtime 有一定的屏蔽系统调用能力(跨平台)
    • 一些 go 的关键字其实是 Runtime 下的函数
      关键字 函数
      go newproc
      new newobject
      make makeslice, makechain, makemap …
      <- chansend1, chanrecv1
  • Go 程序是如何编译的?

    • go build -n 查看编译过程信息
    • 词法分析 → 句法分析 → 语义分析 → 中间码生成(平台无关的) → 代码优化 → 机器码生成→ 链接 → 可执行文件
    • 查看生成的中间码 SSA(平台无关)
      • GO SSA FUNC
      • $env:GOSSAFUNC="main"export GOSSAFUNC=main
      • go build
    • 查看生成的 Plan9 汇编代码(平台相关)
      • go build -gcflags -S main.go
    • 链接:将各个包(.a 文件)进行链接,包括 runtime,生成可执行文件
  • Go 程序是如何运行的?

    • Go 程序的入口不是 main 方法,是 runtime/rt0_xxx_xxx.s 汇编文件中的方法
      1. 读取命令行参数
      2. 初始化 g0 执行栈
      3. 运行时检测
      4. 参数初始化 runtime.args
      5. 调度器初始化 runtime.schedinit
      6. 创建主协程
      7. 主协程执行主函数 runtime.main
    • Go 启动时经历了检查、各种初始化、初始化协程调度的过程
    • main.main() 也是在协程中运行
    • Go 程序的启动过程像不像一个虚拟机,或者框架 ?
  • Go 是面向对象语言吗?Yes and No

    • Go 允许 OO 的编程风格
    • Go 的 struct 可以看作其他语言的 class
    • Go 缺乏其他语言的继承结构,所谓“继承”是组合
    • 组合中的匿名字段,通过语法糖达成了类似继承的效果
    • struct 的每个实例并不是“对象”,而是此类型的“值”
    • struct 并不显式实现接口,而是隐式实现
  • Go Modules

    • 本质上,一个 Go 包就是一个项目的源码
    • gomod 的作用就是将 Go 包和 Git 项目关联起来
    • Go 包的版本就是 git 项目的 Tag
    • gomod 就是解决“需要哪个 git 项目的什么版本”
    • 无法连接远程仓库时,使用重定向或者mod vender方案

数据结构

字符串

  • 基本类型的字节数

    • int 大小跟随系统字长
    • 指针的大小也是系统字长
  • 空结构体

    • 空结构体的地址均相同(不被包含在其他结构体中时)
    • 空结构体主要是为了节约内存
      • map 不要值的时候,实现 hashset
      • ChanneI 不需要携带信息的时候,纯信号
  • 字符串

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // src/runtime/string.go
    type stringStruct struct {
    str unsafe.Pointer
    len int
    }

    // src/reflect/value.go
    type StringHeader struct {
    Data uintptr
    Len int
    }
    • 字符串本质是个结构体

    • Data 指针指向底层 Byte 数组

    • Len 表示 Byte 数组的长度而不是字符个数(Unicode 编码

    • Unicode

      • 是一种统一的字符集
      • 囊括了159种文字的144679个字符
      • 14万个字符至少需要3个字节表示
      • 英文字母均排在前128个
    • UTF-8

      • Unicode 的一种变长格式
      • 128个 US-ASCII 字符只需一个字节编码
      • 西方常用字符需要两个字节
      • 其他字符需要3个字节,极少需要4个字节
    • 字符串的访问

      • 对字符串使用 len 方法得到的是字节数不是字符数
      • 对字符串直接使用下标访问,得到的是字节
      • 字符串被 range 遍历时,被解码成 rune 类型的字符
      • UTF-8 编码解码算法位于 runtime/utf8.go
    • 字符串的切分

        1. 转为rune数组
        2. 切片
        3. 转为 string
      • s = string([]rune(s)[:3])
    • 字符串与切片都是对底层数组的引用

切片

  • 切片

    1
    2
    3
    4
    5
    6
    // runtime/slice.go
    type slice struct {
    array unsafe.Pointer
    len int
    cap int
    }
    • 切片是对数组的引用

    • 切片的创建

      • 根据数组创建:arr[0:3] or slice[0:3]
      • 字面量,编译时插入创建数组的代码:slice := []int{1, 2, 3}
      • make,运行时创建数组:slice := make([]int, 10)
    • 切片的访问

      • 下标直接访问元素
      • range 遍历元素
      • len(slice) 查看切片长度
    • 切片的追加

      • 不扩容时,只调整 len(编译器负责)
      • 扩容时,编译时转为调用 runtime.growslice() (开一个新数组)
      • 如果期望容量大于当前容量的两倍就会使用期望容量
      • 如果当前切片的长度小于 1024,将容量翻倍
      • 如果当前切片的长度大于1024,每次增加 25%
      • 切片扩容时,并发不安全,注意切片并发要加锁
  • 总结

    • 字符串与切片都是对底层数组的引用
    • 字符串有 UTF-8 变长编码的特点
    • 切片的容量和长度不同
    • 切片追加时可能需要重建底层数组

map

  • HashMap 的基本方案

    • 开放寻址法(碰撞横向移动)
    • 拉链法(碰撞纵向移动)
  • Go 的 map

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // runtime/map.go
    // A header for a Go map.
    type hmap struct {
    // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
    // Make sure this stays in sync with the compiler's definition.
    count int // # live cells == size of map. Must be first (used by len() builtin)
    flags uint8
    B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
    noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
    hash0 uint32 // hash seed

    buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)

    extra *mapextra // optional fields
    }
    • map 的初始化

      • m := make(map[string]int, 10)
      • 字面量方式,实际是先 make 再赋值
    • Go 语言使用拉链实现了 hashmap

    • 每一个桶中存储键哈希的前8位

    • 桶超出8个数据,就会存储到溢出桶中

  • map 为什么需要扩容?

    • 哈希碰撞,溢出桶太多时会导致严重的性能下降

    • runtime.mapassign() 可能会触发扩容的情况:

      • 装载因子超过6.5(平均每个槽6.5个key)
      • 使用了太多溢出桶(溢出桶超过了普通桶)
    • mao 扩容的类型

      • 等量扩容:数据不多但是溢出桶太多了(就是整理)
      • 翻倍扩容:数据太多了
    • map 扩容

    • 步骤 Ⅰ

        1. 创建一组新桶
        2. oldbuckets 指向原有的桶数组
        3. buckets 指向新的桶数组
        4. map标记为扩容状态
    • 步骤 Ⅱ

        1. 将所有的数据从旧桶驱逐到新桶
        2. 采用渐进式驱逐
        3. 每次操作一个旧桶时,将旧桶数据驱逐到新桶
        4. 读取时不进行驱逐,只判断读取新桶还是旧桶
    • 步骤 Ⅲ

      • 所有的旧桶驱逐完成后,oldbuckets 回收
    • 总结

      • 装载系数或者溢出桶的增加,会触发 map 扩容
      • “扩容”可能并不是增加桶数,而是整理
      • map 扩容采用渐进式,桶被操作时才会重新分配
  • 怎么解决 map 的并发问题?

    • map 的并发问题
      • map的读写有并发问题
      • A 协程在桶中读数据时,B 协程驱逐了这个桶
      • A 协程会读到错误的数据或者找不到数据
    • map 并发问题解决方案
      • 给 map 加锁(mutex)
      • 使用 sync.Map
        • “读写” 与 “追加” 分离,避免扩容时候的并发问题
        • sync.Map 使用了两个map,分离了扩容问题
        • 不加锁:不会引发扩容的操作(查、改)使用 read map
        • 加锁:可能引发扩容的操作(新增)使用 dirty map
  • 接口:隐式更好还是显式更好?

    • Go 隐式接口特点
      • 只要实现了接口的全部方法,就是自动实现接口
      • 可以在不修改代码的情况下抽象出新的接口
    • 接口值的底层表示
      • 接口数据使用 runtime.iface 结构体表示
      • iface 记录了数据的地址
      • iface 也记录了接口类型信息和实现的方法
    • 类型断言(类型转换)
      • 类型断言是一个使用在接口值上的操作
      • 可以将接口值转换为其他类型值 (实现或者兼容接口)
      • 可以配合 switch 进行类型判断
    • 结构体和指针实现接口
      • 结构体实现接口 —— 结构体初始化变量/结构体指针初始化变量
      • 结构体指针实现接口 —— 只能结构体指针初始化变量
    • 空接口值
      • 空接口底层不是普通接口,是 runtime.eface 结构体
      • 空接口值可以承载任何数据
    • 空接口的用途
      • 空接口的最大用途是作为任意类型的函数入参
      • 函数调用时,会新生成一个空接口,再传参
  • nil,空接口,空结构体有什么区别?

    • nil
      • nil 是空,并不一定是“空指针”
      • builtin/builtin.go
      • Type must be a pointer, channel, func, interface, map, or slice type
      • nil 是6种类型的“零值”
      • 每种类型的 nil 是不同的,无法比较
    • 空结构体
      • 空结构体是 Go 中非常特殊的类型
      • 空结构体的值不是 nil
      • 空结构体的指针也不是 nil,但是都相同(zerobase)
    • 空接口
      • 它是 runtime.eface 结构体
      • 空接口不一定是 nil 接口
      • 两个属性都 nil 才是 nil 接口
  • 内存对齐

    • 非内存对齐:内存的原子性与效率受到影响

    • 内存对齐:提高内存操作效率,有利于内存原子性

    • 对齐系数

      • unsafet.Alignof()
      • 对产系数的含义是:变量的内存地址必须被对齐系数整除
      • 如果对齐系数为4,表示变量内存地址必须是4的倍数
    • 结构体对齐

      • 结构体对齐分为内部对齐外部填充对齐(结构体长度填充)

      • 内部对齐

        • 指的是结构体内部成员的相对位置(偏移量)
        • 每个成员的偏移量是自身大小与其对齐系数较小值的倍数
        • 结构体的内存地址顺序是严格按照内部成员的定义顺序
      • 结构体长度填充

        • 指的是结构体通过增加长度,对齐系统字长
        • 结构体长度是最大成员长度与系统字长较小的整数倍
      • 可以尝试通过调整成员顺序,节约空间

      • 结构体对齐系数

        • 为什么string的大小是16,对齐系数是8?
        • string 底层是一个结构体,包含两个成员(指针的长度是8,int的长度在64位机器也是8,那么其长度就是16)
        • 结构体的对齐系数是其成员的最大对齐系数
      • 空结构体的对齐

        • 空结构体单独出现时,地址为 zerobase
        • 空结构体出现在结构体中时,地址跟随前一个变量
        • 空结构体出现在结构体末尾时,需要补齐字长

Goroutine 协程

  • 为什么要有协程,线程不好用吗?

    • 进程
      • 进程用来占用内存空间
      • 进程相当于厂房,占用工厂空间
    • 线程
      • 每个进程可以有多个线程
      • 线程使用系统分配给进程的内存,线程之间共享内存
      • 线程用来占用CPU时间
      • 线程的调度需要由系统进行
      • 线程相当于厂房中的生产线,占用工人的工时
      • 线程本身占用资源大,线程的操作开销大,切换开销大(CPU)
    • 协程
      • 协程就是将一段程序的运行状态打包,可以在线程之间调度
      • 将生产流程打包,使得流程不固定在生产线上
      • 协程并不取代线程,协程也要在线程上运行
      • 协程使用线程的资源,精细利用线程
      • 资源利用,快速调度,超高并发
  • 协程的本质是什么?

    • runtime 中,协程的本质是一个 g 结构体
      • stack:堆栈地址
      • gobuf:目前程序运行现场
      • atomicstatus:协程状态
    • runtime 中将操作系统线程抽象为 m 结构体
      • g0:g0 协程,操作调度器
      • curg:current g,目前线程运行的 g
      • mOS:操作系统线程信息
  • 协程如何在线程上运行

    • 单线程循环(Go 0.x)
    • 多线程循环(Go 1.0)
    • schedule() → execute() 从 runable queue 中取协程 → gogo() → 业务方法 → goexit() → schedule()
    • 操作系统并不知道 Goroutine 的存在
    • 操作系统线程执行一个调度循环,顺序执行
    • Goroutine调度循环非常像线程池
    • 问题一:协程顺序执行,无法并发
    • 问题二:多线程并发时,会抢夺协程队列的全局锁
  • G-M-P 调度模型

    • 协程 g 结构体,线程 m 结构体,“送料器”(M 与 G 的中介) p 结构体

    • P 持有一些 G(本地队列),使得每次获取 G 的时候不用从全局找

    • 大大减少了并发冲突的情况(解决问题二

    • 窃取式工作分配机制

      • 如果在本地或者全局队列中都找不到 G
      • 去别的 P 中“偷”
      • 增强了线程的用率
    • 新建协程 func newproc()

      • 随机寻找一个 P
      • 将新协程放入 P 的 runnext(插队)
      • 若 P 本地队列满,放入全局队列
  • 解决协程饥饿问题(问题一)

    • 如果协程顺序执行,会有饥饿问题

    • 基于系统调用和主动挂起

      • 协程执行中间,将协程挂起,执行其他协程
      • 切换时机
        • 主动挂起(runtime.gopark)
        • 系统调用完成时
    • 防止全局队列饥饿,本地队列随机抽取全局队列

    • 如果永远都不主动挂起,永远都不系统调用怎么办?

      • 基于协作的抢占式调度(需要协程有方法调用)

        • 业务主动调用 runtime.morestack()

          • morestack 的本意是检查协程栈是否有足够空间
          • 调用方法时,会被编译器插入morestack()
        • 标记抢占

          • 统监控到 Goroutine 运行超过 10ms
          • 将 g.stackguard0 置为 0xfffffade
        • 抢占

          • 执行 morestack() 时判断是否被抢占
          • 如果被抢占,回到 schedule()
      • 基于信号的抢占式调度

        • 线程信号

          • 操作系统中,有很多基于信号的底层通信方式
          • 比如 SIGPIPE/SIGURG/SIGHUP
          • 线程可以注册对应信号的处理函数
        • 注册 SIGURG 信号的处理函数

        • 强制线程调用 doSigPreempt()

        • GC 工作时,向目标线程发送信号

        • 线程收到信号,触发调度

  • 协程太多会出现什么问题?

    • 资源限制

      • 文件打开数限制

      • 内存限制

      • 调度开销过大

    • 处理协程太多的方案

      • 优化业务逻辑

      • 利用 ChanneI 的缓存区

        • 利用 ChanneI 的缓存机制
        • 启动协程前,向 ChanneI 送入一个空结构体
        • 协程结束,取出一个空结构体
      • 协程池(tunny)

        • 预创建一定数量的协程
        • 将任务送入协程池队列
        • 协程池不断取出可用协程,执行任务
        • 慎用协程池
          • Go 语言的线程,已经相当于池化了
          • 二级池化会增加系统复杂度
          • Go语言的初衷是希望协程即用即毁,不要池化
      • 调整系统资源

锁 🔒

  • atomic 操作(sync/atomic)

    • 原子操作是一种硬件层面加锁的机制
    • 保证操作一个变量的时候,其他协程/线程无法访问
    • 只能用于简单变量的简单操作
  • sema 锁(不对用户开放使用)

    • 也叫信号量锁/信号锁(semaphore)

    • 核心是一个 uint32 值,含义是同时可并发的数量

    • 每一个 sema 锁都对应一个 SemaRoot 结构体

    • SemaRoot 中有一个平衡二叉树用于协程排队

    • sema 操作(uint32 > 0)

      • 获取锁:uint32 减一,获取成功
      • 释放锁:uint32 加一,释放成功
    • sema 操作(uint32 == 0)

      • 获取锁:协程休眠,进入堆树等待
      • 释放锁:从堆树中取出一个协程,唤醒
      • sema 锁退化成一个专用休眠队列
  • sync.Mutex 互斥锁

    • Go 的互斥锁
    • Go 中用于并发保护最常见方案
    • 正常模式 加锁
      • 尝试 CAS(compare and swap,借用了CPU提供的原子性指令现) 直接加锁
      • 若无法直接获取,进行多次自旋尝试
      • 多次尝试失败,进入 sema 队列休眠
    • 正常模式 解锁
      • 尝试 CAS 直接解锁
      • 若发现有协程在 sema 中休眠,唤醒一个协程
    • mutex 正常模式下,可能有锁饥饿问题(某个协程长时间获取不到锁,无法执行其业务)
    • Mutex 饥饿模式
      • 当前协程等待锁的时间超过了 10ms,切换到饥饿模式
      • 饥饿模式中,不自旋,新来的协程直接 sema 休眠
      • 饥饿模式中,被唤醒的协程直获取锁(不需要和别的协程竞争)
      • 没有协程在队列中继续等待时,回到正常模式
    • 互斥锁使用经验
      • 减少锁的使用时间
      • 善用 defer 确保锁的释放
  • 多个协程同时只读

  • 只读时,让其他人不能修改即可

  • 只读时,多协程可以共享读

  • 只读时,不需要互斥锁

  • 读写锁需求(读锁是共享的,写锁是独占的)

    • 每个锁分为读锁和写锁,写锁互斥
    • 没有加写锁时,多个协程都可以加读锁
    • 加了写锁时,无法加读锁,读协程排队等待
    • 加了读锁,写锁排队等待
  • sync.RWMutex 读写锁

    • w:互斥锁作为获取加写锁的资格

    • writerSem:作为写协程队列

    • readerSem:作为读协程队列

    • readerCount:正值表示正在读或想读的协程数(读锁),负值表示加了写锁

    • readerWait:写锁应该等待读协程的个数

    • 加写锁

      • 先加 mutex 锁,若已经被加写锁会阻塞等待
      • 将 readerCount 变为负值,阻塞读锁的获取
      • 计算需要等待多少个读协程释放
      • 如果需要等待读协程释放,陷入 writerSem
    • 解写锁

      • 将readerCount变为正值,允许读锁的获取
      • 释放在 readerSem 中等待的读协程
      • 解锁 mutex
    • 加读锁

      • readerCount + 1
      • 若 readerCount > 0 加锁成功
      • 若 readerCount < 0 说明被加了写锁,陷入 readerSem
    • 解读锁

      • readerCount - 1
      • 若 readerCount > 0 解锁成功
      • 若 readerCount < 0 ,有写锁在排队,如果自己是 readerwait 的最后一个,唤醒写协程
    • 使用经验

      • RW 锁适合读多写少的场景,减少锁冲突
  • 如何通过 WaitGroup 互相等待?

    • 实际业务中,一个(组)协程需要等待另一组协程完成

    • Wait()

      • 如果被等待的协程没了,直接返回
      • 否则,waiter 加一,陷入 sema
    • Done()

      • 被等待协程做完,给 counter 减一
      • 通过 Add(-1) 实现
      • Add()
        • 被等待协程没做完,或者没人在等待,返回
        • 被等待协程都做完,且有人在等待,唤醒所有 sema 中的协程
    • WaitGroup 实现了一组协程等待另一组协程

    • 等待的协程陷入 sema 并记录个数

    • 被等待的协程计数归零时,唤醒所有 sema 中的协程

  • 一段代码只能执行一次,怎么实现?

    • 思路1:Atomic

      • 做法:CAS改值,成功就做
      • 优点:算法非常简单
      • 问题:多个协程竞争 CAS 改值会造成性能问题
    • 思路2:Mutex

      • 争抢一个 mutex,抢不到的陷入 sema 休眠
      • 抢到的执行代码,改值,释放锁
      • 其他协程唤醒后判断值已经修改,直接返回
    • sync.Once(采用思路2)

      • 先判断是否已经改值
      • 没改,尝试获取锁
      • 获取到锁的协程执行业务,改值,解锁
      • 冲突协程唤醒后直接返回
  • 如何排查锁异常问题

    • 锁拷贝问题

      • 锁拷贝可能导致锁的死锁问题
      • 使用 vet 工具可以检测锁拷贝问题
      • vet 还能检测可能的 bug 或者可疑的构造
      • go vet main.go
    • RACE 竞争检测

      • 多个协程同时对一个变量进行修改,可能会导致修改操作的丢失
      • 发现隐含的数据竞争问题
      • 可能是加锁的建议
      • 可能是 bug 的提醒
      • go build -race main.go
      • ./main
    • go-deadlock 检测

      • go-deadlock 第三方包
      • 检测可能的死锁
      • 实际是检测获取锁的等待时间
      • 用来排查 bug 和性能问题

ChanneI 管道

  • ChanneI 声明方法

    • make(chan int) //无缓冲
    • make(chan bool, 0) //无缓冲
    • make(chan string, 2) //有缓冲
  • ChanneI 基本用法

    • ch <- × //发送数据
    • × = <- ch //接收数据,赋给 ×
    • <- ch //接收数据,并丢弃
  • 内存与通信

    • “不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存”
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 通过共享内存的方式进行通信
    func watch(i *int) {
    for true {
    if *i == 1 {
    fmt.Printf("bye")
    break
    }
    }
    }

    func main() {
    i := 0
    go watch(&i)
    time.Sleep(time.Second)
    i = 1
    time.Sleep(time.Second)
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 通过通信的方式共享内存
    func watch(c chan int) {
    if <-c == 1 {
    fmt.Println("bye")
    }
    }

    func main() {
    c := make(chan int)
    go watch(c)
    time.Sleep(time.Second)
    c <- 1
    time.Sleep(time.Second)
    }
    • 为什么使用通信来共享内存
      • 避免协程竞争和数据冲突的问题
      • 更高级的抽象,降低开发难度,增加程序可读性
      • 模块之间更容易解耦,增强扩展性和可维护性
  • ChanneI 的设计

    • 环形缓存区可以大幅降低 GC 的开销

    • 发送/接收队列

    • 互斥锁

      • 互斥锁并不是排队发送/接收数据
      • 互斥锁保护 hchan 结构体(runtime/chan.go)本身
      • ChanneI 并不是无锁的
    • 状态值

    • ChanneI 是 go 的一等公民

  • 运用 ChanneI 是如何发送数据的

    • c <- 关键字是一个语法糖

    • 编译阶段,会把 c <- 转化为 runtime.chansend1()

    • chansend1() 会调用 chansend() 方法

    • ChanneI 发送的情形

      • 直接发送

        • 发送数据前,已经有协程 G 在休眠等待接收
        • 此时缓存肯定是空的,不用考虑缓存
        • 将数据直接拷贝给等待接收的 G,唤醒 G
      • 放入缓存

        • 没有 G 在休眠等待,但是有缓存空间
        • 将数据放入缓存(获取可存入的缓存地址,存入数据,维护索引)
      • 休眠等待

        • 没有 G 在休眠等待,而且没有缓存或满了
        • 自己进入发送队列,休眠等待
        • 把自己包装成 sudog, sudog 放入 sendq 队列
        • 休眠并解锁
        • 被唤醒后,数据已经被取走,维护其他数据
  • 运用 ChanneI 是如何接收数据的

    • <- c 关键字也是一个语法糖
    • 编译阶段, i <- c转化为 runtime.chanrecv1()
    • 编译阶段,i, ok <- c 转化为 runtime.chanrecv2()
    • 最后会调用 chanrecv() 方法
    • ChanneI 接收的情形
      • 有等待的 G,从 G 接收

        • 接收数据前,已经有 G 在休眠等待发送
        • 而且这个 ChanneI 没有缓存
        • 将数据直接从 G 拷贝过来,唤醒 G
      • 有等待的 G,从缓存接收

        • 接收数据前,已经有 G 在休眠等待发送
        • 而且这个 ChanneI 有缓存
        • 从缓存取走一个数据将休眠G的数据放进缓存,唤醒 G
        • 将休眠 G 的数据放进缓存,唤醒 G
      • 接收缓存

        • 没有 G 在休眠等待发送,但是缓存有内容
        • 直接从缓存取走数据
      • 阻塞接收

        • 没有 G 在休眠等待,而且没有缓存或缓存空
        • 自己进入接收队列,休眠等待
  • 非阻塞的 Channel 怎么做?

    • select

    • select 原理

      • 同时存在接收、发送、默认路径
      • 首先查看是否有可以即时执行的 case
      • 没有的话,有 default,走 default
      • 没有default,把自己注册在所有的 channel 中,休眠等待
    • timer (time.NewTimer)

TCP 网络编程

  • Socket
    • 系统提供了 Socket 作为 TCP 网络连接的抽象
    • 如:Linux -> lnternet domain socket -> SOCK STREAM
    • Linux 中 Socket 以“文件描述符” FD 作为标识
  • IO 模型
    • IO 模型指的是同时操作 Socket 的方案
    • 阻塞 IO(一个连接对应一个线程)
      • 同步读写 Socket 时,线程陷入内核态
      • 当读写成功后,切换回用户态,继续执行
      • 优点:开发难度小,代码简单
      • 缺点:内核态切换开销大,性能差
    • 非阻塞 IO(一个线程轮询)
      • 如果暂时无法收发数据,会返回错误
      • 应用会不断轮询,直到 Socket 可以读写
      • 优点:不会陷入内核态,自由度高
      • 缺点:需要自旋轮询
    • 多路复用(事件池)
      • 注册多个 Socket 事件
      • 调用 epoll,当有事件发生,返回
      • 优点:提供了事件列表,不需要查询各个 Scoket,性能好
      • 缺点:开发难度大,逻辑复杂
      • Linux: epoll,Mac: kqueue,Windows: IOCP
  • Go 是如何抽象 Epoll 的?
    • 在底层使用操作系统的多路复用 IO
    • 在协程层次使用阻塞模型,阻塞协程时,休眠协程
    • 多路复用抽象层
      • 为了统一各个操作系统对多路复用器的实现
      • Linux: epoll,Mac: kqueue,Windows: IOCP
      • 多路复用器
        • 新建多路复用器 epoll_create()
        • 往多路复用器里插入需要监听的事件 epoll_ctl()
        • 查询发生了什么事件 epoll_wait()
      • netpoll.go netpoll_epoll.go netpoll_kqueue.go netpoll_windows.go …
      • Go Network PoIIer 是对不同操作系统多路复用器的抽象
        • 将 epoll_create() 抽象为 netpollinit()
        • 将 epoll_ctl() 抽象为 netpollopen()
        • 将 epoll_wait() 抽象为 netpoll()
          • 返回的不是事件,而是返回等待事件的协程列表
  • Go Network PoIIer 是如何工作的?
    • NetworkPoIIer 初始化
      • poll_runtime_pollServerlnit()
      • 使用原子操作保证只初始化一次
      • 调用 netpollinit()
    • pollcache 与 pollDesc
      • pollcache:一个带锁的链表头
      • pollDesc:链表的成员
      • poIlDesc 是 runtime 包对 Socket 的详细描述
      • 其中的 rg,wg:1,或 2,或等待的协程 G 地址
    • Network PoIIer 新增监听 Socket
      • poll_runtime_pollOpen()
      • 在 pollcache 链表中分配一个 pollDesc
      • 初始化 pollDesc(rg wg为0)
      • 调用 netpollopen()(屏蔽了不同 OS 的底层调用)
    • NetworkPoIIer 收发数据
      • 收发数据分为两个场景
        • 协程需要收发数据时,Socket 已经可读写
          • runtime 循环调用 netpoll() 方法(g0协程)
          • 发现 Socket 可读写时,给对应的 rg 或者 wg 置为 pdReady(1)
          • 协程调用 poll_runtimepollWait()
          • 判断 rg 或者 wg 已经置为 pdReady(1),返回 0
        • 协程需要收发数据时,Socket 暂时无法读写
          • runtime 循环调用 netpoll() 方法(g0协程)
          • 协程调用 poll_runtimepollWait()
          • 发现对应的 rg 或者 wg 为 0
          • 给对应的 rg 或者 wg 置为协程地址
          • 休眠等待
          • runtime 循环调用 netpoll() 方法(g0协程)
          • 发现 Socket 可读写时,查看对应的 rg 或者 wg
          • 若为协程地址,返回协程地址
          • 调度器开始调度对应协程
    • NetworkPoIIer 是 Runtime 的强大工具
    • 抽象了多路复用器的操作
    • NetworkPoller 可以自动监测多个 Socket 状态
    • 在 Socket 状态可用时,快速返回成功
    • 在 Socket 状态不可用时,休眠等待
  • Go是如何抽象Socket的?
    • net 包

      • net 包是 go 原生的网络包
      • net 包实现了 TCP、UDP、HTTP 等网络操作
    • net.Listen()

      • 新建 Socket 并执行 bind 操作
      • 新建一个 FD(net包对Socket的详情描述)
      • 返回一个 TCPListener 对象
      • 将 TCPListener 的 FD 信息加入监听
      • TCPListener 对象本质上是一个 LISTEN 状态的 Socket
    • TCPListener.Accept()

      • 直接调用 Socket 的 accept()
      • 如果失败,休眠等待新的连接
      • 将新的 Socket 包装为 TCPConn 变量返回
      • 将 TCPConn 的 FD 信息加入监听
      • TCPConn 本质上是一个 ESTABLISHED 状态的 Socket
    • TCPConn.Read() / Write()

      • 直接调用 Socket 原生读写方法
      • 如果失败,休眠等待可读/可写
      • 被唤醒后调用系统 Socket
    • net 包抽象了 TCP 网络操作

    • 使用 net.Listen() 得到 TCPListener(LISTEN状态的Socket)

    • 使用 TCPListener.Accept() 得到TCPConn(ESTABLISHED)

    • TCPConn.Read() / Write() 进行读写 Socket 的操作

    • NetworkPoller 作为上述功能的底层支撑

  • goroutine-per-connection 编程风格
    • 用主协程监听 Listener
    • 每个 Conn 使用一个新协程处理
    • 结合了多路复用的性能和阻塞模型的简洁

内存模型与 GC

  • Go 协程栈的作用
    • 记录协程的执行路径
    • 记录局部变量
    • 记录函数传参
      • Go 使用参数拷贝传递(值传递)
      • 传递结构体时会拷贝结构体中的全部内容
      • 传递结构体指针时会拷贝结构体指针
    • 记录函数返回值
  • Go 协程栈的位置
    • Go 协程栈位于 Go 堆内存上(从堆上申请的)
    • Go 堆内存位于操作系统虚拟内存上
  • 逃逸分析(从栈跑到堆)
    • 不是所有的变量都能放在协程栈上,如:
      • 栈帧回收后,需要继续使用的变量
      • 太大的变量
    • 触发情形
      • 指针逃逸(函数返回了对象的指针)
      • 空接口逃逸
        • 如果函数参数为 interface{}
        • 函数的实参很可能会逃逸
        • 因为 interface{} 类型的函数往往会使用反射(需要对象在栈上)
      • 大变量逃逸
        • 过大的变量会导致栈空间不足
        • 64位机器中,一般超过 64KB 的变量会逃逸
  • 栈扩容(汇编实现的)— 解决栈帧过多的问题
    • Go 栈的初始空间为 2KB
    • 在函数调用前判断栈空间是否足够(morestack)
    • 必要时对栈进行扩容
      • 早期使用分段栈
        • 1.13 之前使用
        • 优点:没有空间浪费
        • 缺点:栈指针会在不连续的空间跳转
      • 后期使用连续栈
        • 优点:同一个协程的栈空间一直连续
        • 缺点:伸缩时的开销大
        • 当空间不足时扩容,变为原来的 2 倍
        • 当空间使用率不足 1/4 时缩容,变为原来的 1/2
  • Go 的堆内存结构
    • 操作系统虚拟内存
      • 指的是操作系统给应用提供的虚拟内存空间
      • 背后是物理内存,也有可能有磁盘
      • Linux 获取虚拟内存:mmap、madvice 系统调用
    • 内存单元 heapArena
      • Go 每次申请的虚拟内存单元 heapArena 为 64MB
      • 最多有 4,194,304 个虚拟内存单元($2^{20}$,256 TB)
      • 所有的 heapArena 组成了 mheap(Go 堆内存)
    • heapArena 使用方案
      • 线性分配或者链表分配容易出现空间碎片
      • 分级分配
        • 内存管理单元 mspan
        • runtime/sizeclasses.go, mheap.go
        • 根据隔离适应策略,使用内存时的最小单位为 mspan
        • 每个 mspan 为 N 个相同大小的 “格子”
        • Go 中一共有 67 种 mspan
    • 中心索引 mcentral
      • runtime/mcentral.go

      • 136 个 mcentral 结构体

      • 其中 68 个组需要 GC 扫描的 mspan

      • 68 个组不需要 GC 扫描的 mspan

    • mcentral 的性能问题
      • mcentral 实际是中心索引,使用互斥锁保护
      • 在高并发场景下,锁冲突问题严重
      • 参考协程 GMP 模型,增加线程本地缓存
      • 线程缓存 mcache(大大减少了 mcentral 高并发加锁的情况)
        • 每个 P 有一个mcache
        • 一个mcache,拥有136个mspan,其中
        • 68 个需要 GC 扫描的 mspan
        • 68 个不需要 GC 扫描的 mspan
  • Go 分配堆内存方案
    • 对象分级

      • Tiny 微对象 (0,16B) 无指针
      • SmalI 小对象 [16B, 32KB]
      • Large 大对象 (32KB, +∞)
    • 微对象分配

      • 从 mcache 拿到 2 级 mspan
      • 将多个微对象合并成一个 16Byte 存入
    • mcache 的替换

      • mcache 中,每个级别的 mspan 只有一个
      • 当 mpan 满了之后,会从 mcentral 中换一个新的
    • mcentral 的扩容

      • mcentral 中,只有有限数量的 mspan
      • 当 mspan 缺少时,会从 heapArena 开辟新的 mspan
    • 大对象分配

      • 直接从 heapArena 开辟 0 级的 mspan
      • 0 级的 mspan 为大对象定制
  • 垃圾回收(Garbage CoIIecting)
    • 思路
      • ”标记 — 清除“ ,会有碎片
      • “标记 — 整理” ,整理开销大
      • “标记 — 复制” ,空间消耗大
    • Go 因为堆内存结构的独特优势,选择最简单的 “标记 — 清除”
    • GC 的起点 GCRoot
      • 被栈上的指针引用
      • 被全局变量指针引用
      • 被寄存器中的指针引用
      • 上述变量被称为 RootSet
    • Root 节点进行广度优先搜索 BFS,可达性分析标记法
    • 找到有引用的对象,剩下的就是没有引用的,标记有用的
    • 串行 GC 步骤(Go 老版本)
      • Stop The WorId,暂停所有其他协程
      • 通过可达性分析,找到无用的堆内存
      • 释放堆内存,恢复所有其他协程
      • STW 对性能影响很大
    • 并发垃圾回收
      • 三色标记法 BFS

        • 黑色:有用,已经分析扫描
        • 灰色:有用,还未分析扫描
        • 白色:不可达,暂时无用(需要清除的对象)
      • 并发标记问题(删除)

        • Yuasa 删除屏障
          • 并发标记时,对指针释放的白色对象置灰
          • 删除屏障可以杜绝在 GC 标记中被释放的指针,被清理
      • 并发标记问题(插入)

        • Dijkstra 插入屏障
          • 并发标记时,对插入的白色对象置灰
          • 插入屏障可以杜绝在 GC 标记中被插入的指针,被清理
      • 混合屏障 (Go 的 GC)

        • 被删除的堆对象标记为灰色
        • 被添加的堆对象标记为灰色
        • 并发垃圾回收的关键在于标记安全
        • 混合屏障机制兼顾了安全与效率
    • GC 触发的时机
      • 系统定时触发

        • sysmon 定时检查
        • 如果 2 分钟内没有过 GC,触发
        • 谨慎调整
      • 用户显式触发

        • 用户调用 runtime.GC 方法
        • 并不推荐调用
      • 申请内存时触发

        • 给对象申请堆空间时,可能导致 GC
      • GC 优化原则 — 尽量少在堆上产生垃圾

        • 内存池化
          • 缓存性质的对象
          • 频繁创建和删除
          • 使用内存池,不 GC
        • 减少逃逸
          • 逃逸会使原本在栈上的对象进入堆中
          • fmt 包慎用,多用 log 的组件
          • 方法返回了指针而不是拷贝,可能会发生逃逸
        • 使用空结构体
          • 空结构体指向一个固定地址
          • 不占用堆空间
          • 比如 channel 传递空结构体
    • GC 分析工具
      • go tool pprof
      • go tool trace
      • go build -gcflags="-m"
      • 设置环境变量 GODEBUG = "gctrace = 1"go run main.go

其他高级特性

  • 如何实现 Go 调用 C 代码?

    • import "C"
    • go tool cgo main.go 查看调用流程
    • 原理
      • 在内存中开辟一个结构体
      • 结构体中含有参数和返回值
      • 结构体地址传入 C 方法
      • C 方法将结果写入返回值的位置
    • 调度器的配合
      • 协程需要抢占式调度
      • 进入 C 程序之后,调度器无法抢占协程
      • 调度器停止对此协程的调度
    • 协程栈的切换
      • C 的栈不受 Runtime 管理
      • 进入 C 时,需要将当前栈切换到线程的系统栈上
    • cgo 的优缺点
      • cgo 可以让 go 调用现成的 C 实现
      • cgo 限制了 go 语言的跨平台特性
      • cgo 并不能提高 Go 语言的性能
  • defer 的底层原理是怎样的?

    • go build -gcflags -S main.go 通过其汇编来查看
    • 思路1:协程记录 defer 信息,函数退出时调用
      • 实现1:堆上分配
        • 1.12 之前使用
        • 在堆上开辟一个 sched.deferpool
        • 遇到 defer 语句,将信息放入 deferpool
        • 函数返回时,从 deferpool 取出执行
      • 实现2:栈上分配
        • 1.13 之后出现
        • 遇到 defer 语句,将信息放入栈上
        • 函数返回时,从栈中取出执行
        • 只能保存一个 defer 信息
    • 思路2:将 defer 代码直接编译进函数尾
      • 实现3:开放编码(性能最高,但是不好触发)
        • 1.14之后出现
        • 如果 defer 语句在编译时就可以固定
        • 直接改写用户代码,defer 语句放入函数末尾
  • recover 如何在 panic 中拯救程序?

    • panic
      • panic 会抛出错误
      • 终止协程运行
      • 带崩整个 Go 程序
    • panic + defer
      • panic 在退出协程之前会执行所有已注册的 defer
      • 不会执行其他协程的 defer
    • panic + defer + recover
      • 在 defer 中执行 recover,可以拯救 panic 的协程
      • 如果涉及 recover,defer 会使用堆上分配(deferpool)
      • 遇到 panic,panic 会从 deferpool 取出的 defer 语句,执行
      • defer 中调用 recover,可以终止 panic 的过程
  • Go 是怎么实现反射的?

    • 需求
      • 获取对象的类型
      • 对任意类型变量赋值
      • 调用任意方法
    • 元数据
      • 元数据就是“数据的数据”(数据的属性)
      • 把对象的类型表示成一个数据类型
      • 把对象的值表示成一个数据类型
    • 对象的类型
      • 接口 refIect.Type
      • 把对象的类型表示成一个接口
      • 就能对类型做各种操作
    • 对象的值
      • 结构体 reflect.Value
      • 把对象的值表示成一个结构体
      • 就能对值做各种操作
    • runtime.eface 是运行时对空接口的表示
    • reflect.emptylnterface 是 reflect 包对空接口表示
    • 对象到反射对象时,编译器会将入参提前转为 eface
    • 反射对象到对象时,根据类型和地址还原数据
  • 使用反射调用不同方法

    • 通过反射调用方法,可以将框架和用户方法解耦
    • 往往需要用户注册方法,框架调用
    • 很多框架的 HTTP 调用处理使用此思路