Simple but not simple sync.Once, have you learned it?

The source code of sync.Once is only a dozen or so lines. The seemingly simple conditional branch is full of basic principles such as concurrent execution, atomic operations, and synchronization primitives. After a deep understanding of these principles, it can help us better Construct concurrent systems and solve problems encountered in concurrent programming.

overview

sync.Once​ can ensure that a certain program will only be executed once during operation. Typical usage scenarios include initialization configuration​, database connection, etc.

Difference from init function

  • • The init function is executed when the package is loaded for the first time. If it is not used for a long time, it will waste memory and prolong the program loading time
  • • The sync.Once method can be initialized and called anywhere in the code. It is thread-safe in concurrent scenarios, so it can be delayed until it is used (lazy loading)

example

Show how to use sync.Once with a small example.

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 ...

As can be seen from the output results, although we have called the initialization configuration method 5 times, the real initialization method is only executed once, realizing the effect of the singleton mode in the design mode.

internal implementation

Next, let's explore the internal implementation of sync.Once​, the file path is $GOROOT/src/sync/once.go​, and the author's Go version is go1.19 linux/amd64.

Once structure

package sync

import (
    "sync/atomic"
)

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

The sync.Once​ structure has 2 fields, m​ means holding a mutex, which is a guarantee that it will only be executed once in a concurrent call scenario, and the done​ field indicates whether the call has been completed, and the field type used is uint32 ​, so that you can use the *Uint32 series methods in the atomic package in the standard library.

Why not use the bool​ type? Because the atomic package in the standard library does not provide related methods for the bool type, if the bool type is applicable, it needs to be converted to a pointer type when operating, and then use the atomic.*Pointer​ series of methods to operate, which will cause memory occupation Excessive (bool​ occupies 1 byte, pointer occupies 8 bytes) and performance loss (parameter type conversion).

done field

sync.Once structure

done, as the first field of the structure, can reduce CPU instructions, that is, can improve performance, specifically:

The hot path hot path is a series of instructions that the program executes very frequently. Most of the sync.Once scenarios will access the done field, so the done field is on the hot path. In this way, the compiled machine of the hot path Fewer code instructions and higher performance.

Why can it reduce instructions by putting it in the first field? Because the address of the first field of the structure is the same as the pointer of the structure, if it is the first field, just dereference the pointer of the structure directly. If it is other fields, in addition to the structure pointer, it is also necessary to calculate the offset from the first value. In the machine code, the offset is an additional value passed along with the instruction. The CPU needs to perform an addition operation of the offset value and the pointer to obtain the address of the value to be accessed. Therefore, the machine code for accessing the first field is more compact. faster.

Do method

// 当且仅当第一次调用实例 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 允许内联
    }
}

 

Example of wrong implementation 

doSlow method

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()
    }
}

 

Correct implementation example

summary

The source code of sync.Once is only a dozen or so lines. The seemingly simple conditional branch is full of basic principles such as concurrent execution, atomic operations, and synchronization primitives. After a deep understanding of these principles, it can help us better Construct concurrent systems and solve problems encountered in concurrent programming.

Reference

  1. 1. Go sync.Once[1]

quote link

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

Guess you like

Origin blog.csdn.net/weixin_42232156/article/details/129932697