并发编程(六) - Once: 只执行一次的操作

问题引入

在编程中,我们经常遇到一些场景,有些操作只需要执行一次。例如:加载配置配件,初始化连接池等。在这些场景中,初始化完成后,后续则直接使用。常见的实现方案有:全局变量初始化、init 中执行初始化操作、main 函数中调用初始化操作。在这三种方案中,均可以保证资源只被初始化一次。

csharp 复制代码
 // 1: 变量声明时直接赋值
var config1 = "config"

// 2: init 函数中赋值
var config2 string

func init() {
    config2 = "config"
}

// 3: main 函数启动时调用
var config3 string

func initConfig() {
    config3 = "config"
}

func main() {
    initConfig()
}

有时候我们希望延迟加载,即只有在使用的时候才去加载内容,从而节省一定的资源。这时就需要一个工具类,支持延迟加载,且并发场景下只能执行一次。

使用示例

针对上面的问题,可以使用 go 提供 Once 工具类。它只提供了一个 Do(f func()) 方法,同时保证了 f 在并发场景下只能执行一次。且后续的 Do 调用会阻塞,直到 f 执行完成。

我们可以使用 Once 来完成对 config 的延迟加载。

go 复制代码
var config string
var once sync.Once

func getConfig() string {
    once.Do(func() {
       config = "name"
    })
    return config
}

func main() {
    // 第一次调用执行 f
    fmt.Println(getConfig())
    // 第二次调用,不执行 f,直接 return; 如果第一次调用中的 f 没有执行完则阻塞等待
    fmt.Println(getConfig())
}

实现原理

Go 一如既往的秉持大道至简的原则,只暴露一个 Do 方法,方便使用者理解。实现的难点在于并发场景下 f 只执行一次,同时后续的调用需要等待 f 执行完成。下面看一下 Once 结构体:

go 复制代码
// done 用于标识 Do(f func()) 是否执行完成,m 用于控制阻塞等待 f 的并发执行
type Once struct {
     // done indicates whether the action has been performed.
     // It is first in the struct because it is used in the hot path.
     // The hot path is inlined at every call site.
     // Placing done first allows more compact instructions on some architectures (amd64/386),
     // and fewer instructions (to calculate offset) on other architectures.
    done uint32
    m    Mutex
}

Do 函数源码。使用原子操作快速判断 f 是否已经执行过。如果没有执行过,或者正在执行,则尝试获取 m。如果 f 未开始执行,获取 m,开始执行 f。如果 f 正在执行中,则阻塞等待 m 释放。获取到 m 后,二次判断 done。这里需要注意的是,在设置 done 是使用了原子操作。是为了保证 fast-path 中的数据竞争问题。

scss 复制代码
func (o *Once) Do(f func()) {
    // Note: Here is an incorrect implementation of Do:
     //
     // if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
     //    f()
     // }
     //
     // Do guarantees that when it returns, f has finished.
     // This implementation would not implement that guarantee:
     // given two simultaneous calls, the winner of the cas would
     // call f, and the second would return immediately, without
     // waiting for the first's call to f to complete.
     // This is why the slow path falls back to a mutex, and why
     // the atomic.StoreUint32 must be delayed until after f returns.

    if atomic.LoadUint32(&o.done) == 0 {
           // Outlined slow-path to allow inlining of the fast-path.
            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()
    }
}

注意事项

不可复制

和 Mutex、RWMutex 一样是不可复制的。

死锁

Once 在第一次执行时执行 f,如果 f 中继续调用 Once 则会导致循环等待而死锁。

go 复制代码
func main() {
    var o sync.Once
    o.Do(func() {
       o.Do(func() {
          println("done")
       })
    })
}

f 只能执行一次

Once 保证的是当前变量只会执行一次 f,与 f 的具体值没有关系。

go 复制代码
func main() {
    var o sync.Once
    o.Do(func() {
       println("A")
    })
    o.Do(func() {
       println("B")
    })
}

f 发生 panic

Once 并未处理函数中的 panic。因此,需要业务方在定义 f 的时候做好 panic 处理的逻辑。

go 复制代码
func main() {
    var o sync.Once
    o.Do(func() {
       panic("err")
    })
}

once 扩展

OnceFunc

Once 解决了开发者常见的延迟加载问题。但也会遇到一些问题。由于传递的 f 并没有返回值,导致开发者很难感知或处理异常情况。例如下面这个 case,如果 f 执行时发生了 panic,后续的调用仍然可以正常执行。

go 复制代码
func main() {
    var o sync.Once
    var wg sync.WaitGroup
    f := func() { panic("err") }
    wg.Add(1)
    go func() {
       defer func() {
          if err := recover(); err != nil {
             println("once do f panic:", err)
          }
          wg.Done()
       }()
       o.Do(f)
    }()
    wg.Wait()
    // 可以正常执行, 因为已经执行过 f
    o.Do(f)
}

针对这个场景可以使用 OnceFunc:返回一个函数,保证在并发场景下 f 只会执行一次,如果 f 发生 panic,会保证每次调用都会发生 panic。

go 复制代码
func OnceFunc(f func()) func()
go 复制代码
func main() {
    var wg sync.WaitGroup
    f := func() { panic("err") }
    onceF := sync.OnceFunc(f)
    wg.Add(1)
    go func() {
       defer func() {
          if err := recover(); err != nil {
             println("once do f panic:", err)
          }
          wg.Done()
       }()
       onceF()
    }()
    wg.Wait()
    // 由于执行 f 时发生 panic,后续调用均会 panic
    onceF()
}

OnceValue

OnceValue 是与 OnceFunc 对应的,用于执行带返回参数的 f。语义和 OnceFunc 一样,如果 f 发生 panic ,则每次调用均会 panic。

go 复制代码
func OnceValue[T any](f func() T) func() T
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)

使用 OnceValue 使用延迟加载 config。

go 复制代码
var configOnceValue = sync.OnceValue[string](func() string {
    return "config"
})

func getConfig() string {
    return configOnceValue()
}

func main() {
    // 第一次调用执行 f
fmt.Println(getConfig())
    // 第二次调用,不执行 f,直接 return f 的结果; 如果第一次调用中的 f 没有执行完则阻塞等待
fmt.Println(getConfig())
}

总结

Once 是一个很简单但却非常实用的工具类。通常用来解决并发场景下 initOnce 的问题。OnceFunc 和 Once 其实是相互补充,核心区别就开如何对 panic 进行处理。

相关推荐
2401_854391085 分钟前
高效开发:SpringBoot网上租赁系统实现细节
java·spring boot·后端
Cikiss14 分钟前
微服务实战——SpringCache 整合 Redis
java·redis·后端·微服务
Cikiss15 分钟前
微服务实战——平台属性
java·数据库·后端·微服务
OEC小胖胖29 分钟前
Spring Boot + MyBatis 项目中常用注解详解(万字长篇解读)
java·spring boot·后端·spring·mybatis·web
2401_857617621 小时前
SpringBoot校园资料平台:开发与部署指南
java·spring boot·后端
计算机学姐1 小时前
基于SpringBoot+Vue的在线投票系统
java·vue.js·spring boot·后端·学习·intellij-idea·mybatis
Yvemil72 小时前
MQ 架构设计原理与消息中间件详解(二)
开发语言·后端·ruby
2401_854391082 小时前
Spring Boot大学生就业招聘系统的开发与部署
java·spring boot·后端
虽千万人 吾往矣3 小时前
golang gorm
开发语言·数据库·后端·tcp/ip·golang
这孩子叫逆3 小时前
Spring Boot项目的创建与使用
java·spring boot·后端