Go 并发原语之简约的 Once

Go 并发系列是根据我对晁岳攀老师的《Go 并发编程实战课》的吸取和理解整理而成,若有误差,欢迎指正~

本文大纲

编程

图片

Once 是什么

Once 是 Go 内置库 sync 中一个比较简单的并发原语。顾名思义,它的做用就是执行那些只须要执行一次的动做。安全

Once 的使用场景

Once 最典型的使用场景就是单例对象的初始化。闭包

在 MySQL 或者 Redis 这种频繁访问数据的场景中,创建链接的代价远远高于数据读写的代价,所以咱们会用单例模式来实现一次创建链接,屡次访问数据,从而实现提高服务性能。并发

常见的单例写法以下:tcp

package mainimport (    "net"    "sync"    "time")// 使用互斥锁保证线程(goroutine)安全var connMu sync.Mutexvar conn net.Connfunc getConn() net.Conn {    connMu.Lock()    defer connMu.Unlock()    // 返回已建立好的链接    if conn != nil {        return conn    }    // 建立链接    conn, \_ = net.DialTimeout("tcp", "baidu.com:80", 10\*time.Second)    return conn}// 使用链接func main() {    conn := getConn()    if conn == nil {        panic("conn is nil")    }}

这个例子中,在建立 tcp 链接的时候,使用了单例模式。使用单例模式的注意点是创建链接的时候,须要对这个过程加锁函数

单例模式有个问题,每次都会进行加锁、解锁操做,对性能的消耗比较大,而 Once 能够解决这个问题。性能

Once 除了 用来初始化单例资源,应用场景还有并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源测试

总之,凡是只须要初始化一次的资源,均可以用 Once。ui

Once 如何使用

Once 的使用很简单,只有一个对外暴露 Do(f func()) 方法,参数是函数。Do 函数只要被调用过一次,以后不管怎么调用,参数怎么变化,都不会生效atom

因此,对于上文的例子,若是咱们使用 Once,要怎么写呢?

示例代码以下:

package mainimport (    "net"    "sync"    "time")var conn net.Connvar once sync.Oncefunc getConn() net.Conn {    once.Do(func() {        // 建立链接    conn, \_ = net.DialTimeout("tcp", "baidu.com:80", 10\*time.Second)    })    return conn}// 使用链接func main() {    conn := getConn()    if conn == nil {        panic("conn is nil")    }}

在这段代码中,咱们经过 once.Do() 实现了前面的单例模式。

这里面三点须要注意,一是 once 的做用域和资源变量 conn 的做用域要保持一致;二是对于同一个 once,仅第一次 Do(f) 中的 f 会被执行,第二次哪怕换成 Do(f1),f1 也不会被执行;三是 Do(f) 的参数 f 是一个无参数无返回值的函数

第一点比较好理解,若是 once 的做用域只在某个函数中生效,显然是能够在多个函数中执行 once.Do() 的。

第二点强调的是,once.Do() 只生效一次指的是 Do() 函数只会执行一次,不会区分参数中的函数 f。

第三点 f 是无参数无返回值的函数。若是有参数要怎么处理呢?你能够将参数改为全局变量,或者用闭包实现 once.Do(),像这样:

func closureF(x int ) func() {    return func() {        fmt.Println(x)    }}func main() {    var once sync.Once    x := 4    once.Do(closureF(x))}

Once 的实现原理

Once 实现的功能很简单,所以不少人会想固然的认为很容易实现。

一开始我也这么想的。。

好比,经过一个全局变量来作标记,执行过一次 Do 函数,就修改标记的状态,以后再执行的时候根据标记的状态来决定是否须要真正执行函数。

这种作法有个很大的问题,若是 Do(f) 中 f 执行速度很慢,标记位的状态已经被修改了,后来的 goroutine 就会觉得资源初始化已经完成,可是实际上啥也获取不到。

下面让咱们看一看 Once 究竟是如何实现的。

Once 实现源码

type Once struct {    done uint32    m    Mutex}func (o \*Once) Do(f func()) {    if atomic.LoadUint32(&o.done) == 0 {        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()    }}

先看 Once 的结构体定义:一个 done 标志位 + 一个锁。done 用来记录资源初始化是否完成,锁用来保证同一时刻,只能有一个 goroutine 进行资源的初始化。

因为这里使用了 done 标志位,锁只有在资源初始化的时候才会调用,其它时候并不会调用,所以性能相比单例模式的原始写法,要高很多。

再看 Do() 函数的实现。这里使用了函数内联的方式,只有发现 done 是 0 的状况下,才会执行 doSlow() 函数。

doSlow() 中又再次检查了 done 的值,这就是双检查机制。并发场景下,done 值的检查和修改必须先持有锁才行。

总体看,Once 的实现仍是比较简单的。在实践中,不多会出现 Once 使用错误的状况,可是有两种场景,仍是要特殊注意下。

使用 Once 的 2 种错误

死锁

因为 Once 的定义中有互斥锁,若是出现 once.Do(f) 执行 f,f再执行 once.Do(f) 的场景,就出现了相似重入的现象,形成死锁,好比下面:

func main() {    var once sync.Once    once.Do(func() {        once.Do(func() {            fmt.Println("初始化")        })    })}

这种状况下,m.Lock 等待 m.Lock,造成了死锁。要避免这种状况,只要 f 中不执行 once.Do() 就行。

初始化失败

若是 f 执行异常,资源初始化失败,Once 仍是会认为执行成功,而再次执行 Do(f) 的时候,f 也不会再被执行,致使接下来直接使用初始化的资源时候异常。

这种状况下该如何处理呢?

能够本身实现一个相似的 Once 原语!! 只有当资源初始化成功,done 的值才置成 1, 不然不变。

固然,还有一点须要注意的是,自定义 Once 的结构体中,须要再加一个标志代表是否初始化成功,不然若是初始化失败,再继续后面的流程,很容易出现 panic。

图片

码农的自由之路

码农的自由之路

996的码农,也能自由~

47篇原创内容

公众号


都看到这里了,不如点个 赞/在看?

相关文章
相关标签/搜索