前言
sema全称为semaphore,是被这么解释的:
A semaphore is a synchronization tool in computer science—invented by Edsger Dijkstra in the early 1960s—that uses an integer counter to manage concurrent access to shared resources. It works via atomic 'wait' (decrement) and 'signal' (increment) operations to control access, preventing race conditions. While providing robust synchronization for multiple resource instances, they can be complex to implement correctly and risk deadlock if misused
sema用于并发访问共享资源场景, 本质是个原子操作的计数器, 加1操作表示唤醒, 减1表示睡眠. 这样子解释可能比较抽象. 我们假设有这样一个场景:
图书馆有n个自习室, 每次来一个人占用1个自习室, 此时空闲的自习室-1(对应计数器减1操作). 当有n个人占用n个自习室, 此时空闲自习室为0, 再来1个人就需要等待了. 直到有人离开自习室此时自习室资源被释放(对应计数器加1操作), 下一个等待的人就可以去自习室进行作业了, 也就是唤醒. 那如果n初始为0, 此时每个进来的人都需要等待, 直到加1被唤醒.
而sema主要做的就是类似这样的事情.
数据结构
go runtime.sema 中的semaphore是一个指向一个整数的指针, 所有的semaphore统一被放在semaTable进行维护. semaTable里存放着251个semaRoot, 每个semaRoot是一个Treap数据结构, semaRoot中元素的key是整数指针值(semaphore), value是一个sudog等待队列. 不了解treap的, 可以看下part1章节的内容.
var semtable semTable
const semTabSize = 251
type semTable [semTabSize]struct {
root semaRoot
pad [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte // 内存对齐, cpu读取的优化, 跟实际业务没有关系
}
type semaRoot struct {
lock mutex
treap *sudog
nwait atomic.Uint32
}
在这个结构中, 我们发现semaRoot也有个mutex锁, 而我们semaRoot实际是实现sync.Mutex的一个手段, 那这里的mutex是干嘛的呢? 这里先说下结论, 这个mutex是系统线程级粒度的锁, 而sync.Mutex是用户线程级粒度的锁(协程), 也就是说这里的mutex是控制系统线程的同步, 而实际上它的实现逻辑跟sync.Mutex有些相似, 这里暂且点到为止, 我会在下个part揭开它的面纱.
nwait参数记录semaRoot里休眠的goroutine数.
每个semaphore 都存储着一个sync.Mutex的goroutine休眠队列. 所有的sync.Mutex休眠goroutine都由semaTable进行管理.
sema 主要核心逻辑是两个方法: semaacquire和semarelease, 接下来着重介绍这两个方法分别干了什么事情.
semaacquire
前面我们介绍说sema实际就是个资源计数器, 资源不够时需要等待. 而semaacquire实际上做的就是计数器的减1操作(需要使用资源).
这里展示核心源码, 一些跟主业务无关的逻辑将会省略:
func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason) {
gp := getg()
if gp != gp.m.curg {
throw("semacquire not on the G stack")
} // 代码防御性报错, 当前g跟m的状态不一致(当前执行的g跟m挂载的g不一致)
if cansemacquire(addr) {
return
} // 快速判断, 是否可以直接扣减资源
s := acquireSudog() // 获取一个sudog用来封转当前g上下文
root := semtable.rootFor(addr) // 获取对应的semaRoot
s.ticket = 0 // 在semaRoot中作为Treap的权重值, 在sema流程中作为是否进行竞争的标准
for {
lockWithRank(&root.lock, lockRankRoot) // 上锁
root.nwait.Add(1) // 等待数+1
if cansemacquire(addr) { // 上锁后进行二次检查
root.nwait.Add(-1)
unlock(&root.lock)
break
}
root.queue(addr, s, lifo) // 进入等待队列
goparkunlock(&root.lock, reason, traceBlockSync, 4+skipframes) // 开始休眠
if s.ticket != 0 || cansemacquire(addr) { // s.ticket 不为0, 表示在被唤醒前已经被分配了资源, 可以直接唤醒
//s.ticket 为0, 表示需要跟其他唤醒的routine抢夺资源, 这里对应sync.Mutex的两种状态
break
}
// 会执行在这里表示当前goroutine抢夺不到资源, 需要重新休眠
}
releaseSudog(s) // 回收sudog
}
上面代码涉及到sudog数据结构, 不了解到可以去看看笔记go channel探索 篇章.以下我们统称为sudog或g.
首先先来看看方法定义 func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason)
addr: 需要夺取资源的semaphore
lifo: true, 当前goroutine将被置在等待队列头部, false将置在等待队列尾部
profile, skipframes, reason: 用于性能跟踪, 跟主逻辑没有关联
源码走读:
首先执行的是一个fast path:
cansemaphore(addr), 这个方法是先尝试获取资源, 其实现:
func cansemacquire(addr *uint32) bool {
for {
v := atomic.Load(addr)
if v == 0 {
return false
}
if atomic.Cas(addr, v, v-1) {
return true
}
}
}
尝试将资源计数器减1, 如果成功则顺利获取到资源, 完成流程.
而实际上在sync.Mutex中, addr初始值是0, 按理论上来说, 应该是先休眠, 再唤醒的逻辑, 并没有额外的资源能通过fast path, 那为什么这里要这么设计呢?
在真实场景中往往是很复杂的, 实际上可能有多个goroutine等着抢夺资源, 这里实际上营造了一种抢占的环境, 谁先抢到谁先执行. 有这样一种场景 fash path 抢到资源:
G1 执行lock, 加入等待队列, 等待唤醒(一般会进入这里代表需要休眠)
G2 fast path阶段, 尝试抢资源
G3 执行unlock, 释放资源, 资源+1, 尝试唤醒一个Goroutine
G2 在fast path阶段抢到资源, 不用进入等待队列, 直接退出
G1 因为G2抢到资源, 导致G1释放不了锁, 重新休眠
上述案例, G3释放资源, G1, G2 争夺资源, G2在fast path阶段抢到资源, G2不用进入休眠, G1被唤醒后抢不到锁, G1重新进入休眠等待下一个资源释放.这样做的好处是资源能很快被抢到, 利用率高, 缺点是可能像G1这样的线程会一直抢不到资源. 而这个问题这里先留个悬念, 后面会提到怎么解决.
接着通过semaphore的值来获取对应的semaRoot:
root := semtable.rootFor(addr), 实现:
func (t *semTable) rootFor(addr *uint32) *semaRoot {
return &t[(uintptr(unsafe.Pointer(addr))>>3)%semTabSize].root
}
这里通过addr对 251 取余获取对应的semaRoot, 这是个以空间换时间的策略, 让所有semaphore分散在这251棵Treap树中, 防止堆在一棵树影响查询效率.
另一个值得一说的点是 uintptr(unsafe.Pointer(addr))>>3 这里为什么要右移3位?
这里的设计涉及到cpu的内存读取优化, 导致golang的内存分配一般以8的倍数进行分配. 这表明内存地址一定是8的倍数, 其低三位一定是0, 并没有参考意义所以需要过滤掉.我们可以写一个小demo进行证实:
type Struct struct {
a int
b byte
c [100]byte
}
func main() {
var a Struct
b := new(Struct)
fmt.Println(uintptr(unsafe.Pointer(&a))%8, uintptr(unsafe.Pointer(&b))%8)
}
a,b 分别是栈分配和堆分配, 它们的地址对8取余, 一定是0
到这里我们获取到了semaphore所在的 Treap, 接下来的流程是进入等待队列进行休眠:
- 加锁
- 先nwait+1, 再尝试抢资源(二次确认), 如果抢到资源, nwait-1, 退出
- 进入等待队列
- 休眠
由于Treap不是线程安全的数据结构, 这里需要加锁, 防止两个系统线程同时进行写操作.
由于前面的加锁流程, 可能执行到这里时已经被锁阻塞了一段时间了, 此时可能竞争没那么激烈, 所以再次尝试抢夺资源.
这里的一个困惑点是为什么不先尝试抢资源, 再nwait+1, 这里是刻意为之的. 这里也留个悬念, 跟release流程有关.
接下来我们看看root.queue(addr, s, lifo) 方法:
func (root *semaRoot) queue(addr *uint32, s *sudog, lifo bool)
该方法传递三个参数, semaphore(addr), 需要保存的sudog, 以及决定sudog位置的lifo
作用: 将sudog放到对应semaphore的等待队列中, lifo为true, 放到队列头, 为false, 放到队列尾
源码:
func (root *semaRoot) queue(addr *uint32, s *sudog, lifo bool) {
// 初始化sudog
s.g = getg()
s.elem = unsafe.Pointer(addr)
s.next = nil
s.prev = nil
var last *sudog
pt := &root.treap
for t := *pt; t != nil; t = *pt {
if t.elem == unsafe.Pointer(addr) { // 找到对应的semaphore
if lifo {
// s将插入队头, 所以s继承t的队头信息
*pt = s
s.ticket = t.ticket
s.acquiretime = t.acquiretime
s.parent = t.parent
s.prev = t.prev
s.next = t.next
if s.prev != nil {
s.prev.parent = s
}
if s.next != nil {
s.next.parent = s
}
// waitlink为等待队列下一个节点, s的下一节点是t(前队头)
s.waitlink = t
s.waittail = t.waittail // s继承t的队尾信息
if s.waittail == nil {
s.waittail = t
}
// 清除t关于treap的信息(s作为队头, t就不需要存储这些信息了)
t.parent = nil
t.prev = nil
t.next = nil
t.waittail = nil
} else {
// 添加到队尾
if t.waittail == nil {
t.waitlink = s
} else {
t.waittail.waitlink = s
}
t.waittail = s
s.waitlink = nil
}
return
}
last = t
if uintptr(unsafe.Pointer(addr)) < uintptr(t.elem) {
pt = &t.prev
} else {
pt = &t.next
} // 遍历treap
}
// treap插入新节点, 并调整树
for s.parent != nil && s.parent.ticket > s.ticket {
if s.parent.prev == s {
root.rotateRight(s.parent)
} else {
if s.parent.next != s {
panic("semaRoot queue")
}
root.rotateLeft(s.parent)
}
}
}
这个方法乍一看很吓人, 其实做的事情不复杂, 我们把它来拆开来讨论:
// 初始化 sudog
... ...
var last *sudog
pt := &root.treap
for t := *pt; t != nil; t = *pt {
if t.elem == unsafe.Pointer(addr) {
// 如果发现semaphoire执行下面逻辑
if lifo {
// 执行插入队列队头操作
... ...
} else {
// 执行插入队列队尾操作
... ...
}
return
}
last = t
if uintptr(unsafe.Pointer(addr)) < uintptr(t.elem) { // 比较key大小
pt = &t.prev // 小于当前treap节点, 遍历左子树
} else {
pt = &t.next // 大于当前treap节点, 遍历右子树
}
}
// 没找到treap节点, 插入新的节点
... ...
总体流程大概是遍历treap树, 如果找到对应的节点则更新等待队列, 如果没找到, 在treap树上插入新的节点
插入对头操作:
*pt = s // 队列队头置为s
// s 继承队头(t)的treap的节点信息
s.ticket = t.ticket
s.parent = t.parent
s.prev = t.prev
s.next = t.next
if s.prev != nil {
s.prev.parent = s
}
if s.next != nil {
s.next.parent = s
}
// s队列的下一个节点为t
s.waitlink = t
s.waittail = t.waittail
if s.waittail == nil {
s.waittail = t
}
// t已经不是队头了, 清理t的节点信息
t.parent = nil
t.prev = nil
t.next = nil
t.waittail = nil
这里可能有些人会懵, ticket, parent, prev, next, waitlink, waittail都是些什么.我们重新回顾一下, semaRoot的结构本质是一棵treap树, key是semaphore, value是一个sudog的等待队列, 而这里treap树的节点之间的连结复用了sudog的parent,prev,next,ticket成员, 而等待队列则复用了sudog的waitlink, waittail成员
以下成员只有队列的队头存储, 用于treap树的生成
- parent: 指向treap节点的父节点
- prev: 指向treap节点的左子节点
- next: 指向treap节点的右子节点
- ticket: treap节点的优先级值, 此值是个随机数
以下成员用于等待队列结构的生成
- waitlink: 指向当前节点的下一个节点, 此成员用于队列成员之间的连接
- waittail: 指向尾部节点, 此成员只有队头节点有值
最后就是在treap树中没有对应的semaphore节点, 我们需要执行插入操作:
s.ticket = cheaprand() | 1
s.parent = last
*pt = s
for s.parent != nil && s.parent.ticket > s.ticket {
if s.parent.prev == s {
root.rotateRight(s.parent)
} else {
if s.parent.next != s {
panic("semaRoot queue")
}
root.rotateLeft(s.parent)
}
}
这里跟treap的插入操作一样, 先生成一个随机优先级值, 然后根据优先级从下到上调整树 这里有个操作可能会让人困惑:
s.ticket = cheaprand() | 1
由于s.ticket == 0, 有其他的特殊意义, 所以不能让它为0.
左旋, 右旋操作跟part1章节的介绍基本一致, 不做赘述
下面就是被唤醒后的逻辑
if s.ticket != 0 || cansemacquire(addr) {
break
}
这里被唤醒后有两个条件跳出抢资源循环:
- 在release流程中, 被唤醒后被分配了资源(s.ticket != 0)
- 没有被分配资源, 尝试抢资源并抢到资源(cansemacquire = true)
这里就是之前说的ticket == 0的意义. 在sema的资源分配中存在两种模式, 当mutex处于饥饿状态时, 使用handoff模式, 否则就正常模式.
- 正常模式下(s.ticket == 0), 所有的g唤醒后需要抢占资源
- handloff模式下, g按队列顺序依次唤醒, 依序分配资源, 有序执行
这时我们就可以讨论前面遗留下来的问题了: 要是存在一个g在竞争中一直抢不到资源怎么办?
在handoff模式下将转为按序使用资源, 这样一来只要g在一点时间内一直抢不到资源就转为handoff模式, 然后g等待被分配资源就可以了. 这种模式转换将在未来记录sync.Mutex运作原理时将被提及, 这里先点到为止.
最后释放sudog资源, semaacquire流程结束
semarelease
前面介绍的semaacquire是使用资源的操作(计数器-1), 那么这个方法是反着来的, 就是释放资源的操作.
源码:
func semrelease1(addr *uint32, handoff bool, skipframes int) {
root := semtable.rootFor(addr) // 获取对应的treap树
atomic.Xadd(addr, 1) // 释放资源, 计数器+1
// ---------- fast path --------------
if root.nwait.Load() == 0 {
return
}
// ---------- slow path --------------
lockWithRank(&root.lock, lockRankRoot)
if root.nwait.Load() == 0 {
unlock(&root.lock)
return
}
s, t0, tailtime := root.dequeue(addr) //出队
if s != nil {
root.nwait.Add(-1)
}
unlock(&root.lock)
if s != nil {
if s.ticket != 0 {
throw("corrupted semaphore ticket")
}
if handoff && cansemacquire(addr) {
s.ticket = 1
}
readyWithTime(s, 5+skipframes) // 唤醒
if s.ticket == 1 && getg().m.locks == 0 {
goyield()
}
}
}
定义: func semarelease1(addr *uint32, handoff bool, skipframes int)
addr: semaphore
handoff: 是否handoff模式
skipframes: 该参数服务于唤醒方法readyWithTime, 用于记录追踪日志
首先是释放资源, 获取treap的root.
接着是fast path:
if root.nwait.Load() == 0 {
return
}
这里判断semaroot中是否有休眠中的g, 0表示没有需要唤醒的g.
之前我们在讨论semaacquire时谈到一个流程:
root.nwait.Add(1)
if cansemacquire(addr){
root.nwait.Add(-1)
return
}
提到一个问题: 为什么不先nwait+1, 在尝试抢夺资源呢?
这里结合fastpath的代码进行讨论:
我假设这里semaacquire的代码是这样的:
if cansemacquire(addr){
return
}
nwait.Add(1)
假设有两个G, G1获取资源, G2释放资源, 且执行顺序:
G1: cansemacquire()-> False
G2: addr+1
G2: nwait == 0 -> True return
G1: nwait+1
上述流程中, 因为release的fast path逻辑, 导致G2提前结束, 可是G1因为没有获取到资源进入休眠状态, 等待g2唤醒, 就算后续有其他g释放资源唤醒g1, 可是这意味着有另外一个g陷入永久休眠的可能, 破坏了两边的一致性.
以目前的代码即先nwait+1再cansemaacquire来讨论, 出现上面情况的原因是出现G2先提前退出, G1再nwait+1, 而现在G1的获取资源步骤放到nwait+1的后面执行, 也就是说G1能成功获取到资源直接退出, 不会休眠.满足一致性的要求.
slow path:
加锁 -> nwait == 0?(二次确认) -> 出队 -> 解锁 -> 唤醒
出队源码(dequeue):
func (root *semaRoot) dequeue(addr *uint32) (found *sudog, now, tailtime int64) {
ps := &root.treap
s := *ps
for ; s != nil; s = *ps {
if s.elem == unsafe.Pointer(addr) {
goto Found
}
if uintptr(unsafe.Pointer(addr)) < uintptr(s.elem) {
ps = &s.prev
} else {
ps = &s.next
}
}
return nil, 0, 0
Found:
if t := s.waitlink; t != nil {
*ps = t
t.ticket = s.ticket
t.parent = s.parent
t.prev = s.prev
if t.prev != nil {
t.prev.parent = t
}
t.next = s.next
if t.next != nil {
t.next.parent = t
}
if t.waitlink != nil {
t.waittail = s.waittail
} else {
t.waittail = nil
}
s.waittail.acquiretime = now
s.waitlink = nil
s.waittail = nil
} else {
for s.next != nil || s.prev != nil {
if s.next == nil || s.prev != nil && s.prev.ticket < s.next.ticket {
root.rotateRight(s)
} else {
root.rotateLeft(s)
}
}
if s.parent != nil {
if s.parent.prev == s {
s.parent.prev = nil
} else {
s.parent.next = nil
}
} else {
root.treap = nil
}
}
s.parent = nil
s.elem = nil
s.next = nil
s.prev = nil
s.ticket = 0
return s, now, tailtime
}
方法定义: func (root *semaRoot) dequeue(addr *uint32) (found *sudog, now, tailtime int64)
addr: semaphore
found:是否发现一个休眠的g
now, tailtime: 用于记录追踪日志, 跟主逻辑无关
这里按照之前分析queue方法的思路来分析这个方法的逻辑:
ps := &root.treap
s := *ps
// ----------- 尝试发现对应的semaphore
for ; s != nil; s = *ps {
if s.elem == unsafe.Pointer(addr) {
goto Found
}
if uintptr(unsafe.Pointer(addr)) < uintptr(s.elem) {
ps = &s.prev
} else {
ps = &s.next
}
}
return nil, 0, 0
Found:
// 发现semaphore
if t := s.waitlink; t != nil {
// 等待队列长度超过1个
... ...
} else {
// 等待队列只有一个sudog
... ...
}
// 清除s的treap, waitlink相关信息
s.parent = nil
s.elem = nil
s.next = nil
s.prev = nil
s.ticket = 0
return s, now, tailtime
通过以上简化的代码, 逻辑比较简单, 在treap树中尝试找到对应的semaphore, 找到了就将它拿出来返回, 找不到直接返回nil
这里如果发现了, 有两个处理分支, 等待队列只有一个节点, 等待队列有多个节点.
分支1:
t = s.waitlink
*ps = t
t.ticket = s.ticket
t.parent = s.parent
t.prev = s.prev
if t.prev != nil {
t.prev.parent = t
}
t.next = s.next
if t.next != nil {
t.next.parent = t
}
if t.waitlink != nil {
t.waittail = s.waittail
} else {
t.waittail = nil
}
s.waitlink = nil
s.waittail = nil
分支1将队列的队头节点拿出来, 队头的下个节点继承队头的treap, waitlink信息
分支2:
// treap 节点下沉操作
for s.next != nil || s.prev != nil {
if s.next == nil || s.prev != nil && s.prev.ticket < s.next.ticket {
root.rotateRight(s)
} else {
root.rotateLeft(s)
}
}
// 删除treap节点
if s.parent != nil {
if s.parent.prev == s {
s.parent.prev = nil
} else {
s.parent.next = nil
}
} else {
root.treap = nil
}
这里由于只有一个节点, 出队后该semaphore的队列为空, 需要删除. 故这里执行的是treap的删除节点操作. 先将节点下移, 变成叶子节点, 再从树中删除, 返回sudog
最后清除掉树和队列的相关信息, 返回
回到semarelease方法:
if s != nil {
root.nwait.Add(-1)
}
这里s是有可能的nil的. 因为nwait是代表root里面有多少休眠的g, 这就代表可能当前semaphore是没有休眠的g的.
最后就是找到g后, 进行唤醒操作:
if s.ticket != 0 {
throw("corrupted semaphore ticket")
}
if handoff && cansemacquire(addr) {
s.ticket = 1
}
readyWithTime(s, 5+skipframes)
if s.ticket == 1 && getg().m.locks == 0 {
goyield()
}
这里对s.ticket进行防御性编码, 指示此时它就应该是0, 从代码可以得知, s.ticket为0表示该g并没有抢到资源
如果为handoff模式, 通俗来说就是排队模式, 则尝试分配资源, 分配到了s.ticket != 0, 这也跟前面semaacquire逻辑对上了.
唤醒出队的g. m.locks == 0, 表示m(系统线程)此时可以被让出去跑唤醒的g. 如果在handoff模式下抢到资源, 且当前m可以被让出来, 则让出当前m, 让当前m去跑唤醒的g, 也就是goyield()方法做的事情. 而这个流程也符合sync.Mutex饥饿状态下的运行模式. 而goyield方法的实现涉及到gmp的调度, 跟本期主题没多大关系, 不多赘述.
至此讨论结束.
后记
目前runtime.sema包只用于实现sync.Mutex和sync.RWMutex, part1 作为part2的前置章节讲述了treap算法的原理. 而本章节则作为sync.Mutex的前置章节, 讲述了休眠的g的最终去向.
记录于 2026.4.21