关于后端:Go-并发原语之简约的-Once

5次阅读

共计 2760 个字符,预计需要花费 7 分钟才能阅读完成。

Go 并发系列是依据我对晁岳攀老师的《Go 并发编程实战课》的排汇和了解整顿而成,如有偏差,欢送斧正~

本文纲要

Once 是什么

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

Once 的应用场景

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

在 MySQL 或者 Redis 这种频繁拜访数据的场景中,建设连贯的代价远远高于数据读写的代价,因而咱们会用单例模式来实现一次建设连贯,屡次拜访数据,从而实现晋升服务性能。

常见的单例写法如下:

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。

Once 如何应用

Once 的应用很简略,只有一个对外裸露 Do(f func()) 办法,参数是函数。Do 函数只有被调用过一次,之后无论怎么调用,参数怎么变动,都不会失效

所以,对于上文的例子,如果咱们应用 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 篇原创内容

公众号


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

正文完
 0