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

开发 前端
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​ 的内部实现,文件路径为 $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 结构体

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 允许内联
}
}

图片

错误实现示例

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

Reference

  1. 1. Go sync.Once[1]

引用链接

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

责任编辑:武晓燕 来源: 洋芋编程
相关推荐

2021-08-29 18:13:03

缓存失效数据

2023-06-06 08:28:58

Sync.OnceGolang

2022-07-08 09:27:48

CSSIFC模型

2024-01-19 08:25:38

死锁Java通信

2023-07-26 13:11:21

ChatGPT平台工具

2024-02-04 00:00:00

Effect数据组件

2023-01-10 08:43:15

定义DDD架构

2024-02-02 11:03:11

React数据Ref

2024-01-02 12:05:26

Java并发编程

2023-08-01 12:51:18

WebGPT机器学习模型

2023-06-06 07:50:07

权限管理hdfsacl

2023-01-30 09:01:54

图表指南图形化

2023-10-10 11:04:11

Rust难点内存

2023-12-12 08:02:10

2022-11-08 08:45:30

Prettier代码格式化工具

2022-12-06 08:37:43

2022-06-16 07:50:35

数据结构链表

2024-03-06 08:28:16

设计模式Java

2023-01-31 08:02:18

2023-08-26 21:34:28

Spring源码自定义
点赞
收藏

51CTO技术栈公众号