目录

Golang 基础


Channel

在 Go 的设计模式中,不要通过共享内存的方式通信,而应该通过通信的方式共享内存。

无锁 Channel

锁通常分为乐观锁与悲观锁,无锁(lock-free)技术其实是基于乐观并发控制的思想。

乐观锁 Optimistic Lock

乐观锁本质是基于验证的协议,它假设多个事务之间不会发生冲突,因此在数据处理期间不加锁,而是在提交操作时检查是否存在冲突。通常使用版本号或时间戳来检测数据是否被修改,依赖 CAS 原子指令。

特点:适用于读多写少,性能高,冲突时需要重试

悲观锁 Pessimistic Lock

悲观锁假设多个事务之间可能发生冲突,因此在数据处理期间对数据加锁,以确保其他事务不能并发访问该数据。悲观锁分为读锁和写锁。

特点:适用于写多读少,存在死锁

数据结构

type hchan struct {
	qcount   uint           // 当前队列中的剩余元素
	dataqsiz uint           // 环形队列长度,即可以存放的元素个数
	buf      unsafe.Pointer // 环形队列指针
	elemsize uint16         // 每个元素的大小
	closed   uint32         // 标识关闭状态
	elemtype *_type         // 元素类型
	sendx    uint           // 发送操作处理到的位置
	recvx    uint           // 接收操作处理到的位置
	recvq    waitq          // list of recv waiters
	sendq    waitq          // list of send waiters
	lock     mutex
}
// waitq 双向链表
// sudog 表示在等待列表里的 g,一个 g 可能在多个等待队列中,所以被打包成 sudog
// runtime 使用 acquireSudog() 和 releaseSudog() 来分配和释放 sudog
type waitq struct {
	first *sudog
	last  *sudog
}

发送数据

直接发送

如果 ch 存在读阻塞的 G,那么:

  1. recvq 的头部弹出一个 G
  2. 使用 runtime.send 将要发送的数据复制给这个 G(将数据从发送者的内存空间拷贝到接收者的内存空间),之后 recvx++(接收索引递增)。
  3. 调用 runtime.goready 将 G 标记为 runnable,使其成为下一个要运行的 G

缓冲区

如果 ch 有缓冲区并且缓冲区中有空闲的容量:

  1. 数据将直接存储到缓冲区中 sendx(发送索引)所在的位置
  2. sendx++(发送索引递增)

阻塞发送

如果不满足上述两种情况,发送 G 会被阻塞:

  1. 发送 G 会被包装成 runtime.sudog 结构,并被添加到通道的 sendq
  2. 当前 G 会陷入阻塞状态,等待其他的 G 从通道中接收数据

接收数据

直接接收

与直接发送逻辑相似,如果 ch 存在写阻塞的 G,那么会从 sendq 的头部弹出一个 G,通过 runtime.recv 接收数据并将其设置成下一个要运行的 G。

缓冲区

如果 ch 的缓冲区中已经包含数据时,接收数据会从缓冲区的 sendx 索引位置取出数据

阻塞接收

如果不满足上面的两种情况,那么:

  1. 接收操作会变为阻塞(如果在 select 语句中使用也可以是非阻塞的)
  2. 将当前 G 包装成 runtime.sudog 结构加入 ch 的 recvq 队列中
  3. 调用 runtime.goparkunlock 立即让出调度权

边界情况

  1. 如果 ch 已经关闭,读操作 runtime.chanrecv 会直接返回零值和一个关闭的标志
  2. 如果缓冲区为空且 ch 已关闭,接收操作会立即返回

关闭

关闭管道会把 recvq 中的协程通过 runtime.goready 全部唤醒,这些协程获取的数据都为管道元素类型的零值,并且接收操作会返回 false;同时把 sendq 队列中的协程全部唤醒,但这些协程会触发 panic

除此之外,其他会触发 panic 的操作:

  1. 关闭值为 nil 的管道
  2. 关闭已经被关闭的管道
  3. 向已经关闭的管道写入数据

Context

上下文 Context 是 Go 并发编程的基础,主要用于在树形结构的 Goroutines 中进行通信

同步原语与锁

Mutex

Mutex 有两种模式:正常模式饥饿模式

普通模式下,Mutex 使用的是公平锁,保证先请求锁的 Goroutine 能先获得锁。

饥饿模式下,Mutex 优先分配给等待最久的 Goroutine,从而避免长尾延迟。

当一个 Goroutine 请求锁但锁已被其他 Goroutine 持有时,它将被添加到等待队列的末尾,并首先尝试自旋,如果在自旋期间锁仍未被释放,它将被挂起进入休眠。

加锁

TODO

自旋

自旋是一种多线程同步机制,用于处理短期的锁争用。进程在进入自旋时会一直保持 CPU 的占用,持续检查锁是否已被释放。在多核 CPU 上,自旋可以避免频繁的上下文切换。 自旋的次数有限,如果在自旋期间仍未获取到锁,Goroutine 将放弃自旋并进入休眠状态。

休眠和唤醒

当一个 Goroutine 在等待队列中进入休眠状态后,它将被阻塞,直到被其他 Goroutine 唤醒。 当前持有锁的 Goroutine 在释放锁时,会负责唤醒等待队列中的下一个 Goroutine。 被唤醒的 Goroutine 将尝试重新获取锁,如果成功,则退出等待队列;否则,将再次进入等待队列的末尾,继续等待。

模式切换

如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式。

sync: make Mutex more fair

RWMutex

基于 Mutex,实现更细粒度的控制

释放写锁

  1. 调用 sync/atomic.AddInt32 函数将 readerCount 变回正数,释放读锁;
  2. 通过 for 循环释放所有因为获取读锁而陷入等待的 Goroutine:
  3. 调用 sync.Mutex.Unlock 释放写锁;

释放读锁

singleflight.Group

扩展同步原语,可以在服务中抑制对下游的多次重复请求。常用于解决 缓存击穿 问题。

https://img.draveness.me/2020-01-23-15797104328078-golang-extension-single-flight.png

GMP 调度器

gmp 调度模型是 go 自身的并发模型,通过在 用户态 实现的各种调度算法与并发控制方案,避免 进入操作系统级别的进程/线程上下文切换,以此提升性能。

https://img.draveness.me/2020-02-05-15808864354595-golang-scheduler.png

组件

G (Goroutine)

调度器中待执行的任务,持有运行函数的指针、栈、上下文。下图是 G 的状态迁移过程:

https://img.draveness.me/2020-02-05-15808864354615-golang-goroutine-state-transition.png

G 分为三种:

  1. g0: runtime 的一部分,它与 m 绑定,用来执行调度逻辑的代码,不能被抢占也不会被调度。
  2. sysmon 协程:全局监控者,也是 runtime 的一部分,在 Go 程序运行时自动在后台启动,直接运行在 m 上,不需要 p,负责检查工作如死锁、网络 IO 是否 ready、某个 g 是否需要抢占式调度。
  3. 普通 g:用户调用 go func() 创建的 g。

M (Machine)

操作系统线程,实际工作的执行者,由操作系统的调度器调度和管理。

使得所有调度发生在 用户态,不会频繁触发操作系统的线程调度和上下文切换。

M 默认值是 GOMAXPROCS ,与机器核数相同,M 的数量上限是 1w 个。

type m struct {
	g0   *g // 持有调度栈的 g
	curg *g // 当前线程运行的 g
	// ...
}

M 分三种:

  1. m0:go 进程的主线程,执行 main 函数,有且只有一个 m0
  2. 运行 sysmon 的 m:负责运行 sysmon 全局监控协程
  3. 普通 m:与 p 绑定并执行用户代码的 g

运行时通过 startm() 启动线程来执行处理器 P,如果闲置列表中没有 M,则通过 newm() 创建一个新线程,newm() 中通过 newosproc() 创建新线程,在 Linux 平台上会通过系统调用 clone() 来创建。

P (Processor)

调度逻辑处理器,G 和 M 的中间层,可以看作是运行在线程上的本地调度器,默认为机器核数。

本地队列

本地队列是 数组+双指针 构成的双端队列,容量固定为 256。

type p struct {
	m        muintptr // 绑定的 M
	runqhead uint32   // 本地队列
	runqtail uint32   // 本地队列
	runq     [256]guintptr // 本地队列
	runnext  guintptr // 下一个要执行的 G
	// ...
}

全局队列

全局队列是一个 链表 结构的 FIFO 队列,访问全局队列需要加锁。

// runtime2.go
type schedt struct {
    lock mutex // 互斥锁
    runq gQueue // 全局队列
    runqsize int32 // 队列容量
    // ...
}

type gQueue struct {
	head guintptr
	tail guintptr
}

调度类型

除了抢占调度的其他方法,都是在用户态的边界之内,通过 g0 主动完成的;而抢占调度是在全局的 sysmon 的帮助下完成的。

协作式调度(主动让出)

用户可以通过主动交出执行权的方式,优先处理其他任务:

  1. 调用 Gosched() 让出当前处理器,通过 mcall() 将执行权交给 g0
  2. 解除该 g 和 m 的关联,g 的状态由 _Grunning 切换为 _Grunnable,并放入全局队列
  3. schedule() 触发下一轮调度

被动调度

在 g 因为操作 channel 或 mutex 等资源陷入阻塞时,会触发被动调度:

  1. 通过 gopark() 暂停当前 g,将状态 _Grunning 变为 _Gwaiting
  2. gopark() 中通过调用 mcall() 将执行权交给 g0
  3. 解除该 g 与 m 的关联
  4. schedule() 触发下一轮调度

当 g 等待的条件满足,需要被 唤醒 时:

  1. 通过 goready() 将休眠的 g 状态切换为 _Grunnable,并加入唤醒者 p 的本地队列中
  2. 注意:唤醒 g 的操作不由 g0 执行,而是由上层完成(channel,mutex 等),上层数据结构会维护因被动调度而陷入阻塞的 g,当事件 就绪 时,主动唤醒它们

抢占调度

当 g 发起 系统调用 陷入阻塞,此时会触发抢占调度:

  1. 通过 reentersyscall() 保存当前上下文,包括程序计数器 PC 和栈指针 SP 中的内容
  2. 将 g 的状态更新为 _Gsyscall
  3. 将当前 p 与 m 解绑,因为系统调用导致当前运行环境从用户态陷入了内核态,线程 m 也陷入挂起状态不可用(m-g 此时强绑定),因此当前 p 的调度操作也不可用,此时就需要在调度器之上的第三方监控协程 sysmon 来主动断开 p 与 m 的关联

当系统调用结束时:

  1. 通过 exitsyscall() 为当前 g 重新分配资源
  2. schedule() 触发下一轮调度

抢占调度突破了用户态的边界,使得当前 m 也陷入阻塞不可用,因此在 m 之上的 g0 也无法正常执行,此时就需要引入第三方 sysmon

调度流程

                              +-------------------- sysmon ---------------//------+ 
                              |                                                   |
                              |                                                   |
                 +---+      +---+-------+                   +--------+          +---+---+
  go func() ---> | G | ---> | P | local | <=== balance ===> | global | <--//--- | P | M |
                 +---+      +---+-------+                   +--------+          +---+---+
                              |                                 |                 | 
                              |      +---+                      |                 |
                              +----> | M | <--- findrunnable ---+--- steal <--//--+
                                     +---+ 
                                       |
                                     mstart
                                       |
                +--- execute <----- schedule 
                |                      |   
                |                      |
                +--> G.fn --> goexit --+ 

findrunnable

本地队列 -> 全局队列 -> 尝试唤醒 IO 就绪的 G -> 从其他 P 中窃取

balance

经过特定次数调度后,处理器会优先从全局队列中取出 G,避免在本地队列过于繁忙时导致全局队列中的 G 陷入饥饿状态;

如果本地队列满,处理器会尝试将一部分本地 G 移动到全局队列,为本地队列减负;

steal

从其他 P 的本地队列使用 fastrand() 窃取随机位置的 G,保证公平性;

每次窃取一半的 G;

execute

调用 gogo(),执行权由 g0 转交到 g,并执行用户代码逻辑

goexit

  1. mcall()
  2. 解绑 p 和 g,并将 g 状态由 _Grunning 切换为 _Gdead

retake(抢占调度的监控)

  1. 遍历所有 p
  2. 调用 handoffp() 查看系统是否繁忙(只有繁忙状态下会出现 g 饥饿,需要抢占调度)
  3. 将 p 由 _Psyscall 切换为 _Pidle,并分配新的 m 和 g

网络轮询器

参考

Go 语言调度器与 Goroutine 实现原理 | Go 语言设计与实现 (draveness.me)

解说Golang GMP 实现原理——Bilibili