简洁而不简单的 sync.Once,你学会了吗?

sync.Once​ 的源代码只有短短十几行,看似简单的条件分支背后充斥着 并发执行​, 原子操作​, 同步原语 等基础原理, 深入理解这些原理之后,可以帮助我们更好地构建并发系统,解决并发编程中遇到的问题。

概述

sync.Once​ 可以保证在运行期间的某段程序只会执行一次,典型的使用场景有 初始化配置​, 数据库连接 等。

与 init 函数差异

  • • init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间
  • • sync.Once 方法可以在代码的任意位置初始化和调用,并发场景下是线程安全的,因此可以延迟到使用时再调用 (懒加载)

示例

通过一个小例子展示 sync.Once 的使用方法。

package main

import (
    "fmt"
    "sync"
)


// 数据库配置
type Config struct {
    Server string
    Port   int
}

var (
    once   sync.Once
    config *Config
)


// 初始化数据库配置
func InitConfig() *Config {
    once.Do(func() {
        fmt.Println("mock init ...") // 模拟初始化代码
    })

    return config
}

func main() {
    // 连续调用 5 次初始化方法
    for i := 0; i < 5; i++ {
        _ = InitConfig()
    }
}
$ go run main.go

# 输出如下
mock init ...

从输出的结果中可以看到,虽然我们调用了 5 次初始化配置方法,但是真正的初始化方法只执行了 1 次,实现了设计模式中 单例模式 的效果。

简洁而不简单的 sync.Once,你学会了吗?_第1张图片

内部实现

接下来,我们来探究一下 sync.Once​ 的内部实现,文件路径为 $GOROOT/src/sync/once.go​,笔者的 Go 版本为 go1.19 linux/amd64。

Once 结构体

package sync

import (
    "sync/atomic"
)

// Once 是一个只执行一次操作的对象
// Once 一旦使用后,便不能再复制
//
// 在 Go 内存模型术语中,once.Do(f) 中函数 f 的返回值会在 once.Do() 函数返回前完成同步
type Once struct {
    done uint32
    m    Mutex
}

sync.Once​ 的结构体有 2 个字段,m​ 表示持有一个互斥锁,这是并发调用场景下 只执行一次​ 的保证, done​ 字段表示调用是否已完成,使用的字段类型是 uint32​, 这样就可以使用标准库中 atomic​ 包里面 *Uint32 系列方法了,

为什么没有使用 bool​ 类型呢? 因为标准库中 atomic​ 包并未提供针对 bool​ 类型的相关方法,如果适用 bool​ 类型,操作时就需要转换为 指针​ 类型, 然后使用 atomic.*Pointer​ 系列方法操作,这样会造成内存占用过多 (bool​ 占用 1 个字节,指针 占用 8 个字节) 和性能损耗 (参数类型转换)。

done 字段

简洁而不简单的 sync.Once,你学会了吗?_第2张图片

sync.Once 结构体

done 作为结构体的第一个字段,能够减少 CPU 指令,也就是能够提升性能,具体来说:

热路径 hot path​ 是程序非常频繁执行的一系列指令,sync.Once​ 绝大部分场景都会访问 done​ 字段,所以 done​ 字段是处于 hot path​ 上的,这样一来 hot path 编译后的机器码指令更少,性能更高。

为什么放在第一个字段就能够减少指令呢?因为结构体第一个字段的地址和结构体的指针是相同的,如果是第一个字段,直接对结构体的指针解引用即可。如果是其他的字段,除了结构体指针外,还需要计算与第一个值的 偏移量。在机器码中,偏移量是随指令传递的附加值,CPU 需要做一次偏移值与指针的加法运算, 才能获取要访问的值的地址,因此访问第一个字段的机器码更紧凑,速度更快。

Do 方法

// 当且仅当第一次调用实例 Once 的 Do 方法时,Do 去调用函数 f
// 换句话说,调用 once.Do(f) 多次时,只有第一次调用会调用函数 f,即使 f 函数在每次调用中有不同的参数值

// 并发调用 Do 函数时,需要等到其中的一个函数 f 执行之后才会返回
// 所以函数 f 中不能调用同一个 once 实例的 Do 函数 (递归调用),否则会发生死锁
// 如果函数 f 内部 panic, Do 函数同样认为其已经返回,将来再次调用 Do 函数时,将不再执行函数 f
// 所以这就要求我们写出健壮的 f 函数
func (o *Once) Do(f func()) {
    // 下面是一个错误的实现
    // if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    //   f()
    // } 
    
    // 错误原因分析: 
    // 这里以数据库连接场景为例,在并发调用情况下,假设其中 1 个 goroutine 正在执行函数 f (初始化连接),
    // 此时其他的 goroutine 将不会等待这个 goroutine 执行完成,而是会直接返回,
    // 如果连接发生了一些延迟,导致函数 f 还未执行完成,那么此时连接其实还未建立,
    // 但是其他的 goroutine 认为函数 f 已经执行完成,连接已建立,可以开始使用了
    // 最后当其他 goroutine 使用未建立的连接操作时,产生报错

    // 要解决上面的问题, 就需要确保当前函数返回时, 函数 f 已经执行完成,
    // 这就是 slow path 退回到互斥锁的原因,以及为什么 atomic.StoreUint32 需要延迟到函数 f 返回之后
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f) // slow-path 允许内联
    }
}

 简洁而不简单的 sync.Once,你学会了吗?_第3张图片

错误实现示例 

doSlow 方法

func (o *Once) doSlow(f func()) {
    // 并发场景下,可能会有多个 goroutine 执行到这里
    o.m.Lock()  // 但是只有 1 个 goroutine 能获取到互斥锁
    defer o.m.Unlock()
    
    // 注意下面临界区内的判断和修改
    
    // 在 atomic.LoadUint32 时为 0 ,不等于获取到锁之后也是 0,所以需要二次检测
    // 因为已经获取到互斥锁,根据 Go 的同步原语约束,对于字段 done 的修改需要在获取到互斥锁之前同步
    // 所以这里直接访问字段即可,不需要调用 atomic.LoadUint32 方法
    // 如果有其他 goroutine 已经修改了字段 done,那么就不会进入条件分支,没有任何影响 
    if o.done == 0 {
        // 只要函数 f 成功执行过一次,就将 o.done 修改为 1
        // 这样其他 goroutine 就不会再执行了,从而保证了函数 f() 只会执行一次,
        // 这里必须使用 atomic.StoreUint32 方法来满足 Go 的同步原语约束
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

 简洁而不简单的 sync.Once,你学会了吗?_第4张图片

正确实现示例

小结

sync.Once​ 的源代码只有短短十几行,看似简单的条件分支背后充斥着 并发执行​, 原子操作​, 同步原语 等基础原理, 深入理解这些原理之后,可以帮助我们更好地构建并发系统,解决并发编程中遇到的问题。

Reference

  1. 1. Go sync.Once[1]

引用链接

[1]​ Go sync.Once: https://geektutu.com/post/hpg-sync-once.html

你可能感兴趣的:(java,开发语言)