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
汇编文件中的方法 - 读取命令行参数
- 初始化 g0 执行栈
- 运行时检测
- 参数初始化 runtime.args
- 调度器初始化 runtime.schedinit
- 创建主协程
- 主协程执行主函数 runtime.main
- Go 启动时经历了检查、各种初始化、初始化协程调度的过程
- main.main() 也是在协程中运行
- Go 程序的启动过程像不像一个虚拟机,或者框架 ?
- Go 程序的入口不是 main 方法,是
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
字符串的切分
- 转为rune数组
- 切片
- 转为 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 扩容
步骤 Ⅰ
- 创建一组新桶
- oldbuckets 指向原有的桶数组
- buckets 指向新的桶数组
- map标记为扩容状态
步骤 Ⅱ
- 将所有的数据从旧桶驱逐到新桶
- 采用渐进式驱逐
- 每次操作一个旧桶时,将旧桶数据驱逐到新桶
- 读取时不进行驱逐,只判断读取新桶还是旧桶
步骤 Ⅲ
- 所有的旧桶驱逐完成后,oldbuckets 回收
总结
- 装载系数或者溢出桶的增加,会触发 map 扩容
- “扩容”可能并不是增加桶数,而是整理
- map 扩容采用渐进式,桶被操作时才会重新分配
怎么解决 map 的并发问题?
- map 的并发问题
- map的读写有并发问题
- A 协程在桶中读数据时,B 协程驱逐了这个桶
- A 协程会读到错误的数据或者找不到数据
- map 并发问题解决方案
- 给 map 加锁(mutex)
- 使用 sync.Map
- “读写” 与 “追加” 分离,避免扩容时候的并发问题
- sync.Map 使用了两个map,分离了扩容问题
- 不加锁:不会引发扩容的操作(查、改)使用 read map
- 加锁:可能引发扩容的操作(新增)使用 dirty map
- map 的并发问题
接口:隐式更好还是显式更好?
- Go 隐式接口特点
- 只要实现了接口的全部方法,就是自动实现接口
- 可以在不修改代码的情况下抽象出新的接口
- 接口值的底层表示
- 接口数据使用 runtime.iface 结构体表示
- iface 记录了数据的地址
- iface 也记录了接口类型信息和实现的方法
- 类型断言(类型转换)
- 类型断言是一个使用在接口值上的操作
- 可以将接口值转换为其他类型值 (实现或者兼容接口)
- 可以配合 switch 进行类型判断
- 结构体和指针实现接口
- 结构体实现接口 —— 结构体初始化变量/结构体指针初始化变量
- 结构体指针实现接口 —— 只能结构体指针初始化变量
- 空接口值
- 空接口底层不是普通接口,是 runtime.eface 结构体
- 空接口值可以承载任何数据
- 空接口的用途
- 空接口的最大用途是作为任意类型的函数入参
- 函数调用时,会新生成一个空接口,再传参
- Go 隐式接口特点
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 接口
- 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:操作系统线程信息
- runtime 中,协程的本质是一个 g 结构体
协程如何在线程上运行
- 单线程循环(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
- 若为协程地址,返回协程地址
- 调度器开始调度对应协程
- 协程需要收发数据时,Socket 已经可读写
- 收发数据分为两个场景
- NetworkPoIIer 是 Runtime 的强大工具
- 抽象了多路复用器的操作
- NetworkPoller 可以自动监测多个 Socket 状态
- 在 Socket 状态可用时,快速返回成功
- 在 Socket 状态不可用时,休眠等待
- NetworkPoIIer 初始化
- 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 标记中被释放的指针,被清理
- Yuasa 删除屏障
并发标记问题(插入)
- Dijkstra 插入屏障
- 并发标记时,对插入的白色对象置灰
- 插入屏障可以杜绝在 GC 标记中被插入的指针,被清理
- Dijkstra 插入屏障
混合屏障 (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 信息
- 实现1:堆上分配
- 思路2:将 defer 代码直接编译进函数尾
- 实现3:开放编码(性能最高,但是不好触发)
- 1.14之后出现
- 如果 defer 语句在编译时就可以固定
- 直接改写用户代码,defer 语句放入函数末尾
- 实现3:开放编码(性能最高,但是不好触发)
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 的过程
- panic
Go 是怎么实现反射的?
- 需求
- 获取对象的类型
- 对任意类型变量赋值
- 调用任意方法
- 元数据
- 元数据就是“数据的数据”(数据的属性)
- 把对象的类型表示成一个数据类型
- 把对象的值表示成一个数据类型
- 对象的类型
- 接口 refIect.Type
- 把对象的类型表示成一个接口
- 就能对类型做各种操作
- 对象的值
- 结构体 reflect.Value
- 把对象的值表示成一个结构体
- 就能对值做各种操作
- runtime.eface 是运行时对空接口的表示
- reflect.emptylnterface 是 reflect 包对空接口表示
- 对象到反射对象时,编译器会将入参提前转为 eface
- 反射对象到对象时,根据类型和地址还原数据
- 需求
使用反射调用不同方法
- 通过反射调用方法,可以将框架和用户方法解耦
- 往往需要用户注册方法,框架调用
- 很多框架的 HTTP 调用处理使用此思路