Context
Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体
Deadline 被取消的时间,也就是完成工作的截止日期
Done 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel
Err 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值
Value 获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据
最大的作用是在 Goroutine 构成的树形结构中对信号进行同步以减少计算资源的浪费
Goroutine树中Context 会从最顶层的 Goroutine 一层一层传递到最下层,可以在上层 Goroutine 执行出现错误时,将信号及时同步给下层
传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID
同步原语与锁
sync.Mutex
type Mutex struct {
state int32
sema uint32
}
8个字节,最低三位分别表示 mutexLocked、mutexWoken 和 mutexStarving,剩下的位置用来表示当前有多少个 Goroutine 在等待互斥锁的释放
互斥锁的状态:
mutexLocked — 表示互斥锁的锁定状态
mutexWoken — 表示从正常模式被从唤醒
mutexStarving — 当前的互斥锁进入饥饿状态
waitersCount — 当前互斥锁上等待的 Goroutine 个数
正常模式下,锁的等待者会按照先进先出的顺序获取锁
但是刚被唤起的 Goroutine 与新创建的 Goroutine 竞争时,大概率会获取不到锁,为了减少这种情况的出现,一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被『饿死』
饥饿模式引入的目的是保证互斥锁的公平性
自旋
是一种多线程同步机制,当前的进程在进入自旋的过程中会一直保持 CPU 的占用,持续检查某个条件是否为真。在多核的 CPU 上,自旋可以避免 Goroutine 的切换,使用恰当会对性能带来很大的增益,但是使用的不恰当就会拖慢整个程序,所以 Goroutine 进入自旋的条件非常苛刻
sync.RWMutex
细粒度的互斥锁,它不限制资源的并发读,但是读写、写写操作无法并行执行
sync.WaitGroup
可以等待一组 Goroutine 的返回
sync.Once
保证在 Go 程序运行期间的某段代码只会执行一次
sync.Cond
可以让一组的 Goroutine 都在满足特定条件时被唤醒
Signal 唤醒队列最前面的 Goroutine
Broadcast 会唤醒队列中全部的 Goroutine
在条件长时间无法满足时,与使用 for {} 进行忙碌等待相比
Cond 能够让出处理器的使用权,提高 CPU 的利用率
errgroup Group
为我们在一组 Goroutine 中提供了同步、错误传播以及上下文取消的功能
semaphore Group
信号量是在并发编程中常见的一种同步机制,在需要控制访问资源的进程数量时就会用到信号量,它会保证持有的计数器在 0 到初始化的权重之间波动
每次获取资源时都会将信号量中的计数器减去对应的数值,在释放时重新加回来
当遇到计数器大于信号量大小时,会进入休眠等待其他线程释放信号
singleflight Group
能够在一个服务中抑制对下游的多次重复请求
一个比较常见的使用场景是:我们在使用 Redis 对数据库中的数据进行缓存,发生缓存击穿时,大量的流量都会打到数据库上进而影响服务的尾延时
在多数情况下,我们都应该使用抽象层级更高的 Channel 实现同步
计时器
Go 1.9 版本之前,所有的计时器由全局唯一的四叉堆维护
Go 1.10 ~ 1.13,全局使用 64 个四叉堆维护全部的计时器,每个处理器(P)创建的计时器会由对应的四叉堆维护
Go 1.14 版本之后,每个处理器单独管理计时器并通过网络轮询器触发
目前计时器都交由处理器的网络轮询器和调度器触发,这种方式能够充分利用本地性、减少上下文的切换开销,也是目前性能最好的实现方式
type timer struct {
pp puintptr
when int64
period int64
f func(interface{}, uintptr)
arg interface{}
seq uintptr
nextwhen int64
status uint32
}
when — 当前计时器被唤醒的时间
period — 两次被唤醒的间隔
f — 每当计时器被唤醒时都会调用的函数
arg — 计时器被唤醒时调用 f 传入的参数
nextWhen — 计时器处于 timerModifiedXX 状态时,用于设置 when 字段
status — 计时器的状态
Channel
不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存
通信顺序进程(Communicating sequential processes,CSP)
Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Goroutine 之间会通过 Channel 传递数据
FIFO
先从 Channel 读取数据的 Goroutine 会先接收到数据
先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利
Channel 是一个用于同步和通信的有锁队列
直接发送
如果目标 Channel 没有被关闭并且已经有处于读等待的 Goroutine
阻塞发送
当 Channel 没有接收者能够处理数据时,向 Channel 发送数据会被下游阻塞,当然使用 select 关键字可以向 Channel 非阻塞地发送消息
触发 Goroutine 调度的时机
发送数据时发现 Channel 上存在等待接收数据的 Goroutine,立刻设置处理器的 runnext 属性,但是并不会立刻触发调度
发送数据时并没有找到接收方并且缓冲区已经满了,这时会将自己加入 Channel 的 sendq 队列并调用goparkunlock触发 Goroutine 的调度让出处理器的使用权
直接接收
当 Channel 的 sendq 队列中包含处于等待状态的 Goroutine 时,该函数会取出队列头等待的 Goroutine
1、如果 Channel 为空,那么会直接调用gopark挂起当前Goroutine
2、如果 Channel 已经关闭并且缓冲区没有任何数据,chanrecv会直接返回
3、如果 Channel 的 sendq 队列中存在挂起的 Goroutine,会将 recvx 索引所在的数据拷贝到接收变量所在的内存空间上并将 sendq 队列中 Goroutine 的数据拷贝到缓冲区
4、如果 Channel 的缓冲区中包含数据,那么直接读取 recvx 索引对应的数据
5、在默认情况下会挂起当前的 Goroutine,将sudog结构加入 recvq 队列并陷入休眠等待调度器的唤醒
触发调度:
当 Channel 为空时
当缓冲区中不存在数据并且也不存在数据的发送者时
关闭
将 recvq 和 sendq 两个队列中的数据加入到 Goroutine 列表 gList 中,与此同时该函数会清除所有sudog上未被处理的元素
调度器
进程和线程
多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量
虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1M 以上的内存空间,在切换线程时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁资源,每一次线程上下文的切换都需要消耗 ~1us 左右的时间,但是 Go 调度器对 Goroutine 的上下文切换约为 ~0.2us,减少了 80% 的额外开销
线程与 Goroutine
Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载
演进
0.x
单线程调度器,程序中只能存在一个活跃线程,由 G-M 模型组成
1.0
多线程调度器,允许运行多线程的程序,全局锁导致竞争严重
1.1
任务窃取调度器,引入了处理器 P,构成了目前的 G-M-P 模型;在处理器 P 的基础上实现了基于工作窃取的调度器;在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题;时间过长的垃圾回收(Stop-the-world,STW)会导致程序长时间无法工作
1.2 至今
抢占式调度器
1.2~1.13 基于协作的抢占式调度器,通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停
1.14 至今 基于信号的抢占式调度器,实现基于信号的真抢占式调度,垃圾回收在扫描栈时会触发抢占调度,抢占的时间点不够多,还不能覆盖全部的边缘情况
非均匀存储访问调度器 · 提案
对运行时的各种资源进行分区
基于信号的抢占式调度
1、程序启动时,在sighandler注册SIGURG信号处理函数 doSigPreempt
2、在触发垃圾回收的栈扫描时会调用suspendG挂起 Goroutine
将 _Grunning 状态的 Goroutine 标记成可以被抢占,即将 preemptStop 设置成 true;调用preemptM触发抢占
3、preemptM会调用signalM向线程发送信号SIGURG
4、操作系统会中断正在运行的线程并执行预先注册的信号处理函数 doSigPreempt
5、doSigPreempt会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用pushCall
6、pushCall会修改寄存器并在程序回到用户态时执行asyncPreempt
7、汇编指令asyncPreempt会调用运行时函数asyncPreempt2
8、asyncPreempt2会调用preemptPark
9、preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted 并调用schedule让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行
为什么选择SIGURG信号?
1、该信号需要被调试器透传
2、该信号不会被内部的 libc 库使用并拦截
3、该信号可以随意出现并且不触发任何后果
4、我们需要处理多个平台上的不同信号
STW 和栈扫描是一个可以抢占的安全点(Safe-points),所以 Go 语言会在这里先加入抢占功能
G — 表示 Goroutine,它是一个待执行的任务
M — 表示操作系统的线程,它由操作系统的调度器调度和管理
P — 表示处理器,它可以被看做运行在线程上的本地调度器
type g struct {
stack stack
stackguard0 uintptr
preempt bool
preemptStop bool
preemptShrink bool
_panic *_panic
_defer *_defer
m *m
sched gobuf
atomicstatus uint32
goid int64
}
stack 当前 Goroutine 的栈内存范围 [stack.lo, stack.hi)
stackguard0 用于调度器抢占式调度
preempt 抢占信号
preemptStop 抢占时将状态修改成 _Gpreempted
preemptShrink 在同步安全点收缩栈
_panic 最内侧的 panic 结构体
_defer 最内侧的延迟函数结构体
m 当前 Goroutine 占用的线程,可能为空
atomicstatus Goroutine 的状态
sched 存储 Goroutine 的调度相关的数据
goid Goroutine 的 ID,该字段对开发者不可见
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ret sys.Uintreg
...
}
sp 栈指针
pc 程序计数器
g 持有该结构体的Goroutine
ret 系统调用的返回值
这些内容会在调度器保存或者恢复上下文的时候用到,其中的栈指针和程序计数器会用来存储或者恢复寄存器中的值,改变程序即将执行的代码
Goroutine的状态:
_Gidle 刚刚被分配并且还没有被初始化
_Grunnable 没有执行代码,没有栈的所有权,存储在运行队列中
_Grunning
可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
_Gsyscall
正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
_Gwaiting
由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
_Gdead
没有被使用,没有执行代码,可能有分配的栈
_Gcopystack
栈正在被拷贝,没有执行代码,不在运行队列上
_Gpreempted
由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
_Gscan
GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在
Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行
在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销
type m struct {
g0 *g
curg *g
...
}
g0 是持有调度栈的 Goroutine
curg 是在当前线程上运行的用户 Goroutine
g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行
type m struct {
p puintptr
nextp puintptr
oldp puintptr
}
正在运行代码的处理器 p
暂存的处理器 nextp
执行系统调用之前使用线程的处理器 oldp
调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率
Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上
type p struct {
m muintptr
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
...
}
runqhead、runqtail 和 runq 三个字段表示处理器持有的运行队列
调度循环
1、为了保证公平,当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine
2、从处理器本地的运行队列中查找待执行的 Goroutine
3、如果前两种方法都没有找到 Goroutine,会通过findrunnable 进行阻塞地查找 Goroutine
findrunnable
1、从本地运行队列、全局运行队列中查找
2、从网络轮询器中查找是否有 Goroutine 等待运行
3、通过 runqsteal 尝试从其他随机的处理器中窃取待运行的 Goroutine,该函数还可能窃取处理器的计时器
网络轮询器
Epoll
系统监控
休眠时间:
初始的休眠时间是 20μs
最长的休眠时间是 10ms
当系统监控在 50 个循环中都没有唤醒 Goroutine 时,休眠时间在每个循环都会倍增
当程序趋于稳定之后,系统监控的触发时间就会稳定在 10ms。它除了会检查死锁之外,还会在循环中完成以下的工作:
运行计时器 — 获取下一个需要被触发的计时器
轮询网络 — 获取需要处理的到期文件描述符
抢占处理器 — 抢占运行时间较长的或者处于系统调用的 Goroutine
垃圾回收 — 在满足条件时触发垃圾收集回收内存
检查死锁:
检查是否存在正在运行的线程
检查是否存在正在运行的 Goroutine
检查处理器上是否存在计时器
正在运行的线程数,如果线程数量大于 0,说明当前程序不存在死锁;如果线程数小于 0,说明当前程序的状态不一致;如果线程数等于 0,我们需要进一步检查程序的运行状态
当存在 Goroutine 处于 _Grunnable、_Grunning 和 _Gsyscall 状态时,意味着程序发生了死锁
当所有的 Goroutine 都处于 _Gidle、_Gdead 和 _Gcopystack 状态时,意味着主程序调用了goexit
当运行时存在等待的 Goroutine 并且不存在正在运行的 Goroutine 时,我们会检查处理器中存在的计时器
如果处理器中存在等待的计时器,那么所有的 Goroutine 陷入休眠状态是合理的,不过如果不存在等待的计时器,运行时会直接报错并退出程序