golang锁及并发安全结构

2020/12/27 Go 共 12343 字,约 36 分钟

前提知识

在 Go 中,标准库中有一些线程安全的数据类型或工具,它们设计时已经考虑到了并发场景的安全性,因此可以在多个 goroutine 间安全地使用。


不是线程安全的数据类型

以下类型在并发环境下需要额外加锁保护,默认情况下不是线程安全的:

  1. 普通变量:
    • 如 int, float64, string 等基本类型。
    • 解决方式: 使用 sync.Mutex 或 sync/atomic。
  2. map:
    • Go 中的普通 map 在并发读写时会引发运行时错误。
    • 解决方式: 使用 sync.Mutex 或 sync.Map。
  3. 切片和数组:
    • 对切片的增删改查在并发环境中可能引发数据竞争。
    • 解决方式: 使用 sync.Mutex 或 channel。
  4. 结构体:
    • 自定义的结构体如果没有明确的同步逻辑,在并发访问时也可能出错。
    • 解决方式: 使用 sync.Mutex 或其他同步机制。

为什么 int64 不是线程安全的?

  1. 非原子性:
    • 操作 int64(例如读或写)可能会被拆分为多条指令,例如从内存加载到寄存器,或者从寄存器写回内存。这些操作在执行期间可能被其他 goroutine 打断,导致数据不一致。
  2. 数据竞争:
    • 如果一个 goroutine 正在写 int64,而另一个 goroutine 同时读取该值,读取到的数据可能是写操作未完成时的中间状态,导致不一致的结果。
	var counter int64

	// 使用原子操作更新和读取
	atomic.AddInt64(&counter, 1) // 原子增加
	value := atomic.LoadInt64(&counter) // 原子读取

悲观锁和乐观锁

悲观锁是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,不管读还是写,悲观锁在执行操作之前都先上锁。

对读对写都需要加锁导致性能低,适合写多读少。

乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过。乐观锁的实现方案主要包含CAS和版本号机制。乐观锁适用于多读的场景,可以提高吞吐量。

CAS即Compare And Swap(比较与交换),是一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步。CAS涉及三个关系:指向内存一块区域的指针V、旧值A和将要写入的新值B。CAS实现的乐观锁会带来ABA问题,同时整个乐观锁在遇到数据不一致的情况下会触发等待、重试机制,这对性能的影响较大。

版本号机制是通过一个版本号version来实现版本控制。

自旋锁

之前介绍的CAS就是自旋锁的一种。同一时刻只能有一个线程获取到锁,没有获取到锁的线程通常有两种处理方式:

  • 一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING);
  • 把自己阻塞起来,等待重新调度请求,这种是互斥锁。

自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。

但是如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被OS调度程序中断的风险越大。如果发生中断情况,那么其他线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。

解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。

信号量和锁

信号量和锁虽然看起来很相似,比如当信号量为1时就实现了互斥锁,但实际上他们表示的含义不同1。锁是用来保护临界资源的,比如读写不可以同时进行等;信号量是为了保证进程(或线程或goroutine)调度的,比如三个进程共同计算c=a+b,首先计算a+b和赋值操作不能同时进行,其次还要保证a+b先执行,对c的赋值后执行,因此这个地方需要采用信号量的方式来进行。

更进一步的,锁可以由信号量实现,那么goroutine可以遵循规定的被阻塞和唤醒,也可以由自旋锁实现,那么goroutine一直占用CPU直到解锁。他们这两种方式的区别在于是否需要goroutine调度,但本质上锁的实现都是为了保证临界资源不会被错误的访问。

Sync.Mutex

golang的Mutex实现

go设计与原理

	a := sync.Mutex{}
	a.Lock()
	a.Unlock()

Golang Mutex其实是不断改进的,截止到目前为至主要改进了4版:

  • V1版本中,完全是按照FIFO的方式来加锁、解锁的,虽然这种方式非常的公平,但是从效率角度来说不是最优的

  • V2: 新goroutine参与锁的竞争,取队头时,如果有新来的goroutine, 新来的有cpu资源,给它。
  • V3: 再多给新goroutine一些机会:有cpu资源的goroutine多自旋一会,如果自旋期间可以得到锁,给它。
  • V4: 解决老goroutine饥饿问题, 老的goroutine可能一直拿不到锁,等待超过阈值(1ms)进入饥饿模式,直接到fifo队头,并关闭其他自旋。与饥饿模式相比,正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。

每一次的改进都是为了提高系统的整体性能,这个升级是逐步的连贯的,所以需要从V1版本慢慢开始看Mutex的演化进程。

Mutex不是FIFO的,不是公平的,随机唤醒gorountine。这是为了效率保证,不用维护FIFO队列。

type Mutex struct {
  state int32
  sema int32
}

sema是信号量,这是真正的导致goroutine被阻塞和唤醒的原因。

基于atomic CAS来操作state

在默认情况下,互斥锁的所有状态位都是 0,int32 中的不同位分别表示了不同的状态:

  • mutexLocked — 表示互斥锁的锁定状态;

  • mutexWoken — 表示从正常模式被从唤醒;

  • mutexStarving — 当前的互斥锁进入饥饿状态;

  • waitersCount — 当前互斥锁上等待的 Goroutine 个数;

获取锁的过程:

  1. 判断当前 Goroutine 能否进入自旋;
  2. 通过自旋等待互斥锁的释放;
  3. 计算互斥锁的最新状态;
  4. 更新互斥锁的状态并获取锁;

Goroutine 进入自旋的条件非常苛刻:

  1. 互斥锁只有在普通模式才能进入自旋;
  2. runtime_canSpin需要返回 true:
    1. 运行在多 CPU 的机器上;
    2. 当前 Goroutine 为了获取该锁进入自旋的次数小于四次;
    3. 当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空;

一旦当前 Goroutine 能够进入自旋就会调用runtime.sync_runtime_doSpin 和 runtime.procyield 并执行 30 次的 PAUSE 指令,该指令只会占用 CPU 并消耗 CPU 时间

互斥锁的加锁过程比较复杂,它涉及自旋、信号量以及调度等概念:

  • 如果互斥锁处于初始化状态,会通过置位 mutexLocked 加锁;
  • 如果互斥锁处于 mutexLocked 状态并且在普通模式下工作,会进入自旋,执行 30 次 PAUSE 指令消耗 CPU 时间等待锁的释放;
  • 如果当前 Goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式;
  • 互斥锁在正常情况下会通过 runtime.sync_runtime_SemacquireMutex 将尝试获取锁的 Goroutine 切换至休眠状态,等待锁的持有者唤醒;
  • 如果当前 Goroutine 是互斥锁上的最后一个等待的协程或者等待的时间小于 1ms,那么它会将互斥锁切换回正常模式;

互斥锁的解锁过程与之相比就比较简单,其代码行数不多、逻辑清晰,也比较容易理解:

  • 当互斥锁已经被解锁时,调用 sync.Mutex.Unlock 会直接抛出异常;
  • 当互斥锁处于饥饿模式时,将锁的所有权交给队列中的下一个等待者,等待者会负责设置 mutexLocked 标志位;
  • 当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,会直接返回;在其他情况下会通过 sync.runtime_Semrelease 唤醒对应的 Goroutine;

Sync.RWMutex

	b := sync.RWMutex{}
	b.Lock()
	b.Unlock()
	b.RLock()
	b.RUnlock()

sync.RWMutex 中总共包含以下 5 个字段:

type RWMutex struct {
	w           Mutex  // 底层的互斥锁,用于控制写操作的排他性
  writerSem   uint32 // 写锁的信号量,用于阻塞等待写锁的 Goroutine。 阻塞Lock(), RUnlock()时会唤醒。
  readerSem   uint32 // 读锁的信号量,用于阻塞等待读锁的 Goroutine。 阻塞Rlock(), Unlock()时会唤醒。
  readerCount int32  // 活跃的读锁数量。RLock()时+1, RUnlock()时减一。Lock()变负数, Unlock()时还原。                                                                                                                                                 
  readerWait  int32  // 等待释放写信号量的的在读 Goroutine 数量。否则RUnlockk不知道何时release唤醒Lock(). 在Lock()时把readerCount暂存到readerWait 然后等待。每次RUnlock都会减一,减到0,唤醒Lock()
}
  • w — 复用互斥锁提供的能力;
  • writerSem 和 readerSem — 分别用于写等待读和读等待写:
  • readerCount 存储了当前正在执行的读操作数量;记录当前持有读锁的 Goroutine 数量
  • readerWait 表示当写操作被阻塞时等待的读操作个数;记录正在等待写锁的 Goroutine 数量

mutex就是个写锁,当读多写少,最好还是RWMutex

写锁-Lock

func (rw *RWMutex) Lock() {
  // mutex获取, 其他goroutine只能自旋或者休眠
	rw.w.Lock() 
  // readercount 变成负数,阻塞后续读操作。让读写公平!!
	r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders 
  // 如果仍然有其他 Goroutine 持有互斥锁的读锁,该 Goroutine 会调用 runtime.sync_runtime_SemacquireMutex 进入休眠状态等待所有读锁所有者执行结束后释放 writerSem 信号量将当前协程唤醒;
	if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
		runtime_SemacquireMutex(&rw.writerSem, false, 0)
	}
}

写锁-Unlock

func (rw *RWMutex) Unlock() {
  // 恢复readCount,释放读锁
	r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
	if r >= rwmutexMaxReaders {
		throw("sync: Unlock of unlocked RWMutex")
	}
  // 通过 for 循环释放所有因为获取读锁而陷入等待的 Goroutine:
	for i := 0; i < int(r); i++ {
		runtime_Semrelease(&rw.readerSem, false, 0)
	}
  // 释放写锁
	rw.w.Unlock()
}
  • 读写锁的竞争和优先级

    • 读优先:

      • 多个读 Goroutine 可以同时持有读锁。
      • 如果已有读锁,写锁请求会被阻塞。
    • 写优先:

      • 写锁优先于后续的读锁。
      • 如果有写锁等待,则后续的读锁请求也会被阻塞,直到写锁释放。

读锁-Rlock

func (rw *RWMutex) RLock() {

	if atomic.AddInt32(&rw.readerCount, 1) < 0 {
		runtime_SemacquireMutex(&rw.readerSem, false, 0)
	}
}
  1. 如果该方法返回负数 — 其他 Goroutine 获得了写锁,当前 Goroutine 就会调用 runtime.sync_runtime_SemacquireMutex 陷入休眠等待锁的释放;
  2. 如果该方法的结果为非负数 — 没有 Goroutine 获得写锁,当前方法会成功返回;

读锁-Runlock

func (rw *RWMutex) RUnlock() {
	if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
			if atomic.AddInt32(&rw.readerWait, -1) == 0 {
        // The last reader unblocks the writer.
        runtime_Semrelease(&rw.writerSem, false, 1)
      }
	}
}

该方法会先减少正在读资源的 readerCount 整数,根据 sync/atomic.AddInt32 的返回值不同会分别进行处理:

  • 如果返回值大于等于零 — 读锁直接解锁成功;
  • 如果返回值小于零 — 有一个正在执行的写操作,在这时会 减少获取锁的写操作等待的读操作数 readerWait 并在所有读操作都被释放之后触发写操作的信号量 writerSem,该信号量被触发时,调度器就会唤醒尝试获取写锁的 Goroutine。

小结:

  • 尝试获取写锁时;
    • 每次 sync.RWMutex.RUnlock 都会将 readerCount 其减一,当它归零时该 Goroutine 会获得写锁;
    • 将 readerCount 减少 rwmutexMaxReaders 个数以阻塞后续的读操作;
  • 释放写锁时,会先通知所有的读操作,然后才会释放持有的互斥锁;

Sync.WaitGroup

	wg := &sync.WaitGroup{}
	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(1 * time.Second)
	}()
	wg.Wait()
	println("end")

源码:

WaitGroup 在逻辑上包含:

  1. worker 计数器:main协程调用 wg.Add(delta int) 时增加 delta,调用 wg.Done时减一。
  2. waiter 计数器:调用 wg.Wait 时,计数器加一; worker计数器降低到0时,重置waiter计数器。
  3. 信号量:用于阻塞 main协程。调用 wg.Wait 时,通过 runtime_Semacquire 获取信号量;降低 waiter 计数器时,通过 runtime_Semrelease 释放信号量。

Sync.Map

	a := sync.Map{}
	a.Store("key", "value")
	val, ok := a.Load("key")
	println(ok, val)
	println(val.(string))

sync.Map的原理很简单,使用了空间换时间策略,通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。

通过引入两个map将读写分离到不同的map,其中read map提供并发读和已存元素原子写,而dirty map则负责读写。

这样read map就可以在不加锁的情况下进行并发读取,当read map中没有读取到值时,再加锁进行后续读取,并累加未命中数。

当未命中数大于等于dirty map长度,将dirty map上升为read map。 从结构体的定义可以发现,虽然引入了两个map,但是底层数据存储的是指针,指向的是同一份值。

注意read map实际上是个atomic value,所以它可以并发读,然后读的entry又和ditry的entry共用。

sync.Map 的实现原理可概括为:

  • 通过 read 和 dirty 两个字段将读写分离,读的数据存在只读字段 read 上,将最新写入的数据则存在 dirty 字段上
  • 读取时会先查询 read,不存在再查询 dirty,写入时则只写入 dirty
  • 读取 read 并不需要加锁,而读或写 dirty 都需要加锁
    • 另外有 misses 字段来统计 read 被穿透的次数(被穿透指需要读 dirty 的情况),超过一定次数则将 dirty 数据同步到 read 上
  • 对于删除数据则直接通过标记来延迟删除

Map 的数据结构如下:

type Map struct {
    // 加锁作用,保护 dirty 字段
    mu Mutex
    // 只读的数据,实际数据类型为 readOnly
    read atomic.Value
    // 最新写入的数据
    dirty map[interface{}]*entry
    // 计数器,每次需要读 dirty 则 +1
    misses int
}
复制代码

其中 readOnly 的数据结构为:

type readOnly struct {
    // 内建 map
    m  map[interface{}]*entry
    // 表示 dirty 里存在 read 里没有的 key,通过该字段决定是否加锁读 dirty
    amended bool
}
复制代码

entry 数据结构则用于存储值的指针:

type entry struct {
    p unsafe.Pointer  // 等同于 *interface{}
}

Load函数

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    // 首先尝试从 read 中读取 readOnly 对象
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]

    // 如果不存在则尝试从 dirty 中获取
    if !ok && read.amended {
        m.mu.Lock()
        // 由于上面 read 获取没有加锁,为了安全再检查一次
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]

        // 确实不存在则从 dirty 获取
        if !ok && read.amended {
            e, ok = m.dirty[key]
            // 调用 miss 的逻辑
            m.missLocked()
        }
        m.mu.Unlock()
    }

    if !ok {
        return nil, false
    }
    // 从 entry.p 读取值
    return e.load()
}

Sync.Once

var (
    instance7 *singleton7
    once      sync.Once
)

type singleton7 struct{}

func GetInstance7() *singleton7 {
    once.Do(func() {
        instance7 = &singleton7{}
    })
    return instance7
}

sync.Once有点类似于init()函数,它们都执行且仅执行一次,区别在于sync.Once是在你需要的时候执行,而init()是在包第一次被加载的时候执行。那为什么sync.Once可以解决加锁的问题呢?这就跟sync.Once的内部实现有关了。

以下是sync.Once的源码,非常短,但是很有参考价值:

type Once struct {
    done uint32
    m    Mutex
}

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()
    }
}

可以发现Do()函数中仅仅做了一次判断——如果传入的函数已经执行了(done的值为1),那么就不执行,直接返回;否则执行doSlow()方法。在doSlow()方法中进行了加锁并执行了传入的函数,在代码块运行结束后再把done修改为1,这样就实现了执行且仅执行一次的功能,并且只有第一次需要加锁,这样对于GetInstance()函数来说就不再需要判断instance是否为nil了,也不再需要手动进行加锁解锁操作了,可谓是非常棒的一种解决方案。

Sync.Cond

sync.Cond: 条件变量本质上是对 sync.Mutex 和信号机制的封装。

关联的锁: sync.Cond 需要绑定一个 sync.Locker 接口(例如 sync.Mutex 或 sync.RWMutex),以确保对共享资源的并发访问是安全的。

sync.Cond 是 Go 中的条件变量,用于让一个或多个 goroutine 等待某个条件满足,然后通过通知唤醒等待的 goroutine。它适用于需要基于条件协调多个 goroutine 的场景。

每个 Cond 实例都会关联一个锁 L(互斥锁 *Mutex,或读写锁 *RWMutex),当修改条件或者调用 Wait 方法时,必须加锁。

  • Signal 只唤醒任意 1 个等待条件变量 c 的 goroutine,无需锁保护。

  • 调用 Wait 会自动释放锁 c.L,并挂起调用者所在的 goroutine,因此当前协程会阻塞在 Wait 方法调用的地方。如果其他协程调用了 Signal 或 Broadcast 唤醒了该协程,那么 Wait 方法在结束阻塞时,会重新给 c.L 加锁,并且继续执行 Wait 后面的代码。

对条件的检查,使用了 for !condition() 而非 if,是因为当前协程被唤醒时,条件不一定符合要求,需要再次 Wait 等待下次被唤醒。为了保险起见,使用 for 能够确保条件符合要求后,再执行后续的代码。

  • Wait方法是释放锁,等有信号来时再加锁。需要反复检查条件。其它Signal和Broadcast都不用提前加锁。

接下来我们实现一个简单的例子,三个协程调用 Wait() 等待,另一个协程调用 Broadcast() 唤醒所有等待的协程。


var done = false

func read(name string, c *sync.Cond) {
	c.L.Lock()
	for !done {
		c.Wait()
	}
	log.Println(name, "starts reading")
	c.L.Unlock()
}

func write(name string, c *sync.Cond) {
	log.Println(name, "starts writing")
	time.Sleep(time.Second)
	c.L.Lock()
	done = true
	c.L.Unlock()
	log.Println(name, "wakes all")
	c.Broadcast()
}

func main() {
	cond := sync.NewCond(&sync.Mutex{})

	go read("reader1", cond)
	go read("reader2", cond)
	go read("reader3", cond)
	write("writer", cond)

	time.Sleep(time.Second * 3)
}
  • done 即互斥锁需要保护的条件变量。
  • read() 调用 Wait() 等待通知,直到 done 为 true。
  • write() 接收数据,接收完成后,将 done 置为 true,调用 Broadcast() 通知所有等待的协程。
  • write() 中的暂停了 1s,一方面是模拟耗时,另一方面是确保前面的 3 个 read 协程都执行到 Wait(),处于等待状态。main 函数最后暂停了 3s,确保所有操作执行完毕。

Atomic

代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁操作更好。

为什么 atomic 能保证原子性?

硬件支持

sync/atomic 包中的方法(如 LoadInt64, StoreInt64, AddInt64, CompareAndSwapInt64 等)依赖于底层硬件的指令集。例如,x86 架构中的以下指令可以实现原子操作:

  • LOCK 前缀: 确保 CPU 在执行指令时锁定总线或缓存行,禁止其他核访问同一内存地址。
  • CAS(Compare-And-Swap): 比较和交换操作,是实现原子性的重要基础。
  • XADD: 原子地获取并增加某个值。

Go 的 atomic 包通过调用这些底层原子指令,避免了加锁的开销,同时保证了操作的原子性。

内存屏障

原子操作通常会在执行时使用内存屏障(Memory Barrier,也叫 Memory Fence)。内存屏障确保:

  1. CPU 不会乱序执行操作。
  2. 其他核心能正确看到内存的最新状态。

例如:

  • 在一个 atomic.StoreInt64 操作中,写入会立即对其他线程可见,而不会被 CPU 的乱序优化推迟。

Sync.Pool

一句话总结:保存和复用临时对象,减少内存分配,降低 GC 压力。

sync.Pool 是可伸缩的,同时也是并发安全的,其大小仅受限于内存的大小。sync.Pool 用于存储那些被分配了但是没有被使用,而未来可能会使用的值。这样就可以不用再次经过内存分配,可直接复用已有对象,减轻 GC 的压力,从而提升系统的性能。

sync.Pool 的大小是可伸缩的,高负载时会动态扩容,存放在池中的对象如果不活跃了会被自动清理。

var bufferPool = sync.Pool{
  // 对象池中没有对象时,将会调用 New 函数创建。
	New: func() interface{} { 
		return &bytes.Buffer{}
	},
}

var data = make([]byte, 10000)

func BenchmarkBufferWithPool(b *testing.B) {
	for n := 0; n < b.N; n++ {
		buf := bufferPool.Get().(*bytes.Buffer)
		buf.Write(data)
		buf.Reset()
		bufferPool.Put(buf)
	}
}

// 下面是不用Sync.Pool的代码
func BenchmarkBuffer(b *testing.B) {
	for n := 0; n < b.N; n++ {
		var buf bytes.Buffer
		buf.Write(data)
	}
}

select 结构的执行过程与实现原理

select 关键字是 Go 语言特有 的控制结构,它的实现原理比较复杂,需要 编译器 和 运行时 函数的通力合作。其执行过程包括 编译期间的优化 和 运行时的调度机制,以确保 select 语句的高效执行。

编译期间的优化

在编译期间,Go 语言会对 select 语句进行优化,根据 case 的不同选择不同的优化路径:

  1. 空的 select 语句
  • 会被转换成调用 runtime.block,直接挂起当前 Goroutine。
  1. 仅包含一个 case 的 select 语句

    • 编译器会将其转换成:
      if ch == nil { block }; n;
      
       - 先判断 `Channel` 是否为空,再执行 `case` 结构中的内容。
      
  2. 包含两个 case,其中一个是 default 的 select 语句

  • 会使用 runtime.selectnbrecv 和 runtime.selectnbsend,非阻塞地执行收发操作。
  1. 默认情况下(多个 case,无 default)

    • 通过 runtime.selectgo 获取执行 case 的索引,并通过多个 if 语句执行对应 case 代码。

运行时执行 runtime.selectgo 的流程

在编译器优化后,Go 语言在运行时执行 runtime.selectgo,具体流程如下:

  1. 生成遍历顺序
    • 随机生成 pollOrder 轮询顺序。
    • 根据 Channel 地址生成 lockOrder 锁定顺序。
  2. 轮询 case,查找可立即执行的 Channel
    • 遍历 pollOrder,检查是否有可以立刻处理的 Channel:
      • 如果存在:直接获取 case 对应的索引并返回。
      • 如果不存在:
        • 创建 runtime.sudog 结构体,将当前 Goroutine 加入所有相关 Channel 的收发队列。
        • 调用 runtime.gopark 挂起当前 Goroutine,等待调度器的唤醒。
  3. Goroutine 被调度器唤醒后
    • 按照 lockOrder 再次遍历所有 case,查找需要处理的 runtime.sudog 对应索引。

ErrGroup 原理

Go 方法和 Wait 方法

var g errgroup.Group
for _, url := range urls {
  url := url // capture range variable
  g.Go(func() error {
    return fetchURL(url)
  })
}

// 等待所有任务完成,并捕获错误
if err := g.Wait(); err != nil {
  fmt.Println("Error:", err)
} else {
  fmt.Println("All URLs fetched successfully")
}

原理

  • 一个 WaitGroup
  • 一个 sync.Once
  • 记录第一个 error
type Group struct {
    wg     sync.WaitGroup
    errOnce sync.Once
    err    error
}

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()
                }
            })
        }
    }()
}

Reference

Search

    Table of Contents