并发实现
并发的实现有两种模式:
- 多线程共享机制
- CSP(Communicating Sequential Process 通信顺序进程)
线程模型:
- 用户线程模型:1内核 - N用户线程
- 内核线程模型:1内核 - 1用户
- 二级线程模型:M内核 - N用户
Golang:
- 基于CSP:不要通过共享内存来通信,要通过通信来共享内存(goroutine & channel)
- 二级线程模型
优势劣势
- 优势
- 跨平台
- goroutine&channel
- 函数多返回值
- 社区强大
- 劣势
- 错误处理
- 范围界定不够强大(同一个包内)
- 没有枚举
MPG调度机制
M
内核线程
上限可以由runtime/debug.SetMaxThreads(num)来设置
运行动态按需创建
M的数量和P的数量没有关系,运行时如果没有足够的M来执行P,那么会创建M
P
调度器
数量由runtime.GOMAXPROCS(num)来设置、没有上限
一般和CPU核数保持一致,如果设置过多会导致一直在线程间切换
如果要在容器里设置P,要根据cgroup分配的cpu资源数来设置,此时cpunums无法准确被代码获取
在runtime启动时创建
G
协程(goroutine)
数量无限制
运行时动态创建
调度过程
- 创建G,加入到P的本地队列头部或者全局队列,然后P会唤醒M
- P按照它的顺序继续执行。被唤醒的M会找空闲的P,如果找得到,将G移向M(G只能在M上跑,G不一定是1步骤里创建的G,而是等待队列里最前面的G)
- M执行G
调度策略
- work stealing
当M没有可以运行的G时,可以从其它的P那里偷G来运行
- hand off
陷入系统调用时,M会直接连着G跑,P会被基于协作的抢占放开,让给其它可以跑的M使用
goroutine
创建执行
- runtime.newproc(其实就是调度过程里的步骤)
- wakep(找P):获取本地P,并尝试在P的gfree中找不用的G,未找到则创建G(2K),将func与G绑定,加入到P的本地队列,满了就放到全局队列
- startm(找M):如果没有可用的M并且M还未满则newm,否则直接找到M
- mstart(M找P):M找到可执行的P,执行schedule运行G
- M schedule
M的调度循环,找到G以及运行G
找到G:localq -> globalq -> netpoll(网络事件) -> work stealing -> 休眠
运行G:调用G对象 -> 运行 -> 清理线程 -> 继续找到G
调度
- 阻塞
runtime可拦截:channel、select、lock、time.sleep、网络io
runtime不可拦截:cgo、系统调用
- gopark(挂起)
当goroutine碰到runtime可拦截阻塞时,会触发gopark:
- 将running状态设置成waiting状态
- 解除goroutine与M的关系
- 触发一次M调度
- goready(恢复)
当阻塞恢复时,会通过goready唤醒阻塞的goroutine
- 将waiting状态设置成runable状态
- 将goroutine放入P的队列里等待运行
抢占式调度
在引入这个机制之前,如果goroutine执行的是无sleep的死循环,那么这个循环永远不会被调度
基于此引入该机制,由sysmon负责执行retake函数,主要做以下事情:
- 抢占进行系统调用的P(GO1.2引入基于协作的抢占)-> hand off机制
- 抢占运行时间过长的P(GO1.14引入基于信号的抢占)-> 给goroutine设置栈错误,运行中主动触发gopark
消亡
goroutine执行runtime.Goexit退出,回收资源,将其设置为free状态,放入P的gfree队列中等待复用
runtime
启动过程
- 初始化tls,直接把g0放入tls里(为newproc里面M0的创建做准备)
- runtime.check:运行检查
- runtime.args:设置启动参数和路径
- runtime.osinit:操作系统层面初始化
- runtime.schedinit:资源初始化(模块、符号、内存、GC、P)
- runtime.newproc:goroutine启动运行流程,在这里是把runtime.main放入G对象
- runtime.main:启动sysmon,运行main.main
sysmon
独立与所有调度M之外的单独线程
主要负责netpoll、抢占、gc、trace、归还heap给os
M0
程序启动后创建的第一个线程,负责启动第一个G,启动完后就跟其余的M是一样的
G0
每个M创建时都会创建一个G0
G0在M的栈上分配的,栈空间有64K
G0的作用就是在栈上不断的调度G,例如M上G1切换到G2,那么会首先从G1到G0,然后G0到G2
G0运行也依赖M
内存管理
整体分配
- arena:真正存放值的地方,一个page 8kb
分配思想
- 每次从内存申请一大块,然后按需分配
- 用完的内存被回收后,不会立马释放,而是收集起来等待复用,只有闲置太多时才会释放部分
- 内存分配算法采用tcmalloc算法
管理组件
- mspan
go内存管理的基本单元
有很多种类(67种),每种对应不同的大小
- mcache
每个P独有的,对应mspan内存块的缓存
分配时无锁分配
- mcentrel
特定类型的mpsan全局列表,用于管理mspan,数量与mspan一一对应
分配时有锁分配
- mheap
程序持有的堆空间,全局唯一
分配算法
- 微小对象(size class <=16B)
mcache的tiny分配器分配
- 小对象(16B< size class <=32K)
申请流向:mcache -> mcentral -> mheap -> os
mcache不足时找mcentral
mcentral不足时找mheap
mheap不足时找os,os分配后,mheap拆分成对应大小的span,分配给mcentral
- 大对象(size class >32K)
直接从mheap分配
注意:所有最终分配出去的,都是mspan
程序分配区域判定
编译器决定,当变量生命周期完整时在栈上,生命周期不完整时在堆上
不是所有局部对象都在栈上,当局部对象发生内存逃逸时,就会分配在堆上
- 内存逃逸
- 局部指针外部引用
- 栈空间不足:栈空间不足以分配所需对象
- 动态类型:把对象转为interface{}传入函数时,该对象会逃逸,例如fmt.Println(name),name会逃逸
- 分配大小未知的对象:make([]int, n),n为一个编译时期未知的变量
要尽量避免内存逃逸,因为栈的处理速度远大于堆
- 内存逃逸分析
go build -gcflags '-m -l' main.go
-m:是逃逸分析建议,可以用4个-m,但是一般没必要
-l:是禁止内联,防止干扰
更硬核的看法,反编译:go tool compile -S main.go
GC
常见机制
- 引用计数
当出现嵌套时,会出现循环引用的问题
解决循环引用:
- 可达性分析:引入gc root,追踪对象是否可以连接到gc root,如果不行则清除
- 弱引用机制:出现循环引用时用弱引用代码,弱引用不持有对象
- 标记清除
mark(标记):从gc root出发,遍历所有对象(图遍历算法)并设置标记
sweep(清除):把所有未设置标记的对象回收
make阶段会出现stw(stop the world)现象
- 分代回收
将对象分类,分为新生代(young)和老年代(old),新生代又分为伊甸区(eden)和幸存区(from、to)
内存在使用的过程中逐渐升级,慢慢往后挪
坚持新创建的对象更值得被回收的思想,从新生代开始回收,足够则不往后推移
- 复制收集
把堆空间分成两部分from和to,每次gc开始把from全部复制到to,然后清除from,再把to拷贝到from
缺点显而易见,堆空间减半
机制
一句话总结:三色标记法(属于mark&sweep),混合写屏障
- 三色标记法
三色标记法是在标记清除算法上的改进,目的是为了尽量少的stw,将长段的stw变为多个小段stw
mark prepare(stw):
- 初始所有对象都是白色
- 启动混合写屏障(记录mark状态下被修改的对象)
- 找到所有的roots(stack、heap、global)加入到标记队列,进入标记队列则变成灰色
mark:
- 并发goroutine扫描队列中的对象
- 对象被扫描则出队,出队就标记为黑色
- 然后继续查看出队的对象是否还有指向的对象,有则加入扫描队列,继续扫描
mark termination(stw)
- 把被混合写屏障拦截的对象加入到扫描队列
- 按照mark扫描动作针对拦截对象再次扫描(rescan)
sweep:
所有的节点非黑即白,黑色清除,白色留存
sweep termination:
对未清扫的span进行清扫
- 混合写屏障
未引入混合写屏障时,mart termination会重新扫描栈,这个是stw最大的来源
混合写屏障的作用,就是在mark termination阶段不用再次扫描roots,因为它会单独记录在mark阶段所有的修改动作,极大的缩短时间
时机
- 内存达到阈值(上次GC后的两倍)
- 达到定时时间(2m)
- 手动GC(runtime.GC())
基本结构
传递性
- 值传递:值传递是对值的一次拷贝
golang所有对象的传递都是值传递
当变量为值类型时,会产生新的副本,跟原变量不再有关联
当变量为引用类型时,也会产生新的副本,但此时新副本指向的数据和老副本指向的数据一致,因此实际上还是一个变量
可比较性
参考资料:
- slice、map、func不可比较
- array可以比较,但array的类型必须一致
- 相同结构体的实例能不能比较,主要看结构体内的成员是不是都可以比较,都能比较则能比较
- 相同结构体的指针一定能比较
- 不同结构体的实例和指针都不能比较
不可比较的类型是无法作为map的key
比较办法:
- bytes.Equal比较byte数组
- reflect.DeepEqual比较任意两个类型的变量
- 使用google的cmp包
rune/byte
- byte:等同于uint8,一个字节长度,用来处理ascii
- rune:等同于uint32,四个字节长度,用来处理unicode或者utf8
utf8是属于unicode编码的,除此之外还有utf16、utf32
string
utf8编码,常量字符串无法修改(只读)
当使用strings方法去操作string时,会生成string的额外拷贝
当直接赋值string时,并不会生成string的额外拷贝,因此string也是引用类型
数据结构:
注意事项:
- 只读string无法修改,修改会panic
- 只读string经过程序处理后,会变成可修改的string,此时可以修改
array
值类型
slice
在数组基础上额外增加一层包装,可扩展的数组
引用类型
非协程安全
数据结构:
扩容策略:
- cap <= 1024,加倍扩容
- cap > 1024,扩展因子变为1.25,每次增长四分之一
- 每次扩容之后,切片地址会发生变化,不再跟原有地址保持一致,变成一个新的数组
注意事项:
nums := []int{1}
nums[:0]、nums[1:]都是不会panic的
nums[2:]会panic
因为nums[1:] = nums[1:1],前面的数字是不能超过nums的长度的
map
参考资料:
底层是哈希表实现,使用链地址法解决冲突,依赖的核心数据结构是数组加链表
引用类型
非协程安全
数据结构:
关键字段解析:
写入过程:
- 通过key计算出一个hash值,低B位(比如B是5那么就是低5位)用于选择桶,高8位用于在桶中区别key
- 先扫描该桶以及对应的溢出桶,判断该key是否存在(高8位是否相同),如果存在直接覆盖,不存在继续往下走
- 按照顺序从前往后找第一个空位,有溢出桶则也要在溢出桶中查找
- 如果桶满了,并且没有溢出桶,那么就在overflow上挂载溢出桶往下写
扩容:
- 扩容触发
新增元素时检查以下条件,满足则触发扩容:
- key超过了装载因子B
- 多次增删操作,造成溢出桶过多
- 扩容机制
- 增量扩容(针对触发1):增加一倍桶的个数,把原来一个桶的数据分配到两个桶中
- 等量扩容(针对触发2):不会更改桶数,只是让桶的数据变得更紧凑
无论等量还是增量扩容,都不是原地操作,都需要创建新的桶数组进行搬迁
扩容过程是渐进式的,防止一次操作太多key引发性能问题,搬迁时机为赋值和删除期间,每次最多搬迁两个桶
channel
协程安全
引用类型
数据结构:
关键字段解析:
注意点:
value, ok := <- channel,关闭channel时,ok=false
for v := range channel, 关闭channel时,主动退出循环
<- channel int,作为参数时,只能读数据
channel <- int,作为参数时,只能写数据
interface
参考资料:
接口类型,只要实现了interface{}定义的全部方法,那么就可以认为是该interface{}
具体结构是在编译时确定
数据结构:
- eface
不包含方法的interface{}
_type:指向实际类型描述的指针
data:代表数据指针
- iface
包含方法的interface{}
tab指向itab结构:
itab是实际类型和interface类型的映射
- inter:指向interface的类型信息
- _type:同eface,指向实际类型描述的指针
- func:实际类型的函数列表
data:代表数据指针
unsafe.Pointer
go的指针不支持指针运算和转换
第一种用法,用指针做偏移:
第二种用法,用于数据转换:
error
不是引用类型,函数内部修改,外面不会变更
关键字
select
监听与channel有关的io操作
规则:
- 如果有多个io操作可执行,那么随机选择一个
- 如果所有io操作都未就绪,那么选择default
- 所有的表达式都会被求值,求值顺序从上到下,从左到右
defer
执行顺序:后进先出
执行原理:
- 设置返回值
- 执行defer函数
- ret指令跳转到caller函数(真正执行return)
recover
只在当前goroutine内捕获异常,可以防止panic后程序崩溃
外层goroutine是无法捕获内层goroutine的错误的
panic
注意点:
defer前panic:defer不会执行
defer后panic:先defer再panic
iota
参考资料:
- const关键字出现时变为0
- const中每新增一行常量声明将使iota计数一次
泛型
约束
反射
所有的对象都默认实现了interface接口,因此借助interface内部的_type和_word指针来实现反射
常用工具包
sync
- sync.Pool
原理:
- Put:先放入P的私有对象,如果已经被设置,那么放入P的共享对象中
- Get:先从P的私有对象获取,没有就从P的共享对象获取(加锁),还没有就从别的P共享池里面偷,再没有就new一个
- Clear:GC Mark Prepare阶段会把pool里的所有对象全部重置,且不会通知用户
用途:
由于sync.Pool数据被删除时并没通知,因此只适合做临时对象的池子
最常用的场景就是fmt中的输出缓冲区
- sync.Mutex
模式:
- 正常模式
- 饥饿模式
正常模式:
- 一个尝试加锁goroutine会自旋四次,仍获取不到锁则通过信号量排队(陷入系统调用)
- 当锁被释放时,第一个排队的goroutine不会立马获得锁,还是需要和后来者竞争,由于后来者大概率处于自旋状态,因此第一个排队的goroutine大概率抢不到锁,此时它会重新插入到队列头部(注意不是尾部)
- 当一个goroutine等待加锁的时长超过1ms,进入饥饿模式
饥饿模式:
- Mutex的所有权从解锁的goroutine直接传递到队列头部的goroutine
- 后来者不会再尝试获得锁了,也不会自旋,直接到队列尾部排队
- 如果获得锁的goroutine等待时间小于1ms,或者等待队列已经清空了,此时由饥饿模式转为正常模式
- sync.WaitGroup
数据结构:
注意事项:
- WaitGroup不能做值拷贝(从数据结构里的noCopy字段就知道,不支持拷贝使用)
- Wait调用后只能调用Done,而不能再调用Add
- sync.Map
协程安全的map
适用于读多写少的场景
数据结构:
原理:
- read存只读数据,dirty存写入数据,读写分离
- 读取时先查read(无锁),read没有查dirty(有锁),写时直接写dirty(有锁)
- misses标识read被穿透的次数,超过一定次数则把dirty挪到read上(防止加锁读影响效率)
- 删除则直接通过标记延迟删除,真正删除是在store时删除
rand
两种使用模式:
- 内部封装好的
并发安全
rand.Seed(time.Now().Unix())
rand.Intn(100)
- 未封装的
非并发安全的
rander := rand.New(rand.NewSource(time.Now().Unix()))
rander.Intn(100)
- 注意点
- Seed种子如果设置的是一样的,那么生成的随机数也是一样的
- Seed设置一次就好
context
通过构建树形关系的结构,来达到上层goroutine对下层goroutine的控制
- WithCancel
通过调用cancel方法,达到上层goroutine终止下层goroutine的效果
- WithDeadline
传入终止时间,一旦达到终止时间,自动调用cancel方法
- WithTimeout
传入超时时间,到达超时时间,就自动调用cancel方法
跟deadline的区别:deadline传入的是时刻,timeout传入的是时长
- WithValue
在context基础上添加了key-value
注意点:
key的类型最好在本地声明别名,不要传入通用类型
file相关包
- io
底层接口定义库,定义一些基本接口和基本常量
常见的接口有Reader、Writer
- ioutil
go1.16中已经将其废弃,相关功能挪到io或者os模块下
- bufio
可以理解为在io基础上的二次封装,引入缓存功能
- os
主要 跟操作系统打交道,文件操作基本都会跟操作系统挂钩,例如创建文件、打开文件
debug
- SetGCPercent
设置垃圾回收的比例,比例指的是新插入的数据和旧的数据比例
- SetMaxStack
约束单个goroutine所能申请栈空间的最大尺寸
- SetMaxThreads
设置M的最大个数
- ReadGCStats
返回运行时GC相关的关键指标,对于判断是否发生GC以及STW时长是否偏高有重要意义
runtime
- Gosched
让当前正在运行的goroutine让出cpu,类似rust中的yield方法
- Goexit
退出goroutine
- GOMAXPROCS
设置P的数量,默认为runtime.NumCPU
http
- http分为处理器和路由器
- 任何结构体只要实现了serveHttp方法都可以作为处理器,这种方法比较容易,也就有了handlerFunc类型,作用是将普通函数变为处理器
- handle和handlerFunc都是把处理器和路由器绑定的,handlerFunc在handle基础上再封装了一层
性能调优
GODEBUG
环境变量,用于控制runtime调度,其值为逗号隔开的name=val列表
例如:name1=val1,name2=val2,name3=val3
pprof
模块:
- cpu:可以查看一段时间内cpu的表现,默认持续30秒
- heap:直接拿到内存结果,不支持某一段时间查看
- block:报告goroutine不在运行的情况,可以用来分析和查找死锁等性能瓶颈
- goroutine:运行goroutine列表以及调用关系
代码优化
- 使用slice/map可扩容结构时,在能确定其大小时先确定大小,不要依赖扩容
- 临时对象用sync.pool,大于32K也可以使用sync.pool
- 少用reflect、defer(1.14有相关性能优化,现在挺快的)使用
- 不滥用goroutine和mutex
- string和[]byte的转换可以用unsafe.pointer(注意只读string转[]byte后一定不能修改!)
网络IO优化
- 批量接口支持
- http长连接
- redis pipeline
- db、redis连接池
- 缓存
- 大数据传输压缩
内存泄漏
- 使用字符串/slice中的一段,导致其余未被使用的部分无法被释放
- goroutine泄漏
- 定时器使用不当:time.Ticker没有close