Go sync.Once 很简单... 真的是这样吗?

文摘   2024-11-20 13:54   美国  


Go sync.Once 很简单...真的是这样吗?

sync.Once 可能是最容易使用的同步原语,但其内部可能比你想象的更复杂。

通过同时使用原子操作和互斥锁,我们有了一个很好的机会来理解它的工作原理。

在这次讨论中,我们将分解 sync.Once 是什么,如何正确使用它,以及最重要的是,它在底层是如何工作的。我们还将看看它的"亲戚":OnceFuncOnceValue[T]OnceValues[T, K]

什么是 sync.Once?

当你需要一个函数只运行一次,无论它被调用多少次或有多少个 goroutine 同时访问它时,sync.Once 就是你要找的工具。

它非常适合初始化单例资源,这些资源在应用程序生命周期中只应该发生一次,比如设置数据库连接池、初始化日志记录器或配置指标系统等。

var once sync.Once
var conf Config

func GetConfig() Config {
    once.Do(func() {
        conf = fetchConfig()
    })
    return conf
}

如果多次调用 GetConfig()fetchConfig() 只会执行一次。

sync.Once 的真正好处是延迟某些操作直到首次需要(懒加载),这可以提高运行时性能并减少初始内存使用。例如,如果只有在首次访问时才创建大型查找表,那么创建表的内存和处理时间将被节省。

大多数情况下,它比使用 init() 更适合初始化(外部)资源。

现在,需要记住一件重要的事:一旦你使用 sync.Once 对象运行了一个函数,那就是全部了——你不能重复使用它。一旦完成,就完成了。

sync.Once 成功使用 Do(f) 运行函数后,它会在内部用一个标志(done)将自己标记为"完成",从那时起,任何进一步调用 Do(f) 都不会再次运行该函数,即使是不同的函数。

var once sync.Once

func main() {
    once.Do(func() {
        fmt.Println("This will be printed once")
    })

    once.Do(func() {
        fmt.Println("This will not be printed")
    })
}

// Output:
// This will be printed once

没有内置方法可以重置 sync.Once。一旦完成任务,它就永久退役。

现在,这里有一个有趣的转折。如果你传递给 Once.Do 的函数在运行时发生 panic,sync.Once 仍然将其视为"任务完成"。这意味着将来对 Do(f) 的调用不会再次运行该函数。这可能会很棘手,尤其是当你试图捕获 panic 并在之后处理错误时,没有重试机会。

另外,如果你需要处理可能从 f 中出现的错误,写起来可能会有点尴尬:

var once sync.Once
var config Config

func GetConfig() (Config, error) {
    var err error
    once.Do(func() {
        config, err = fetchConfig() 
    })
    return config, err
}

这只在第一次有效。

问题是,如果 fetchConfig() 失败,只有第一次调用会收到错误。任何后续调用——第二次、第三次等——都不会返回该错误(正如  venicedreamway[7]  指出的)。要使行为在所有调用中保持一致,我们需要将 err 设为包级变量,像这样:

var once sync.Once
var config Config
var err error

func GetConfig() (Config, error) {
    var err error
    once.Do(func() {
        config, err = fetchConfig() 
    })
    return config, err
}

好消息是,从 Go 1.21 开始,我们得到了 OnceFuncOnceValueOnceValues

这些基本上是 sync.Once 的便捷包装器,使事情变得更顺畅,而不牺牲任何性能。OnceFunc 很直接,它接受你的函数 f 并将其包装在另一个函数中,你可以多次调用该函数,但 f 本身只运行一次:

var wrapper = sync.OnceFunc(printOnce)

func printOnce() {
  fmt.Println("This will be printed once")
}

func main() {
  wrapper()
  wrapper()
}

// Output:
// This will be printed once

即使你多次调用 wrapper()printOnce() 也只在第一次调用时运行。

现在,如果 f() 在第一次执行时发生 panic,每次后续调用 wrapper() 都会以相同的错误 panic。就像它锁定了失败状态,所以当某些关键内容没有正确初始化时,你的应用程序不会像什么都没发生一样继续运行。

但这并不能很好地解决捕获错误的问题。

让我们继续讨论更有用的内容:OnceValueOnceValues。它们很酷,因为它们会记住 f 的第一次执行结果,并在后续调用中直接返回缓存的结果。

var getConfigOnce = sync.OnceValue(func() Config {
	fmt.Println("Loading config...")
	return fetchConfig() // Pretend this is expensive
})

func main() {
	config1 := getConfigOnce() // Loading config...
	config2 := getConfigOnce() // No print, just returns the cached config
  ...
}

// Output:
// Loading config...

在第一次调用 getConfigOnce() 后,它只会返回相同的结果,而不会重新运行 fetchConfig()。除了惰性加载,这里不需要处理闭包。

但错误呢?获取某些内容通常涉及错误处理。

这就是 sync.OnceValues 的用武之地。它的工作方式类似于 OnceValue,但允许返回多个值,包括错误。因此,你可以缓存结果和第一次运行期间出现的任何错误。

var config Config

var getConfigOnce = sync.OnceValues(fetchConfig)

func main() {
  var err error

  config, err = getConfigOnce()
  if err != nil {
    log.Fatalf("Failed to fetch config: %v", err)
  }
  ...
}

现在,getConfigOnce() 的行为就像一个普通函数 — 它仍然是并发安全的,缓存结果,并且只在第一次运行时产生开销。之后,每次调用都很便宜。

"那么,如果发生错误,错误也会被缓存吗?"

不幸的是,是的。无论是 panic 还是错误,结果和失败状态都会被缓存。因此,调用代码需要意识到可能正在处理缓存的错误或失败。如果需要重试,你需要从 sync.OnceValues 创建一个新实例以重新运行初始化。

另外,在示例中,我们返回错误作为第二个值以匹配我们习惯的函数签名,但实际上,这取决于你的需求,可以是任何内容。

它是如何工作的?

如果你不熟悉 Go 中的原子操作或同步技术,那么 sync.Once 是一个很好的起点,因为它是最简单的同步原语之一。

sync.Mutex 相比,它真的很简单,后者是 Go 中 最棘手的同步[0] 工具之一。所以让我们退一步,思考 sync.Once 是如何实际实现的。

以下是 sync.Once 的基本结构:

type Once struct {
	done atomic.Uint32
	m    Mutex
}

你会注意到的第一件事是 done 字段,它使用原子操作。

有一个有趣的细节,done 被放在结构体的最顶部是有原因的。在许多 CPU 架构(如 x86-64)中,访问结构体中的第一个字段更快,因为它位于内存块的基地址。这种小优化允许 CPU 更直接地加载第一个字段,无需计算内存偏移。

另外,将其放在顶部有助于内联优化,我们稍后会讨论。

那么,实现 sync.Once 以确保函数只运行一次,首先想到的是什么?使用互斥锁,对吧?

func (o *Once) Do(f func()) {
	o.m.Lock()
	defer o.m.Unlock()

	if o.done.Load() == 0 {
		o.done.Store(1)
		f()
	}
}

这很简单并且能完成工作。其思路是互斥锁(o.m.Lock())只允许一个协程同时进入临界区。然后,如果 done 仍为 0(表示函数尚未运行),它将 done 设置为 1 并运行函数 f()

这实际上是 Rob Pike 在 2010 年编写的 sync.Once 的原始版本。

现在,我们刚刚看到的版本可以正常工作,但性能不是最佳的。即使在第一次调用后,每次调用 Do(f) 时仍然需要获取锁,这意味着协程彼此等待。通过在任务已完成时添加快速退出,我们肯定可以做得更好。

func (o *Once) Do(f func()) {
  if atomic.LoadUint32(&o.done) == 1 {
    return
  }

  // slow path
  o.m.Lock()
  defer o.m.Unlock()

  if o.done.Load() == 0 {
    o.done.Store(1)
    f()
  }
}

这给了我们一个快速路径,当 done 标志被设置时,我们完全跳过锁并立即返回。很快很简单。但是,如果标志未设置,我们会回退到慢速路径,锁定互斥锁,重新检查 done,然后运行函数。

现在,我们必须在获取锁后重新检查 done,因为在检查标志和实际锁定互斥锁之间存在一个小窗口,在这期间另一个 goroutine 可能已经运行了 f() 并设置了标志。我们还在调用 f() 之前设置 done 标志。其想法是即使 f() 发生 panic,我们仍将其标记为"成功"以防止再次运行。

但是,这个操作也是一个错误。

想象这个场景,我们将 done 设置为 1,但 f() 尚未完成,可能它正卡在一个长时间的网络调用上。

sync.Once 的竞争条件

现在,第二个 goroutine 过来,检查标志,看到它已被设置,错误地认为:"太好了,资源已准备就绪!"但实际上,资源仍在获取中。那么会发生什么?空指针解引用和 panic!资源尚未就绪,系统却过早尝试使用它。

我们可以通过使用 defer 来修复这个问题:

func (o *Once) Do(f func()) {
  if o.done.Load() == 1 {
    return
  }

  // slow path
  o.m.Lock()
  defer o.m.Unlock()

  if o.done.Load() == 0 {
    defer o.done.Store(1)
    f()
  }
}

你可能会想:"好吧,现在看起来相当可靠了。"但它仍然不完美。

这个想法是 Go 支持所谓的内联优化。

如果一个函数足够简单,Go 编译器会"内联"它,意味着它会将函数的代码直接粘贴到调用函数的地方,使其更快。我们的 Do() 函数仍然太复杂,无法内联,因为它有多个分支、defer 和函数调用。

为了帮助编译器更好地决定内联代码,我们可以将慢路径逻辑移动到另一个函数:

func (o *Once) Do(f func()) {
	if o.done.Load() == 0 {
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()

	if o.done.Load() == 0 {
		defer o.done.Store(1)
		f()
	}
}

这使得 once.Do() 函数变得更加简单,可以被编译器内联。

尽管从我们的角度看,现在有 2 个函数调用,但实际上并非如此。o.done.Load() 是一个原子操作,Go 的编译器以特殊方式处理(编译器内在函数),所以它不计入函数调用复杂性。

"为什么不直接内联 doSlow()?"

原因是在第一次调用 Do(f) 之后,常见的场景是快速路径 — 只是检查函数是否已运行。

在实际应用中,在 f() 运行一次(这是慢路径)之后,通常会有更多调用 once.Do(f) 的情况,只需快速检查 done 标志而不锁定或重新运行函数。

这就是为什么我们为快速路径进行优化,在那里我们只需检查是否已完成并立即返回。还记得我们讨论过为什么 done 字段放在 Once 结构体的第一个位置吗?这是因为它通过更容易访问使快速路径更快。

现在,我们有了 sync.Once 的完美版本,但这里是最后的测验。Go 团队还提到了一个使用比较并交换(CAS)操作的实现版本,这使我们的 Do() 函数变得更加简单:

func (o *Once) Do(f func()) {
	if o.done.CompareAndSwap(0, 1) {
		f()
	}
}

其思想是哪个 goroutine 能成功将 done 的值从 0 交换到 1,就会"赢得"竞争并运行 f(),而所有其他 goroutine 只会返回。

但为什么 Go 团队不使用这个版本?在阅读下一节之前,你能猜出原因吗?正如我们已经讨论过的这个错误。

是的,这让我们回到了我们之前讨论过的同一个错误:

使用比较并交换(CAS)操作的 sync.Once

当"获胜"的 goroutine 仍在运行 f() 时,其他 goroutine 可能会来检查 done 标志,认为 f() 已经完成,并继续使用尚未完全准备好的资源。

就是这样!sync.Once 在实现和使用上都很简单,但事实证明要正确实现它是相当棘手的。

参考链接

  1. Go sync.Mutex:正常模式和饥饿模式: https://victoriametrics.com/blog/go-sync-mutex
  2. Go sync.WaitGroup 和对齐问题: https://victoriametrics.com/blog/go-sync-waitgroup
  3. Go sync.Pool 及其机制: https://victoriametrics.com/blog/go-sync-pool
  4. Go sync.Cond,最被忽视的同步机制: https://victoriametrics.com/blog/go-sync-cond
  5. Go sync.Map:为正确的工作选择正确的工具: https://victoriametrics.com/blog/go-sync-map
  6. Go Singleflight 在代码中融化,而不是在数据库中: https://victoriametrics.com/blog/go-singleflight
  7. venicedreamway: https://www.reddit.com/user/venicedreamway/
  8. X(@func25): https://twitter.com/func25
  9. Go 数组的工作原理及 For-Range 的巧妙之处: https://victoriametrics.com/blog/go-array
  10. Go 切片:要么大,要么回家: https://victoriametrics.com/blog/go-slice
  11. Go Maps 详解:键值对实际是如何存储的: https://victoriametrics.com/blog/go-map
  12. Golang Defer:从基础到陷阱: https://victoriametrics.com/blog/defer-in-go
  13. 深入 Go 的 Unique 包:字符串内部化简化: https://victoriametrics.com/blog/go-unique-package-intern-string
  14. 依赖管理,或 go mod vendor:这是什么?: https://victoriametrics.com/blog/vendoring-go-mod-vendor
  15. VictoriaMetrics: https://docs.victoriametrics.com/

幻想发生器
图解技术本质
 最新文章