并发实现

并发的实现有两种模式:

  1. 多线程共享机制
  2. CSP(Communicating Sequential Process 通信顺序进程)

线程模型:

  1. 用户线程模型:1内核 - N用户线程
  2. 内核线程模型:1内核 - 1用户
  3. 二级线程模型:M内核 - N用户

Golang:

  1. 基于CSP:不要通过共享内存来通信,要通过通信来共享内存(goroutine & channel)
  2. 二级线程模型

优势劣势

  • 优势
  1. 跨平台
  2. goroutine&channel
  3. 函数多返回值
  4. 社区强大
  • 劣势
  1. 错误处理
  2. 范围界定不够强大(同一个包内)
  3. 没有枚举

MPG调度机制

M

内核线程

上限可以由runtime/debug.SetMaxThreads(num)来设置

运行动态按需创建

M的数量和P的数量没有关系,运行时如果没有足够的M来执行P,那么会创建M

P

调度器

数量由runtime.GOMAXPROCS(num)来设置、没有上限

一般和CPU核数保持一致,如果设置过多会导致一直在线程间切换

如果要在容器里设置P,要根据cgroup分配的cpu资源数来设置,此时cpunums无法准确被代码获取

在runtime启动时创建

G

协程(goroutine)

数量无限制

运行时动态创建

调度过程

  1. 创建G,加入到P的本地队列头部或者全局队列,然后P会唤醒M
  2. P按照它的顺序继续执行。被唤醒的M会找空闲的P,如果找得到,将G移向M(G只能在M上跑,G不一定是1步骤里创建的G,而是等待队列里最前面的G)
  3. M执行G

调度策略

  • work stealing

当M没有可以运行的G时,可以从其它的P那里偷G来运行

  • hand off

陷入系统调用时,M会直接连着G跑,P会被基于协作的抢占放开,让给其它可以跑的M使用

goroutine

创建执行

  • runtime.newproc(其实就是调度过程里的步骤)
  1. wakep(找P):获取本地P,并尝试在P的gfree中找不用的G,未找到则创建G(2K),将func与G绑定,加入到P的本地队列,满了就放到全局队列
  2. startm(找M):如果没有可用的M并且M还未满则newm,否则直接找到M
  3. 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:

  1. 将running状态设置成waiting状态
  2. 解除goroutine与M的关系
  3. 触发一次M调度
  • goready(恢复)

当阻塞恢复时,会通过goready唤醒阻塞的goroutine

  1. 将waiting状态设置成runable状态
  2. 将goroutine放入P的队列里等待运行

抢占式调度

在引入这个机制之前,如果goroutine执行的是无sleep的死循环,那么这个循环永远不会被调度

基于此引入该机制,由sysmon负责执行retake函数,主要做以下事情:

  1. 抢占进行系统调用的P(GO1.2引入基于协作的抢占)-> hand off机制
  2. 抢占运行时间过长的P(GO1.14引入基于信号的抢占)-> 给goroutine设置栈错误,运行中主动触发gopark

消亡

goroutine执行runtime.Goexit退出,回收资源,将其设置为free状态,放入P的gfree队列中等待复用

runtime

启动过程

  1. 初始化tls,直接把g0放入tls里(为newproc里面M0的创建做准备)
  2. runtime.check:运行检查
  3. runtime.args:设置启动参数和路径
  4. runtime.osinit:操作系统层面初始化
  5. runtime.schedinit:资源初始化(模块、符号、内存、GC、P)
  6. runtime.newproc:goroutine启动运行流程,在这里是把runtime.main放入G对象
  7. 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

程序分配区域判定

编译器决定,当变量生命周期完整时在栈上,生命周期不完整时在堆上

不是所有局部对象都在栈上,当局部对象发生内存逃逸时,就会分配在堆上

  • 内存逃逸
  1. 局部指针外部引用
  2. 栈空间不足:栈空间不足以分配所需对象
  3. 动态类型:把对象转为interface{}传入函数时,该对象会逃逸,例如fmt.Println(name),name会逃逸
  4. 分配大小未知的对象:make([]int, n),n为一个编译时期未知的变量

要尽量避免内存逃逸,因为栈的处理速度远大于堆

  • 内存逃逸分析

go build -gcflags '-m -l' main.go

-m:是逃逸分析建议,可以用4个-m,但是一般没必要

-l:是禁止内联,防止干扰

更硬核的看法,反编译:go tool compile -S main.go

GC

常见机制

  • 引用计数

当出现嵌套时,会出现循环引用的问题

解决循环引用:

  1. 可达性分析:引入gc root,追踪对象是否可以连接到gc root,如果不行则清除
  2. 弱引用机制:出现循环引用时用弱引用代码,弱引用不持有对象
  • 标记清除

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):

  1. 初始所有对象都是白色
  2. 启动混合写屏障(记录mark状态下被修改的对象)
  3. 找到所有的roots(stack、heap、global)加入到标记队列,进入标记队列则变成灰色

mark:

  1. 并发goroutine扫描队列中的对象
  2. 对象被扫描则出队,出队就标记为黑色
  3. 然后继续查看出队的对象是否还有指向的对象,有则加入扫描队列,继续扫描

mark termination(stw

  1. 把被混合写屏障拦截的对象加入到扫描队列
  2. 按照mark扫描动作针对拦截对象再次扫描(rescan)

sweep:

所有的节点非黑即白,黑色清除,白色留存

sweep termination:

对未清扫的span进行清扫

  • 混合写屏障

未引入混合写屏障时,mart termination会重新扫描栈,这个是stw最大的来源

混合写屏障的作用,就是在mark termination阶段不用再次扫描roots,因为它会单独记录在mark阶段所有的修改动作,极大的缩短时间

时机

  • 内存达到阈值(上次GC后的两倍)
  • 达到定时时间(2m)
  • 手动GC(runtime.GC())

基本结构

传递性

  • 值传递:值传递是对值的一次拷贝

golang所有对象的传递都是值传递

当变量为值类型时,会产生新的副本,跟原变量不再有关联

当变量为引用类型时,也会产生新的副本,但此时新副本指向的数据和老副本指向的数据一致,因此实际上还是一个变量

可比较性

参考资料:
  1. slice、map、func不可比较
  2. array可以比较,但array的类型必须一致
  3. 相同结构体的实例能不能比较,主要看结构体内的成员是不是都可以比较,都能比较则能比较
  4. 相同结构体的指针一定能比较
  5. 不同结构体的实例和指针都不能比较

不可比较的类型是无法作为map的key

比较办法:

  1. bytes.Equal比较byte数组
  2. reflect.DeepEqual比较任意两个类型的变量
  3. 使用google的cmp包

rune/byte

  • byte:等同于uint8,一个字节长度,用来处理ascii
  • rune:等同于uint32,四个字节长度,用来处理unicode或者utf8

utf8是属于unicode编码的,除此之外还有utf16、utf32

string

utf8编码,常量字符串无法修改(只读)

当使用strings方法去操作string时,会生成string的额外拷贝

当直接赋值string时,并不会生成string的额外拷贝,因此string也是引用类型

数据结构:

注意事项:

  1. 只读string无法修改,修改会panic
  2. 只读string经过程序处理后,会变成可修改的string,此时可以修改

array

值类型

slice

在数组基础上额外增加一层包装,可扩展的数组

引用类型

非协程安全

数据结构:

扩容策略:

  1. cap <= 1024,加倍扩容
  2. cap > 1024,扩展因子变为1.25,每次增长四分之一
  3. 每次扩容之后,切片地址会发生变化,不再跟原有地址保持一致,变成一个新的数组

注意事项:

nums := []int{1}

nums[:0]、nums[1:]都是不会panic的

nums[2:]会panic

因为nums[1:] = nums[1:1],前面的数字是不能超过nums的长度的

map

参考资料:

底层是哈希表实现,使用链地址法解决冲突,依赖的核心数据结构是数组加链表

引用类型

非协程安全

数据结构:

关键字段解析:

写入过程:

  1. 通过key计算出一个hash值,低B位(比如B是5那么就是低5位)用于选择桶,高8位用于在桶中区别key
  2. 先扫描该桶以及对应的溢出桶,判断该key是否存在(高8位是否相同),如果存在直接覆盖,不存在继续往下走
  3. 按照顺序从前往后找第一个空位,有溢出桶则也要在溢出桶中查找
  4. 如果桶满了,并且没有溢出桶,那么就在overflow上挂载溢出桶往下写

扩容:

  • 扩容触发

新增元素时检查以下条件,满足则触发扩容:

  1. key超过了装载因子B
  2. 多次增删操作,造成溢出桶过多
  • 扩容机制
  1. 增量扩容(针对触发1):增加一倍桶的个数,把原来一个桶的数据分配到两个桶中
  2. 等量扩容(针对触发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类型的映射

  1. inter:指向interface的类型信息
  2. _type:同eface,指向实际类型描述的指针
  3. func:实际类型的函数列表

data:代表数据指针

unsafe.Pointer

go的指针不支持指针运算和转换

第一种用法,用指针做偏移:

第二种用法,用于数据转换:

error

不是引用类型,函数内部修改,外面不会变更

关键字

select

监听与channel有关的io操作

规则:

  1. 如果有多个io操作可执行,那么随机选择一个
  2. 如果所有io操作都未就绪,那么选择default
  3. 所有的表达式都会被求值,求值顺序从上到下,从左到右

defer

执行顺序:后进先出

执行原理:

  1. 设置返回值
  2. 执行defer函数
  3. ret指令跳转到caller函数(真正执行return)

recover

只在当前goroutine内捕获异常,可以防止panic后程序崩溃

外层goroutine是无法捕获内层goroutine的错误的

panic

注意点:

defer前panic:defer不会执行

defer后panic:先defer再panic

iota

参考资料:
  1. const关键字出现时变为0
  2. const中每新增一行常量声明将使iota计数一次

泛型

约束

反射

所有的对象都默认实现了interface接口,因此借助interface内部的_type和_word指针来实现反射

常用工具包

sync

  • sync.Pool

原理:

  1. Put:先放入P的私有对象,如果已经被设置,那么放入P的共享对象中
  2. Get:先从P的私有对象获取,没有就从P的共享对象获取(加锁),还没有就从别的P共享池里面偷,再没有就new一个
  3. Clear:GC Mark Prepare阶段会把pool里的所有对象全部重置,且不会通知用户

用途:

由于sync.Pool数据被删除时并没通知,因此只适合做临时对象的池子

最常用的场景就是fmt中的输出缓冲区

  • sync.Mutex

模式:

  1. 正常模式
  2. 饥饿模式

正常模式:

  1. 一个尝试加锁goroutine会自旋四次,仍获取不到锁则通过信号量排队(陷入系统调用)
  2. 当锁被释放时,第一个排队的goroutine不会立马获得锁,还是需要和后来者竞争,由于后来者大概率处于自旋状态,因此第一个排队的goroutine大概率抢不到锁,此时它会重新插入到队列头部(注意不是尾部)
  3. 当一个goroutine等待加锁的时长超过1ms,进入饥饿模式

饥饿模式:

  1. Mutex的所有权从解锁的goroutine直接传递到队列头部的goroutine
  2. 后来者不会再尝试获得锁了,也不会自旋,直接到队列尾部排队
  3. 如果获得锁的goroutine等待时间小于1ms,或者等待队列已经清空了,此时由饥饿模式转为正常模式
  • sync.WaitGroup

数据结构:

注意事项:

  1. WaitGroup不能做值拷贝(从数据结构里的noCopy字段就知道,不支持拷贝使用)
  2. Wait调用后只能调用Done,而不能再调用Add
  • sync.Map

协程安全的map

适用于读多写少的场景

数据结构:

原理:

  1. read存只读数据,dirty存写入数据,读写分离
  2. 读取时先查read(无锁),read没有查dirty(有锁),写时直接写dirty(有锁)
  3. misses标识read被穿透的次数,超过一定次数则把dirty挪到read上(防止加锁读影响效率)
  4. 删除则直接通过标记延迟删除,真正删除是在store时删除

rand

两种使用模式:

  • 内部封装好的

并发安全

rand.Seed(time.Now().Unix())

rand.Intn(100)

  • 未封装的

非并发安全的

rander := rand.New(rand.NewSource(time.Now().Unix()))

rander.Intn(100)

  • 注意点
  1. Seed种子如果设置的是一样的,那么生成的随机数也是一样的
  2. 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

  1. http分为处理器和路由器
  2. 任何结构体只要实现了serveHttp方法都可以作为处理器,这种方法比较容易,也就有了handlerFunc类型,作用是将普通函数变为处理器
  3. handle和handlerFunc都是把处理器和路由器绑定的,handlerFunc在handle基础上再封装了一层

性能调优

GODEBUG

环境变量,用于控制runtime调度,其值为逗号隔开的name=val列表

例如:name1=val1,name2=val2,name3=val3

pprof

模块:

  1. cpu:可以查看一段时间内cpu的表现,默认持续30秒
  2. heap:直接拿到内存结果,不支持某一段时间查看
  3. block:报告goroutine不在运行的情况,可以用来分析和查找死锁等性能瓶颈
  4. goroutine:运行goroutine列表以及调用关系

代码优化

  1. 使用slice/map可扩容结构时,在能确定其大小时先确定大小,不要依赖扩容
  2. 临时对象用sync.pool,大于32K也可以使用sync.pool
  3. 少用reflect、defer(1.14有相关性能优化,现在挺快的)使用
  4. 不滥用goroutine和mutex
  5. string和[]byte的转换可以用unsafe.pointer(注意只读string转[]byte后一定不能修改!)

网络IO优化

  1. 批量接口支持
  2. http长连接
  3. redis pipeline
  4. db、redis连接池
  5. 缓存
  6. 大数据传输压缩

内存泄漏

  1. 使用字符串/slice中的一段,导致其余未被使用的部分无法被释放
  2. goroutine泄漏
  3. 定时器使用不当:time.Ticker没有close