6.1 上下文Context
Context
是Golang中非常有趣的设计,它与Go语言中的并发编程有着比较密切的关系,在其他语言中我们很难见到类似Context
的东西,它不仅能够用来设置截止日期、同步“信号”,还能用来传递特定请求相关的值。
这一节就会介绍Go语言中这个非常常见的Context
接口,我们将从这里开始了解Go语言并发编程的设计理念以及实现原理。
概述
Go语言中的每一个请求都是通过一个单独的Goroutine进行处理的,HTTP/RPC请求的处理器往往都会启动新的Goroutine访问数据库和RPC服务,我们可能会创建多个Goroutine来处理一次请求,而Context
的主要作用就是在不同的Goroutine之间同步特定请求的数据、取消信号以及处理请求的截止日期。
每一个Context
都会从最顶层的Goroutine一层一层传递到最下层,这也是Golang中上下文最常见的使用方式,如果没有Context
,当上层执行的操作出现错误时,下层不会收到错误而是会继续执行下去。
当最上层的Goroutine因为某些原因执行失败时,下两层的Goroutine由于没有接收到这个信号所以会继续工作;但是当我们正确使用Context
时,就可以在下层及时停掉无用的工作减少额外资源的消耗:
这其实就是Golang中上下文的最大作用,在不同Goroutine之间对信号进行同步避免对计算资源的浪费,与此同时Context
还能携带以请求为作用域的键值对信息。
接口
Context
其实是Go语言context
包对外暴露的接口,该接口定义了四个需要实现的方法,其中包括:
1.Deadline
方法需要返回当前Context
被取消的时间,也就是完成工作的截止日期;
2.Done
方法需要返回一个Channel,这个Channel会在当前工作完成或者上下文被取消后关闭,多次调用Done
方法会返回同一个Channel;
3.Err
方法会返回当前Context
结束的原因,它只会在Done
返回的Channel被关闭时才会返回非空值;
(1)如果当前Context
被取消就会返回Canceled
错误;
(2)如果当前Context
超时就会返回DeadlineExceeded
错误;
4.Value
方法会从Context
中返回键的对应值,对于同一个上下文来说,多次调用Value
并传入相同的Key
会返回相同的结果,这个功能可以用来传递特定请求的数据;
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
context
包中提供的Background
、TODO
、WithDeadline
等方法会返回实现该接口的私有结构体,我们会在后面的小节中介绍它们的工作原理。
示例
我们可以通过一个例子简单了解一下Context
是如何对信号进行同步的,在这段代码中我们创建了一个过期时间为1s
的上下文,并将上下文传入handle
方法,该方法会使用500ms
处理该“请求”:
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
go.handle(ctx, 500*time.Milliisecond)
select {
case <-ctx.Done():
fmt.Println("main", ctx.Err())
}
}
func handle(ctx context.Context, duration time.Duration) {
select {
case <-ctx.Done():
fmt.Println("handle", ctx.Err())
case <-time.After(duration)
fmt.Println("process request with", duration
}
}
所以我们有足够的时间处理该“请求”,而运行上述代码时会打印出如下所示内容:
“请求”被Goroutine正常处理没有进入handle函数中超时的select
分支,但是在main
函数中的select
却会等待Context
的超时最终打印出main context deadline exceeded
,如果我们将处理“请求”的时间改成1500ms
,当前处理的过程就会因Context
到截止日期而被中止:
我运行时,经常只打印main中的超时,因为main结束时,其他Goroutine也都会被终止,handle Goroutine还没有机会打印就被终止了。
两个函数都会因为ctx.Done()
返回的管道被关闭而中止,也就是上下文超时。
相信这两个例子能够帮助各位读者了解Context
的使用方法和基本工作原理——多个Goroutine同时订阅ctx.Done()
管道中的消息,一旦接收到取消信号就停止当前正在执行的工作并提前返回。
实现原理
Context
相关的源代码都在context.go这个文件中,在这一节中我们会从Go语言的源码出发介绍Context
的实现原理,包括如何在多个Goroutine之间同步信号、为请求设置截止日期并传递参数和信息。
默认上下文
在context
包中,最常使用的是context.Background
和context.TODO
两个方法,这两个方法最终都会返回一个预先处理好的私有变量background
和todo
:
func Background() Context {
return background
}
func TODO() Context {
return todo
}
这两个变量是在包初始化时就被创建好的,它们都是通过new(emptyCtx)
表达式初始化的指向私有结构体emptyCtx
的指针,这也是包中最简单也是最常用的类型:
type emptyCtx int
func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}
func (*emptyCtx) Done() <-chan struct{} {
return nil
}
func (*emptyCtx) Err() error {
return nil
}
func (*emptyCtx) Value(key interface{}) interface{} {
return nil
}
它对Context
接口方法的实现也都非常简单,无论何时调用都会返回nil
或空值,并没有任何特殊功能,Background
和TODO
方法在某种层面上看其实也只是互为别名,两者没有太大的差别,不过context.Background()
是上下文中最顶层的默认值,所有其他的上下文都应该从context.Background()
演化出来(作者此处用词为“演化”,其实就是将空上下文Background作为参数调用其他context包中的函数,如下图中的函数,从而赋予这个空上下文更多的功能和信息)。
我们应该只在不确定时使用context.TODO()
(作者此处用词为“不确定”,具体指的应该是程序的上下文传递策略还未确定,常被用作占位符,标记代码,从而作为一种提示,表明开发者需要回过头来决定如何处理上下文),在多数情况下如果函数没有上下文作为入参,我们往往都会使用context.Background()
作为起始的Context
向下传递。
取消信号
WithCancel
方法能够从Context
中创建出一个新的子上下文,同时还会返回用于取消该上下文的函数,也就是CancelFunc
,我们直接从WithCancel
函数的实现来看它到底做了什么:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
// propagateCancel函数负责取消信号的传播,它确保从父上下文传播取消信号到新创建的子上下文
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
newCancelCtx
是包中的私有方法,它将传入的父上下文到私有结构体cancelCtx{Context: parent}
中,cancelCtx
就是当前函数最终会返回的结构体类型,我们在详细了解它是如何实现接口前,先来了解一下用于传递取消信号的propagateCancel
函数:
func propagateCancel(parent Context, child canceler) {
// 如果父上下文永远不会被取消,则直接返回,因为没有取消信号需要传播
if parent.Done() == nil {
return // parent is never canceled
}
// 尝试获取父上下文的取消控制结构,如果成功,说明父上下文是可取消的
if p, ok := parentCancelCtx(parent); ok {
// 加互斥锁同步对互斥状态的访问,确保并发安全
p.mu.Lock()
// 如果父上下文已被取消
if p.err != nil {
// 立刻取消子上下文
child.cancel(false, p.err)
} else {
// 如果父上下文中没有初始化children字段,则初始化它为一个map
if p.children == nil {
p.children = make(map[canceler]struct{})
}
// 将子上下文加入父上下文的children字段的map中,后续父上下文取消时会取消所有注册的子上下文
p.children[child] = struct{}{}
}
p.mu.Unlock()
// 如果父上下文不是可取消的结构(例如父上下文是通过context.WithValue创建的,不直接支持取消)
} else {
// 启动一个Goroutine监听父上下文的取消信号
go func() {
select {
// 如果父上下文被取消,则取消子上下文
case <-parent.Done():
child.cancel(false, parent.Err())
// 如果子上下文被取消,则退出当前Goroutine
case <-child.Done():
}
}()
}
}
该函数总共处理与父上下文相关的三种不同情况:
1.当parent.Done() == nil
,也就是parent
不会触发取消事件时,当前函数直接返回;
2.当parent
是可以取消的上下文时,就会判断parent
是否已经触发了取消信号;
(1)如果已经被取消,当前child
会被立刻取消;
(2)如果没有被取消,当前child
会被加入parent
的children
列表中,等待parent
释放取消信号;
3.遇到其他情况会开启一个新的Goroutine,同时监听parent.Done()
和child.Done()
两个管道,并在前者结束后立刻调用child.cancel
取消子上下文;
propagateCancel函数的主要作用是在parent
和child
之间同步取消和结束信号,保证在parent
被取消时,child
也会收到对应信号,不会发生状态不一致的问题。
cancelCtx
实现的几个接口方法其实没有太多值得介绍的地方,该结构体最重要的方法是cancel
方法,这个方法会关闭上下文的管道并向所有子上下文发送取消信号:
// removeFromParent参数指定是否需要从父上下文中移除此上下文
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
// 加锁访问共享资源
c.mu.Lock()
// 是否已经设置了错误信息,如果是,说明此上下文已经被取消或完成
if c.err != nil {
c.mu.Unlock()
return
}
// 设置错误信息
c.err = err
// 如果通道未被初始化
if c.done == nil {
// 将其设置为已关闭通道
c.done = closedchan
} else {
// 关闭该通道,以通知所有监听该通道的Goroutine
close(c.done)
}
// 遍历所有子上下文
for child := range c.children {
// 递归取消所有子上下文,并指示不从父上下文中移除子上下文
child.cancel(false, err)
}
// 清空子上下文的引用
c.children = nil
c.mu.Unlock()
// 如果需要从父上下文中移除当前子上下文c
if removeFromParent {
// 调用removeChild从父上下文中移除子上下文c
removeChild(c.Context, c)
}
}
除了WithCancel
外,context
包中的另外两个函数WithDeadline
和WithTimeout
也都能创建可以被取消的上下文,WithTimeout
只是context
包为我们提供的便利方法,能让我们更方便地创建timerCtx
:
// 创建一个上下文,这个上下文将在参数timeout时间后超时
// 返回值是创建的上下文和取消函数,当调用取消函数时,所有与该上下文关联的操作都应被取消
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
// 创建一个当达到截止时间参数d会取消的Context
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 如果父上下文已经有截止日期,且父上下文的截止日期更早
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// 创建一个可取消的上下文,等待父上下文截止即可
return WithCancel(parent)
}
// 创建一个timerCtx,继承了cancelContext,并添加了一个定时器来处理超时
c := &timerCtx {
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// 将取消信号从父上下文传递到子上下文
propagateCancel(parent, c)
// 计算举例截止时间还有多久
dur := time.Until(d)
// 如果截止时间已过
if dur <= 0 {
// 立即取消上下文
c.cancel(true, DeadlineExceeded) // deadline has already passed
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
// 如果上下文尚未发生错误
if c.err == nil {
// 设置一个定时器,到达时间时自动调用c.cancel
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
WithDeadline
方法在创建timerCtx
上下文的过程中,判断了上下文的截止日期和当前日期,并通过time.AfterFunc
方法创建了定时器,当时间超过了截止日期后就会调用cancel
方法同步取消信号。
timerCtx
结构体内部嵌入了一个cancelCtx
结构体,也“继承”了相关的变量和方法,除此之外,持有的定时器timer
和截止时间deadline
实现了定时取消这一功能:
type timerCtx struct {
cancelCtx
// 对timer的访问需要通过cancelCtx.mu的互斥锁进行保护
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
// 返回上下文的截止时间,以及截止时间是否有效
func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}
// 取消timerCtx上下文
func (c *timerCtx) cancel(removeFromParent bool, err error) {
// 调用基类取消方法
c.cancelCtx.cancel(false, err)
// 如果需要父上下文移除当前正在取消的上下文
if removeFromParent {
removeChild(c.cancelCtx.Context, c)
}
// 停止timer
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
cancel
方法不仅调用了内部嵌入的cancelCtx.cancel
,还会停止持有的定时器减少不必要的资源浪费。
传值方法
在最后我们需要了解一下如果使用上下文传值,context
包中的WithValue
函数能从父上下文中创建一个子上下文,传值的子上下文使用私有结构体valueCtx
类型:
func WithValue(parent Context, key, val interface{}) Context {
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}
valueCtx
函数会将除了Value
外的Err
、Deadline
等方法代理到父上下文中(即直接使用父上下文的Err
、Deadline
方法),只会处理(重写)Value
方法的调用,然而每个valueCtx
内部也并没有存储键值对的哈希,而是只包含一个键值对:
type valueCtx struct {
Context
key, val interface{}
}
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
如果当前valueCtx
中存储的键与Value
方法中传入的不匹配,就会从父上下文中查找该键对应的值直到在某个父上下文中返回nil
或者查找到对应的值。
总结
Go语言中的Context
的主要作用还是在多个Goroutine或模块之间同步取消信号或截止日期,用于减少对资源的消耗和长时间占用,避免浪费资源,虽然传值也是它的功能之一,但是这个功能我们还是很少用到。
在真正使用传值功能时我们应该非常谨慎,不能将请求的所有参数都使用Context
进行传递,这是一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求ID。
6.2 同步原语与锁
当提到并发编程、多线程编程时,我们往往都离不开“锁”这一概念,Go语言作为一个原生支持用户态进程Goroutine的语言,也一定会为开发者提供这一功能,锁的主要作用就是保证多个线程或Goroutine在访问同一片内存时不会出现混乱的问题,锁其实是一种并发编程中的同步原语(Synchronization Primitives)。
在这一节中我们就会介绍Go语言中常见的同步原语Mutex
、RWMutex
、WaitGroup
、Once
、Cond
以及扩展原语ErrGroup
、Semaphore
、SingleFlight
的实现原理,同时也会涉及互斥锁、信号量等并发编程中的常见概念。
基本原语
Go语言在sync包中提供了用于同步的一些基本原语,包括常见的互斥锁Mutex
与读写互斥锁RWMutex
以及Once
、WaitGroup
。
这些基本原语的主要作用是提供较为基础的同步功能,我们应该使用Channel和通信来实现更加高级的同步机制,我们在这一节中不会介绍标准库中全部原语,而是会介绍其中比较常见的Mutex
、RWMutex
、Once
、WaitGroup
、Cond
,我们并不会涉及剩下两个用于存取数据的结构体Map
和Pool
。
Mutex
Go语言中的互斥锁在sync
包中,它由两个字段state
和sema
组成,state
表示当前互斥锁的状态。而sema
是真正用于控制锁状态的信号量,这两个加起来只占8个字节空间的结构体就表示了Go语言中的互斥锁。
type Mutex struct {
state int32
sema uint32
}
状态
互斥锁的状态是用int32
来表示的,但是锁的状态并不是互斥的,它的最低三位分别表示mutexLocked
、mutexWoken
、mutexStarving
,剩下的位置用于表示当前有多少个Goroutine等待互斥锁被释放:
互斥锁在被创建出来时,所有的状态位的默认值都是0
,当互斥锁被锁定时mutexLocked
就会被置为1
、当互斥锁正常模式下被唤醒时mutexWoken
就会被置成1
、mutexStarving
用于表示当前的互斥锁进入了饥饿状态,剩下的几位是在当前互斥锁上等待的Goroutine个数。
饥饿模式
在了解具体的加锁和解锁过程前,我们需要先简单了解一下Mutex
在使用过程中可能会进入的饥饿模式,饥饿模式是在Go语言1.9版本引入的特性,它的主要功能是保证互斥锁获取的“公平性”(Fairness)。
互斥锁可以同时处于两种不同的模式(作者说一个互斥锁可以同时处于两种模式,实际上在某一时刻,锁只能是两种模式之一,由上图中的starving部分控制,但在锁的生命周期内,两种模式可以互相切换),也就是正常模式和饥饿模式,在正常模式下,所有锁的等待者都会按照先进先出的顺序获取锁,但是如果一个刚刚被唤起的Goroutine遇到了新的Goroutine也调用了Lock
方法时,大概率会获取不到锁(作者这块描述过于粗糙,且没有说明引入饥饿模式这一优化的原因,下面会用官方issue来说明一下为什么会有饥饿模式),为了减少这种情况的出现,防止Goroutine被“饿死”,一旦Goroutine超过1ms没有获取到锁,它就会将当前互斥锁切换到饥饿模式。
在有关Mutex的饥饿模式官方issue中,给出了这个例子来说明正常模式的不公平:
// shared state
done := make(chan bool, 1)
var mu sync.Mutex
// goroutine 1
go func() {
for {
select {
case <-done:
return
default:
mu.Lock()
time.Sleep(100 * time.Microsecond)
mu.Unlock()
}
}
}()
// goroutine 2
for i := 0; i < n; i++ {
time.Sleep(100 * time.Microsecond)
mu.Lock()
mu.Unlock()
}
done <- 1
如果在1.8及以前的版本运行以上程序,会发现Goroutine1获取锁的次数要比Goroutine2多非常多,程序可能会运行几十上百秒Goroutine2才能获取一次锁,原因在于,Goroutine1长期占有锁,而Goroutine2只占有锁非常短的时间,虽然Goroutine1和Goroutine2每次循环的周期大致相同,但Goroutine1释放锁时,会让Goroutine2运行,但Goroutine2从睡眠到运行需要一段时间,即程序并不会立即将Goroutine2唤醒,从而Goroutine1在Goroutine2被唤醒之前,释放后又重新占有了锁(虽然Goroutine2此时可能已经在加锁队列了,但Goroutine1加锁时发现锁未被锁定,所以就忽略了在加锁队列中的Goroutine2,直接加了锁)。
在饥饿模式中,互斥锁会被直接交给等待队列最前面的Goroutine(上例中的Goroutine2),新的Goroutine(上例中的Goroutine1)在这时不能获取锁,也不会进入自旋状态,它们只会在队列的末尾等待,如果一个Goroutine获得了互斥锁并且它是队列中最末尾的协程或者它等待的时间少于1ms,那么当前的互斥锁就会被切换回正常模式。
相比于饥饿模式,正常模式下的互斥锁能够提供更好的性能,饥饿模式的主要作用是避免一些Goroutine由于陷入等待无法获取锁而造成较高的尾延时(尾延时指响应时间较慢的请求,如响应时间位于99百分位或更慢的请求,这些少数非常慢的请求,也会极大影响系统整体性能和用户体验,这里指某些长时间等待锁的情况),这也是对Mutex
的一个优化。
加锁
互斥锁Mutex
的加锁是靠Lock
方法完成的,最新的Go语言源代码中已经将Lock
方法进行了简化,方法的主干只保留了最常见、简单且快速的情况;当锁的状态是0
时直接将mutexLocked
位置成1
:
func (m *Mutex) Lock() {
// 原子地改变锁状态,原子的比较锁是否加锁(0),并且如果没有就将其替换为加锁(mutexLocked)
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
// 如果原子操作失败,即其他Goroutine已加锁,就调用lockSlow处理锁争用
m.lockSlow()
}
但是当Lock
方法被调用时Mutex
的状态不是0时就会进入lockSlow
方法尝试通过自旋或其他方法等待锁的释放并获取互斥锁,该方法的主体是一个非常大的for
循环,我们会将该方法分成几个部分介绍获取锁的过程:
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
// 如果锁处于被锁定状态且不处于饥饿状态 && 允许进入自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 如果当前Goroutine未被唤醒 && 锁处于未被唤醒状态 && 有其他等待者 &&
// 成功通过原子操作CAS设置锁的唤醒状态成功
// 锁处于唤醒状态说明有Goroutine尝试获取锁,唤醒状态避免了唤醒风暴
// 即多个Goroutine同时等待一个锁,如果同时唤醒锁,会导致CPU激增和性能下降
// 通过锁的唤醒状态,系统可以控制唤醒锁的Goroutine数量,从而减少资源竞争
if !awoke && old&mutexWoken == 0 && old>>mutexWaterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
// 执行自旋,等待锁可用
runtime_doSpin()
// 增加迭代次数
iter++
// 再次获取锁状态,为下次循环做准备
old = m.state
continue
}
在这段方法的第一部分会判断当前方法能否进入自旋来等待锁的释放,自旋(Spinning)其实是在多线程同步的过程中使用的一种机制,当前的进程在自旋的过程中会一直保持CPU的占用,持续检查某个条件是否为真,在多核CPU上,自旋的优点是避免了Goroutine的切换,所以如果使用恰当会对性能带来非常大的增益。
在Go语言的Mutex
互斥锁中,只有在普通模式下才可能进入自旋,除了模式的限制之外,runtime_canSpin
方法中会判断当前方法是否可以进入自旋,进入自旋的条件非常苛刻:
1.运行在多CPU机器上;
2.当前Goroutine为了获取该锁进入自旋的次数小于四次;
3.当前机器上至少存在一个正在运行的处理器P
并且处理的运行队列是空的;
一旦当前Goroutine能够进入自旋就会调用runtime_doSpin
,它最终调用汇编语言编写的方法procyield
并执行指定次数的PAUSE
指令,PAUSE
指令什么都不会做,但是会消耗CPU时间,每次自旋都会调用30
次PAUSE
,下面是该方法在386架构的机器上的实现:
TEXT runtime.procyield(SB),NOSPLIT,$0-0
// 将帧指针FP偏移cycles字节位置的值(循环次数)复制到寄存器AX中
MOVL cycles+0(FP), AX
again:
PAUSE
SUBL $1, AX
// jump if not zero,即结果(上一步的减法)非0就跳转到again处
JNZ again
RET
处理了自旋相关的特殊逻辑之后,互斥锁接下来就根据上下文计算当前互斥锁最新的状态了,几个不同的条件分别会更新state
中存储的不同信息mutexLocked
、mutexStarving
、mutexWoken
、mutexWaiterShift
:
new := old
// 如果锁不处于饥饿状态,则将其设为锁定状态,根据上文,要么锁已被锁定,要么我们要锁定它
// 饥饿状态下由于要注重锁获取的公平性,要从锁等待队列中取头部Goroutine加锁
// 非饥饿状态下如果没加锁,忽略加锁等待队列,直接加锁即可
if old&mutexStarving == 0 {
new |= mutexLocked
}
// 如果互斥锁已被锁定或处于饥饿状态,增加等待者计数
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
// 如果当前Goroutine处于饥饿状态 && 锁已被加锁
// 说明锁应该考虑当前饥饿的Goroutine,将锁设为饥饿状态
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
// 如果当前线程已被唤醒,清除锁的唤醒标志,以允许其他Goroutine唤醒锁
if awoke {
new &^= mutexWoken
}
计算了新的互斥锁状态之后,我们就会使用atomic
包提供的CAS函数修改互斥锁的状态,如果当前的互斥锁已经处于饥饿和锁定的状态,就会跳过当前步骤,调用runtime_SemacquireMutex
方法:
// 如果成功通过CAS操作锁定互斥锁
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 如果锁已被锁定且不处于饥饿状态,说明锁定成功
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
// 是否使用Lifo队列(即栈),如果waitStartTime非0,说明已经在等待锁
queueLifo := waitStartTime != 0
// 记录开始等待锁的时间
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 等待锁使用的底层信号量,此函数返回说明已解锁
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 更新饥饿状态,如果等待时间超出一定阈值,就进入饥饿状态
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
// 更新old为最新的状态,为下次循环准备
old = m.state
// 如果锁处于饥饿状态
if old&mutexStarving != 0 {
// 根据条件调整锁的state,delta此时表示已加锁且等待者数量为当前的值的负数
delta := int32(mutexLocked - 1<<mutexWaiterShift)
// 如果当前线程不处于饥饿状态 || 锁等待者只有本Goroutine
if !starving || old>>mutexWaiterShift == 1 {
// 调整delta,移除饥饿状态标志,此时delta表示已加锁、负值的等待者数量、非饥饿
delta -= mutexStarving
}
// 将delta加到锁状态上,此时锁状态为已加锁、0等待者
// 饥饿状态需要看delta是否减了mutexStarving,如果减了,则表示移除饥饿状态
atomic.AddInt32(&m.state, delta)
break
}
// 设置当前Goroutine已被唤醒
awoke = true
iter = 0
// 如果锁定互斥锁失败,更新old为最新状态,准备进入下一次循环
} else {
old = m.state
}
}
}
runtime_SemacquireMutex
方法的主要作用就是通过Mutex
中使用的信号量保证资源不会被两个Goroutine获取,从这里我们就能看出Mutex
其实就是对更底层的信号量进行封装,对外提供更加易用的API,runtime_SemacquireMutex
会在方法中调用goparkunlock
将当前Goroutine陷入休眠等待信号量可以被获取。
一旦当前Goroutine可以获取信号量,就证明互斥锁已经被解锁,该方法就会立刻返回,Lock
方法的剩余代码也会继续执行下去了,当前互斥锁处于饥饿模式时,如果该Goroutine是队列中最后一个Goroutine或者等待锁的时间小于starvationThresholNs(1ms)
,当前Goroutine就会直接获得互斥锁并且从饥饿模式中退出并获得锁。
解锁
互斥锁的解锁过程相比之下就非常简单,Unlock
方法会直接使用atomic
包提供的AddInt32
,如果返回的新状态不等于0
就会进入unlockSlow
方法:
func (m *Mutex) Unlock() {
// atomic.AddInt32函数原子地加上一个数,并将其结果返回
new := atomic.AddInt32(&m.state, -mutexLocked)
// 如果解锁后状态为0,即一定已解锁,则不进入if
if new != 0 {
m.unlockSlow(new)
}
}
unlockSlow
方法首先会对锁的状态进行校验,如果当前互斥锁已经被解锁过就会抛出异常sync: unlock of unlocked mutex
中止当前程序,在正常情况下会根据当前互斥锁的状态是正常模式还是饥饿模式进入不同的分支:
func (m *Mutex) unlockSlow(new int32) {
// 如果已加锁
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
// 如果锁不处于饥饿模式
if new&mutexStarving == 0 {
old := new
for {
// 如果锁的等待者为0 || 锁处于加锁、唤醒、饥饿模式其中任一状态
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
// 直接退出,因为此时已解锁成功
return
}
// 减少一个锁的等待者并设置唤醒标志
new = (old - 1<<mutexWaiterShift) | mutexWoken
// 使用CAS操作原子地设置锁的新状态,如果成功
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 释放锁底层使用的信号量,唤醒其他等待的Goroutine
runtime_Semrelease(&m.sema, false, 1)
return
}
// CAS失败,即其他Goroutine已修改锁状态,则获取最新的锁状态
old = m.state
}
// 如果处于饥饿状态
} else {
// 直接释放信号量,防止其他Goroutine等待时间过长
runtime_Semrelease(&m.sema, true, 1)
}
}
如果当前互斥锁的状态是饥饿模式就会直接调用runtime_Semrelease
方法直接将当前锁交给下一个正在尝试获取锁的等待者,等待者会在被唤醒后设置mutexLocked
状态,由于此时仍处于mutexStarving
,所以新的Goroutine也无法获得锁。
在正常模式下,如果当前互斥锁不存在等待者或者最低三位表示的状态都为0
,那么当前方法就不需要唤醒其他Goroutine可以直接返回,当有Goroutine正在处于等待状态时,还是会通过runtime_Semrelease
唤醒对应的Goroutine并移交锁的所有权。
小结
通过对互斥锁Mutex
加锁和解锁过程的分析,我们能够得出以下的一些结论,它们能够帮助我们更好地理解互斥锁的工作原理,互斥锁的加锁过程比较复杂,涉及自旋、信号量、Goroutine调度等概念:
1.如果互斥锁处于初始化状态,就会直接通过置位mutexLocked
加锁;
2.如果互斥锁处于mutexLocked
并且在普通模式下工作,就会进入自旋,执行30次PAUSE
指令消耗CPU时间等待锁的释放;
3.如果当前Goroutine等待锁的时间超过了1ms
,互斥锁就会被切换到饥饿模式;
4.互斥锁在正常情况下会通过runtime_semacquireMutex
方法将调用Lock
的Goroutine切换至休眠状态,等待持有信号量的Goroutine唤醒当前协程;
5.如果当前Goroutine是互斥锁上的最后一个等待的协程或者等待的时间小于1ms
,当前Goroutine会将互斥锁切换回正常模式;
互斥锁的解锁过程相对来说就比较简单,虽然对于普通模式和饥饿模式的处理有一些不同,但是由于代码行数不多,所以逻辑清晰,也非常容易理解:
1.如果互斥锁已经被解锁,那么调用Unlock
会直接抛出异常;
2.如果互斥锁处于饥饿模式,会直接将锁的所有权交给队列中的下一个等待者,等待者会负责设置mutexLocked
标志位(这种情况相当于我们刚解锁,其他Goroutine就获取到了锁);
3.如果互斥锁处于普通模式,并且没有Goroutine等待锁的释放或者已经有被唤醒的Goroutine获得了锁就会直接返回,在其他情况下会通过runtime_Semrelease
唤醒对应的Goroutine;
RWMutex
读写互斥锁也是Go语言sync
包为我们提供的接口之一,如果一个常见的服务对资源的读写比例会非常高,即大多数的请求都是读请求,它们之间不会相互影响,那么我们为什么不能将对资源的读和写操作分离呢?这也就是RWMutex
读写互斥锁解决的问题,不限制对资源的并发读,但是读写、写写操作无法并行执行。
读写互斥锁在Go语言中的实现是RWMutex
,其中不仅包含一个互斥锁,还持有两个信号量,分别用于写等待读和读等待写:
type RWMutex struct {
w Mutex
writerSem uint32
readerSem uint32
readerCount int32
readerWait int32
}
readerCount
存储了当前正在执行读操作的数量,最后的readerWait
表示当写操作被阻塞时等待的读操作个数。
读锁
读锁的加锁非常简单,我们通过atomic.AddInt32
方法为readerCount
加一,如果该方法返回了负数说明当前有Goroutine获得了写锁,当前Goroutine就会调用runtime_SemacquireMutex
陷入休眠等待唤醒:
func (rw *RWMutex) RLock() {
// 对读者数量加1,如果增加后的结果小于0,说明有写者正在等待或持有锁
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// 休眠,直到获取到信号量rw.readerSem
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
}
如果没有写操作获取当前互斥锁,当前方法就会在readerCount
加一后返回;当Goroutine想要释放读锁时会调用RUnlock
方法:
func (rw *RWMutex) RUnlock() {
// 减少读者数量,如果减少后的结果小于0,说明有写者正在等待锁
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// 唤醒等待的写者
rw.rUnlockSlow(r)
}
}
该方法会减少正在读资源的readerCount
,当前方法如果遇到了atomic.AddInt32返回值小于零的情况,说明有一个正在进行的写操作(作者应该是想说有正在等待的写者),这时就应该通过rUnlockSlow
方法减少当前写操作等待的读操作数readerWait
并在所有读操作都被释放后触发(指的是释放)写操作的信号量writerSem
:
func (rw *RWMutex) rUnlockSlow(r int32) {
// 如果没有读者加锁 || 读者数量溢出
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
throw("sync: RUnlock of unlocked RWMutex")
}
// 减少读者个数,如果减少后读者为0,就可以唤醒等待的写者了
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
// 释放信号量,唤醒等待的写者
runtime_Semrelease(&rw.writerSem, false, 1)
}
}
writerSem
在被触发之后,尝试获取读写锁的进程就会被唤醒并获得锁。
读写锁
当资源的使用者想要获取读写锁时,就需要通过Lock
方法了,在Lock
方法中首先调用了读写互斥锁持有的Mutex
的Lock
方法保证其他获取读写锁的Goroutine进入等待状态,随后的atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders)
其实是为了阻塞后续的读操作:
func (rw *RWMutex) Lock() {
// 对读写锁内部的mutex加锁,保证只有一个写者Goroutine可以修改读写锁的状态
rw.w.Lock()
// atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders)将读者数量减去一个很大的数
// 从而使其变为负数,表示这是一个写请求,然后将其结果加上rwmutexMaxReaders,得到当前还有多少读者
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// 如果还有读者 && 当前还有读锁没有释放
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
// 获取信号量,等待所有读锁释放
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
}
如果当前仍然有其他Goroutine持有互斥锁的读锁,该Goroutine就会调用runtime_SemacquireMutex
进入休眠状态,等待读锁释放时触发writerSem
信号量将当前协程唤醒。
对资源的读写操作完成之后就会通过atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
将rw.readerCount变回正数并通过for循环触发所有因获取读锁而陷入等待的Goroutine:
func (rw *RWMutex) Unlock() {
// 将读者数量加上rwmutexMaxReaders,重置之前写请求时设置的负值计数器
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
// 如果rw.readerCount之前不是负值,说明锁已解锁
if r >= rwmutexMaxReaders {
throw("sync: Unlock of unlocked RWMutex")
}
// 允许等待的读操作继续执行
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
// 释放内部的锁,允许其他写者加锁
rw.w.Unlock()
}
小结
相符状态复杂的互斥锁Mutex
来说,读写互斥锁RWMutex
虽然提供的功能非常复杂,但由于站在了Mutex
的“肩膀”上,所以整体的实现上会简单很多。
1.readerSem
——读写锁释放时通知由于读锁等待的Goroutine;
2.writerSem
——读锁释放时通知由于获取读写锁等待的Goroutine;
3.w
互斥锁——保证写操作之间的互斥;
4.readerWait
——当前读写锁等待的读者协程数,在触发Lock
之后的每次RUnlock
都会将其减一,当它归零时该Goroutine就会获得读写锁;
5.当读写锁被释放Unlock
时首先会通知所有的读操作,然后才会释放持有的互斥锁,这样能够保证读操作不会被连续的写操作“饿死”;
WaitGroup
WaitGroup
是Go语言sync
包中比较常见的同步机制,它可以用于等待一系列的Goroutine的返回,一个比较常见的使用场景是批量执行RPC或者调用外部服务:
requests := []*Request{...}
wg := &sync.WaitGroup{}
wg.Add(len(requests))
for _, request := range requests {
go func(r *Request) {
defer wg.Done()
// res, err := service.call(r)
}(request)
}
wg.Wait()
通过WaitGroup
我们可以在多个Goroutine之间非常轻松地同步信息,原本顺序执行的代码也可以在多个Goroutine中并发执行,加快了程序处理的速度,在上述代码中只有在所有的Goroutine都执行完毕后Wait
方法才会返回,程序可以继续执行其他逻辑。
总而言之,它的作用就像它的名字一样,通过Done
来传递任务完成的信号,比较常用于等待一组Goroutine中并发执行的任务全部结束。
结构体
WaitGroup
结构体中的成员变量非常简单,其中的noCopy
的主要作用就是保证WaitGroup
不会被开发者通过再赋值的方式进行拷贝,进而导致一些诡异的行为:
type WaitGroup struct {
noCopy noCopy
state1 [3]uint32
}
copylock包是一个用于检查类似错误的分析器,它的原理就是在编译期间检查被拷贝的变量中是否包含noCopy
或sync
关键字,如果包含当前关键字就会报出以下错误:
package main
import {
"fmt"
"sync"
}
func main() {
wg := sync.Mutex{}
yawg := wg
fmt.Println(wg. yawg)
}
执行它:
这段代码会在赋值和调用fmt.Println
时发生值拷贝,从而导致分析器报错。
除了noCopy
之外,WaitGroup
结构体中还包含一个总共占用12字节大小的数组,这个数组中会存储当前结构体持有的状态和信号量,在64位和32位机器上表现也非常不同。
WaitGroup
提供了私有方法state
能够帮助我们从state1
字段中取出它的状态和信号量。
操作
WaitGroup
对外暴露的接口只有三个Add
、Wait
、Done
,其中Done
方法只是调用了wg.Add(-1)
,本身并没有什么特殊的逻辑,我们来了解一下剩余的两个方法:
func (wg *WaitGroup) Add(delta int) {
// 获取状态指针和信号量指针
statep, semap := wg.state()
// 状态是一个64bit的值,其中高32bit是计数器
state := atomic.AddUint64(statep, uint64(delta)<<32)
// 获取计数器的值
v := int32(state >> 32)
// 低32bit是等待者数量
w := uint32(state)
// 如果计数器为负
if v < 0 {
// 抛出异常
panic("sync: negative WaitGroup counter")
}
// 如果计数器大于0(Done的次数不足) || 等待者为0(没有调用Wait的)
if v > 0 || w == 0 {
return
}
// 将状态重置为0
*statep = 0
// 释放所有信号量,每次循环释放一个Wait的Goroutine
for ; w != 0; w-- {
runtime_Semrelease(semap, false, 0)
}
}
Add
方法的主要作用就是更新WaitGroup
中持有的计数器counter
,即64位状态的高32位,虽然Add
方法传入的参数可以为负数,但是一个WaitGroup
的计数器只能是非负数,当调用Add
方法导致计数器归零且还有等待的Goroutine时,就会通过runtime_Semrelease
唤醒处于等待状态的所有Goroutine。
另一个WaitGroup
的方法Wait
会在当前计数器大于0
时修改等待的Goroutine个数waiter
,并调用runtime_Semacquire
陷入睡眠状态。
func (wg *WaitGroup) Wait() {
// 获取状态指针和信号量指针
statep, semap := wg.state()
for {
// 每次循环开始原子地加载最新的状态
state := atomic.LoadUint64(statep)
// 获取当前的计数器值
v := int32(state >> 32)
// 如果计数器为0
if v == 0 {
// 直接返回
return
}
// 尝试原子地比较statep指向的值是否与state相等,如果相等,将其值设为state+1
// 如果操作成功,说明自己已注册为等待者
if atomic.CompareAndSwapUint64(statep, state, state+1) {
// 阻塞自身,直到其他Goroutine释放信号量
runtime_Semacquire(semap)
// 如果statep指向的值非0,说明获取完信号量后,WaitGroup又被重新使用了
// 在Add方法中,释放信号量时会将状态置0
if *statep != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
return
}
}
}
陷入睡眠的Goroutine就会等待Add
方法在计数器为0
时唤醒它。
小结
通过对WaitGroup
的分析和研究,我们能得出以下结论:
1.Add
不能和Wait
方法在Goroutine中并发调用,一旦出现就会造成程序崩溃;(Wait不就应该等待其他Goroutine并发执行结束吗,并发执行结束时的Done中不就调用了Add吗?作者想表达的应该是要先调用Add,然后再调用Wait,作者这句话中的Add应该不包括Done中的Add,如果Add和Wait并发执行,可能Wait返回后,Add又增加了计数器,会造成任务还未完成,但Wait已返回的现象)
2.WaitGroup
必须在Wait
方法返回后才能被重新使用;
3.Done
只是对Add
方法的简单封装,我们可以向Add
方法传入任意负数(需要保证计数器非负)快速将计数器归零以唤醒其他等待的Goroutine;
4.可以同时有多个Goroutine等待当前WaitGroup
计数器的归零,这些Goroutine也会被“同时”唤醒;
Once
Go语言在标准库的sync
同步包中还提供了Once
语义,它的主要功能是保证在Go程序运行期间Once
对应的某段代码只会执行一次。
在如下所示的代码中,Do
方法中传入的函数只会被执行一次:
func main() {
o := &sync.Once{}
for i := 0; i < 10; i++ {
o.Do(func() {
fmt.Println("only once")
})
}
}
运行它:
作为sync
包中的结构体,Once
有着非常简单的数据结构,每一个Done
结构体中都只包含一个用于标识代码块是否被执行过的done
以及一个互斥锁Mutex
:
type Once struct {
done uint32
m Mutex
}
Once
结构体对外唯一暴露的方法就是Do
,该方法会接受一个入参为空的函数,如果使用atomic.LoadUint32
检查到已经执行过函数了,就会直接返回,否则就会进入doSlow
运行传入的函数:
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
doSlow
的实现也非常简单,我们先为当前的Goroutine获取互斥锁,然后通过defer
关键字将done
成员变量设置成1
并运行传入的函数,无论当前函数是正常运行还是抛出panic
,当前方法都会将done
设置成1
保证函数不会执行第二次。
小结
作为用于保证函数执行次数的Once
结构体,它使用互斥锁和atomic
提供的方法实现了某个函数在程序运行期间只能执行一次的语义,在使用过程中我们需要注意:
1.Do
方法中传入的函数只会被执行一次,哪怕函数中发生了panic
;
2.两次调用Do
方法传入不同函数时只会执行第一次调用的函数;
Cond
Go语言在标准库中提供的Cond
是一个条件变量,通过Cond
我们可以让一系列的Goroutine都在触发某个事件或条件时才被唤醒,每一个Cond
结构体都包含一个互斥锁L
,我们先看一下Cond
是如何使用的:
func main() {
c := sync.NewCond(&sync.Mutex{})
for i := 0; i < 10; i++ {
go listen(c)
}
time.Sleep(1*time.Second)
go broadcast(c)
ch := make(chan os.Signal, 1)
// 将os.Interrupt信号注册到通道ch,这样当用户输入Ctrl+c产生中断信号时,就会发到ch中
signal.Notify(ch, os.Interrupt)
<-ch
}
func broadcast(c *sync.Cond) {
c.L.Lock()
c.BroadCast()
c.L.Unlock()
}
func listen(c *sync.Cond) {
c.L.Lock()
c.Wait()
fmt.Println("listen")
c.L.Unlock()
}
运行它:
在上述代码中我们同时运行了11个Goroutine,其中的10个Goroutine会通过Wait
等待期望的信号或事件,而剩下的一个Goroutine会调用BroadCast
方法通知所有陷入等待的Goroutine,当调用Broadcast
方法后,就会打印出10次"listen"
并结束调用。
结构体
Cond
的结构体中包含noCopy
和copyChecker
两个字段,前者用于保证Cond
不会在编译期间拷贝,后者保证在运行期间发生拷贝会直接panic
,持有的另一个锁L
其实是一个接口Locker
,任意实现Lock
和Unlock
方法的结构体都可以作为NewCond
方法的参数:
type Cond struct {
noCopy noCopy
L Locker
notify notifyList
checker copyChecker
}
结构体中的变量notifyList
是为了实现Cond
同步机制,该结构体就是一个Goroutine
的链表:
type notifyList struct {
wait uint32
notify uint32
lock mutex
head *sudog
tail *sudog
}
在这个结构体中,head
和tail
分别指向的就是整个链表的头和尾,而wait
和notify
分别表示当前正在等待的Goroutine和已经通知到的Goroutine。
操作
Cond
对外暴露的Wait
方法会将当前Goroutine陷入休眠状态,它会先调用runtime_notifyListAdd
将等待计数器+1
,然后解锁并调用runtime_notifyListWait
等待其他Goroutine的唤醒:
func (c *Cond) Wait() {
c.checker.check()
// 向通知列表中加入当前Goroutine,返回当前Goroutine在通知列表中的标识符
t := runtime_notifyListAdd(&c.notify)
// 解锁,从而使其他Goroutine来获得锁从而改变状态并最终触发条件变量
c.L.Unlock()
// Goroutine会阻塞,等待条件变量的通知,具体地说,等待通知列表中位置t的通知
runtime_notifyListWait(&c.notify, t)
// 加锁,类似于POSIX的条件变量接口,保证该函数返回后,条件不会被修改(直到手动解锁)
c.L.Lock()
}
func notifyListAdd(l *notifyList) uint32 {
// 将通知列表中的Goroutine数量加1,将加1前的值返回,作为新增加的Goroutine的标识符
return atomic.Xadd(&l.wait, 1) - 1
}
notifyListWait
方法的主要作用是获取当前Goroutine并将它追加到notifyList
链表的末端:
func notifyListWait(l *notifyList, t uint32) {
// 加锁,保护notifyList
lock(&l.lock)
// 如果t小于l.notify,说明可以唤醒当前Goroutine,直接返回
if less(t, l.notify) {
unlock(&l.lock)
return
}
// 获取sudog对象,用于管理Goroutine
s := acquireSudog()
// 将g字段设为当前Goroutine
s.g = getg()
// 将ticket字段设为t,用于标识Goroutine在通知队列中的等待顺序
s.ticket = t
// 将sudog加入队列
if l.tail == nil {
l.head = s
} else {
l.tail.next = s
}
l.tail = s
// 将当前Goroutine阻塞,同时解锁l.lock,以便其他Goroutine访问和修改notifyList,等待被唤醒
goparkunlock(&l.lock, waitReasonSyncCondWait, traceEvGoBlockCond, 3)
// 释放sudog对象
releaseSudog(s)
}
除了将当前Goroutine追加到链表末尾外,我们还会调用goparkunlock
陷入休眠状态,该函数也是Go语言切换Goroutine时常用的方法,它会直接让出当前处理器的使用权并等待调度器的唤醒。
Cond
对外提供的Signal
和Broadcast
方法就是用来唤醒因调用Wait
而陷入休眠的Goroutine,从两个方法的名字来看,前者会唤醒队列最前面的Goroutine,后者会唤醒队列中全部的Goroutine:
func (c *Cond) Signal() {
c.checker.check()
runtime_notifyListNotifyOne(&c.notify)
}
func (c *Cond) Broadcast() {
c.checker.check()
runtime_notifyListNotifyAll(&c.notify)
}
notifyListNotifyAll
方法会从链表中取出全部Goroutine并为它们依次调用readyWithTime
,该方法会通过goready
将目标Goroutine唤醒:
func notifyListNotifyAll(l *notifyList) {
s := l.head
l.head = nil
l.tail = nil
// 原子地将已通知Goroutine数量设为等待中的Goroutine数量,即设为已通知全部等待中的Goroutine
atomic.Store(&l.notify, atomic.Load(&l.wait))
for s != nil {
next := s.next
s.next = nil
readyWithTime(s, 4)
s = next
}
}
虽然它会依次唤醒全部Goroutine,但是这里唤醒的顺序其实也是按照加入队列的先后顺序,先加入的会先被goready
唤醒,后加入的Goroutine可能就需要等待调度器的调度。
而notifyListNotifyOne
函数就只会从sudog
构成的链表中通过readyWithTime
唤醒满足sudog.ticket == l.notify
的Goroutine:
func notifyListNotifyOne(l *notifyList) {
t := l.notify
// 增加已通知的Goroutine数量
atomic.Store(&l.notify, t+1)
// 遍历sudog链表
for p, s := (*sudog)(nil), l.head; s != nil, p, s = s, s.next {
// 如果找到了要唤醒的Goroutine
if s.ticket == t {
// 从链表中删去找到的sudog对象
n := s.next
if p != nil {
p.next = n
} else {
l.head = n
}
if n == nil {
l.tail = p
}
s.next = nil
// 唤醒该Goroutine
readyWithTime(s, 4)
return
}
}
}
在一般情况下我们都会选择在不满足特定条件时调用Wait
陷入休眠,当某些Goroutine检测到当前满足了唤醒条件,就可以选择使用Signal
通知一个或者Broadcast
通知全部的Goroutine当前条件已经满足,可以继续完成工作了。
小结
与Mutex
相比,Cond
还是一个不被所有人都清楚和理解的同步机制,它提供了类似队列的FIFO等待机制,同时也提供了Signal
和Broadcast
两种不同的唤醒方法,相比于使用for {}
忙碌等待,使用Cond
能够在遇到长时间无法满足条件时将当前处理器让出,如果我们合理使用能在一些情况下提升性能,在使用过程中需注意:
1.Wait
方法在调用前一定要使用L.Lock
持有该资源,否则会发生panic
(补充一下,Wait前必须加锁的原因是,如果我们检查发现条件不满足,此时我们需要调用Wait等待条件满足,如果不加锁,可能我们检查和Wait之间的时间窗口内,条件又满足了,使条件满足的Goroutine会Signal或Broadcast,如果条件只出现一次,那么本Goroutine会永远都Wait,所以Go有Wait前必须加锁的限制);
2.Signal
方法唤醒的Goroutine都是队列最前面、等待最久的Goroutine;
3.Broadcast
虽然是广播通知全部等待的Goroutine,但是真正被唤醒时也是按照一定顺序的;
扩展原语
除了这些标准库中提供的同步原语之外,Go语言还在子仓库x/sync
中提供了额外的四种同步原语,ErrGroup
、Semaphore
、SignalFlight
、SyncMap
,其中的SyncMap
其实就是sync
包中的sync.Map
,它在1.9版本的Go语言中被引入了x/sync
包,随着API的成熟和稳定最后被移到了标准库sync
包中。
我们在这一节会介绍Go语言目前在扩展包中提供的三种原语,也就是ErrGroup
、Semaphore
、SignalFlight
。
ErrGroup
子仓库x/sync
中的包errgroup为我们在一组Goroutine中提供了同步、错误传播、上下文取消的功能,我们可以使用如下所示方式并行获取网页数据:
var g errgroup.Group
var urls = []string{
"http://www.golang.org/",
"http://www.google.com/",
"http://www.somestupidname.com/",
}
for i := range urls {
url := urls[i]
g.Go(func() error {
resp, err := http.Get(url)
if err == nil {
resp.Body.Close()
}
return err
})
}
if err := g.Wait(); err == nil {
fmt.Println("Successfully fetched all URLs.")
}
Go
方法能够创建一个Goroutine并在其中执行传入的函数,而Wait
方法会等待Go
方法创建的Goroutine全部返回后返回第一个非空的错误,如果所有Goroutine都没有返回错误,该函数就会返回nil
。
结构体
errgroup
包中的Group
结构体同时由三个比较重要的部分组成:
1.创建Context
时返回的cancel
函数,主要用于通知使用context
的Goroutine由于某些子任务出错,可以停止工作让出资源了;
2.用于等待一组Goroutine完成子任务的WaitGroup
同步原语;
3.用于接收子任务返回错误的err
和保证err
只会被赋值一次的errOnce
;
type Group struct {
cancel func()
wg sync.WaitGroup
errOnce sync.Once
err error
}
这些字段共同组成了Group
结构体并为我们提供同步、错误传播、上下文取消等功能。
操作
errgroup
对外唯一暴露的构造器就是WithContext
方法,我们只能从一个Context
中创建一个新的Group
变量,WithCancel
返回的取消函数也仅会在Group
结构体内部使用:
func WithContext(ctx context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancel(ctx)
return &Group{cancel: cancel}, ctx
}
创建新的并行子任务需要使用Go
方法,这个方法内部会对WaitGroup
加一并创建一个新的Goroutine,在Goroutine内部运行子任务并在返回错误时及时调用cancel
并对err
赋值,只有最早返回的错误才会被上游感知到,后续的错误都会被舍弃:
func (g *Group) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done
if err := f(); err != nil {
g.errOnce.Do(func() {
g.err = err
if g.cancel != nil {
g.cancel()
}
})
}
}()
}
func (g *Group) Wait() error {
g.wg.Wait()
if g.cancel != nil {
g.cancel()
}
return g.err
}
Wait
方法其实就只是调用了WaitGroup
的同步方法,在子任务全部完成时取消Context
并返回可能出现的错误。
小结
errgroup
包中的Group
同步原语的实现原理还是非常简单的,它没有涉及非常底层和运行时包中的API,只是对基本同步语义进行了简单的封装提供了更加复杂的功能,在使用时我们也需要注意以下几个问题:
1.出现错误或者等待结束后都会调用Context
的cancel
方法取消上下文;
2.只有第一个出现的错误才会被返回,剩余的错误都会被直接抛弃;
Semaphore
信号量是在并发编程中比较常见的一种同步机制,它会保证持有的计数器在0
到初始化的权重之间,每次获取资源时都会将信号量中的计数器减去(根据下面的代码,这里应该是加上)对应的数值,在释放时重新加回来(根据下面的代码,这里应该是减回去),当遇到计数器大于信号量大小时(根据下面的代码,这里应该是计数器加上要获取的信号量大小大于信号量的最大大小时)就会进入休眠等待其他进程释放信号,我们常常会在控制访问资源的进程数量时用到。
Golang的扩展包中就提供了带权重的信号量,我们可以按照不同的权重对资源的访问进行管理,这个包对外也只提供了四个方法:
1.NewWeighted
用于创建新信号量;
2.Acquire
获取了指定权重的资源,如果当前没有“空闲资源”,就会陷入休眠等待;
3.TryAcquire
也用于获取指定权重的资源,但是如果当前没有“空闲资源”,就会直接返回false
;
4.Release
用于释放指定权重的资源;
结构体
NewWtighted
方法的主要作用是创建一个新的权重信号量,传入信号量最大的权重,返回一个新的Weighted
结构体指针:
func NewWeighted(n int64) *Weighted {
w := &Weighted{size: n}
return w
}
type Weighted struct {
size int64
cur int64
mu sync.Mutex
waiters list.List
}
Weighted
结构体中包含一个waiters
列表,其中存储着等待获取资源的“用户”,除此之外它还包含当前信号量的上限以及一个计数器cur
,这个计数器的范围是[0, size]
:
信号量中的计数器会随着用户对资源的访问和释放进行改变,引入的权重概念能帮助我们更好地对资源的访问粒度进行控制,尽可能满足所有常见的用例。
获取
上面我们已经提到过Acquire
方法,它就是用于获取指定权重资源的方法,这个方法总共由三个不同情况组成:
1.当信号量中剩余的资源大于获取的资源且没有等待的Goroutine时就会直接获取信号量;
2.当需要获取的信号量大于Weighted
的最大大小时,由于不可能满足条件就会直接返回;
3.遇到其它情况时会将当前Goroutine接入等待列表并通过select
等待当前Goroutine被唤醒,被唤醒后就会获取信号量;
func (s *Weighted) Acquire(ctx context.Context, n int64) error {
s.mu.Lock()
// 如果资源足够 && 没有其他等待者
if s.size-s.cur >= n && s.waiters.Len() == 0 {
// 获取资源并返回
s.cur += n
s.mu.Unlock()
return nil
}
// 如果要获取的资源超过最大资源数量
if n > s.size() {
// 解锁、等待上下文结束、返回上下文错误
s.mu.Unlock()
<-ctx.Done()
return ctx.Err()
}
// 如果不能立即获取资源
// 创建一个等待通道
ready := make(chan struct{})
// 创建一个waiter对象,并将其加入队列
w := waiter{n: n, ready: ready}
elem := s.waiters.PushBack(w)
s.mu.Unlock()
// 等待上下文取消或资源足够
select {
// 如果上下文被取消
case <-ctx.Done():
// 获取上下文错误
err := ctx.Err()
s.mu.Lock()
select {
// 如果资源已足够
case <-ready:
// 将错误设为nil
err = nil
// 如果资源还是不够
default:
// 从等待队列中移除等待者
s.waiters.Remove(elem)
}
s.mu.Unlock()
return err
// 如果资源已足够(资源已分配给这个等待者)
case <-ready:
return nil
}
}
另一个用于获取信号量的方法TryAcquire
相比之下就非常简单,它只会判断当前信号量是否有充足的资源获取,如果有充足的资源就直接获取并立刻返回true
,否则返回false
:
func (s *Weighted) TruAcquire(n int64) bool {
s.mu.Lock()
success := s.size-s.cur >= n && s.waiters.Len() == 0
if success {
s.cur += n
}
s.mu.Unlock()
return success
}
与Acquire
相比,TryAcquire
由于不会等待资源的释放所以可能更适用于一些延迟敏感、用户需要立刻感知结果的场景。
释放
最后要介绍的Release
方法其实也非常简单,当我们对信号量进行释放时,Release
方法会从头到尾遍历waiters
列表中全部的等待者,如果释放资源后的信号量有充足的剩余资源就会通过Channel唤起指定的Goroutine:
func (s *Weighted) Release(n int64) {
s.mu.Lock()
// 释放资源
s.cur -= n
for {
// 获取队首的等待者
next := s.waiters.Front()
if next == nil {
break
}
w := next.Value.(waiter)
// 如果不满足队首等待者的资源要求
if s.size-s.cur < w.n {
break
}
// 为队首等待者分配资源、从队列中移除队首等待者、通知队首等待者
s.cur += w.n
s.waiters.Remove(next)
close(w.ready)
}
s.mu.Unlock()
}
当然也可能会出现剩余资源无法唤起Goroutine的情况,这时当前方法就会释放锁后直接返回,通过对这段代码的分析我们也能发现,如果一个信号量需要占用的资源非常多,它可能会长时间无法获取锁,这可能也是Acquire
方法引入另一个参数Context
的原因,为信号量的获取设置一个超时时间。
小结
带权重的信号量确实有着更多的应用场景,这也是Go语言对外提供的唯一一种信号量实现,在使用过程中需注意以下问题:
1.Acquire
和TryAcquire
方法都可以用于获取资源,前者用于同步获取,会等待锁的释放,后者会在无法获取锁时直接返回;
2.Release
方法会按照FIFO的顺序唤醒可以被唤醒的Goroutine;
3.如果一个Goroutine获取了较多的资源,由于Release
的释放策略可能会等待比较长的时间;