go 源码解析 - sync.Once

106 阅读3分钟

个人主页:erlangtui.top

本文代码基于 go1.17.13,src/sync/once.go

一、简述

  • 保证某段代码在程序执行期间只执行一次;
  • 常用于服务启动时的配置初始化操作;

二、基本原理

  • 通过原子计数和互斥锁的方式,记录函数 f 执行的次数,当计数为 0 时,加锁、计数并执行函数 f,**即使函数 f panic 了也只会执行一次;
  • Once 首次使用后不允许被复制;

三、基本用法

package main

import (
	"sync"
)

func initConfig() {
}
func main() {
	var one sync.Once
	one.Do(initConfig)
}
  • 该函数只能作为参数传递给Do函数才能保证只会被执行一次,如果是在其他地方继续调用则无法保证;
  • 即使多次执行one.Do(initConfig)操作,该函数也只会被执行一次;

四、源码解读

1, Once

// Once 是一个对象,它将只执行一个操作
type Once struct {
	// done 表示是否已执行操作。
	// 因为它在热路径中使用,热路径在每个呼叫站点上都内联。
	// 将 done 放在第一位允许在某些架构 (amd64386) 上更紧凑的指令,而在其他架构上更少的指令(用于计算偏移)。
	done uint32
	m    Mutex
}
  • done原子计数,记录函数 f 执行的次数,它在结构中排在第一位,整个结构体变量的地址也就是该字段的地址,无需地址偏移就可以获取该字段的值;
  • m 互斥锁,在计数和执行 f 函数时被调用;

2, Do

// Do 当且仅当 Do 是首次为 Once 实例调用 Do 时,Do 才会调用函数 f。
// 换句话说,给定 var once Once,如果 once.Do(f) 被多次调用,只有第一次调用才会调用 f,即使 f 在每次调用中都有不同的值。
// 每个函数都需要一个新的 Once 实例才能执行。
// Do 用于必须只运行一次的初始化。config.once.Do(func() { config.init(filename) })
// 因为在对 f 的一次调用返回之前,对 Do 的调用不会返回,所以如果 f 导致调用 Do,它将死锁。
// 如果 f panic,Do 认为它已经返回了,未来再调用 Do 时将直接用返回而不调用 f。
func (o *Once) Do(f func()) {
	// 注意:这是 Do 的错误实现:
	//
	//	if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
	//		f()
	//	}
	//
	// Do 保证当它返回时,f 已经完成。
	// 此实现不会实现该保证:给定两个同时调用,cas 的获胜者将调用 f,第二个将立即返回,而无需等待第一个调用完成,此时 f 还没有完成。
	// 这就是为什么慢速路径回落到互斥锁的原因,互斥锁能让第二个阻塞等待,获得锁后发现已经执行完再立即返回,
	// 以及为什么 atomic.StoreUint32 必须延迟到 f 返回之后,保证先执行 f 再执行原子操作,只要 f 执行了,无论是否 panic 都执行 原子操作。

	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 panic 该 defer 也能执行成功,后续 Do 将不会再调用 f
		f()
	}
}
  • 采用原子操作判断done是否为 0,不为 0 则说明已经执行过 f 直接返回;
  • 为 0 则说明还没执行 f,转入慢路径执行;
  • 慢路径执行时,先加锁,再次判断done是否为 0,避免再加锁期间 f 被其他 goroutine 执行完成;
  • 采用 defer 函数使done原子加一,再执行 f 函数,即使函数 f panic 了,done 的加一操作也能执行成功;
  • 采用互斥锁而不是 cas 的原因是保证Done返回时,函数 f 一定是执行完成的
    • 互斥锁能够让其他 goroutine 阻塞等待,阻塞结束后 f 一定是执行完成的
    • 其他 goroutine 通过 cas 获取不到值时,则是直接返回,此时 f 可能还没有执行完成;