Go并发编程之美-sync.once原理剖析

Go并发编程之美-sync.once原理剖析,第1张

一、前言

有时,我们需要让某个函数只执行一次,那么如何来保证那?一般是我们自己写个原子变量来控制函数是否已经执行过,这样可行,但是需要我们自己实现这非业务逻辑的功能,其实go SDK中提供了sync.once()方法,帮我们封装了这层 *** 作。

二、自己实现函数只执行一次

如下代码,我们在main方法内通过循环语句开启新goroutine异步调用sayHello()方法,运行后我们会看到输出100句 hello,world。

func sayHello() {
    fmt.Println("hello,world")
}
func main() {
    for i := 0; i < 100; i++ {
        go sayHello()
    }


    // go中main goroutine退出,则整个进程就退出
    //所以这里我们简单休眠,等待子goroutine执行完毕
    time.Sleep(time.Minute)
}

如果我们想要实现虽然调用100次sayHello()但是只输出一条hello,world,一个可能的实现是这样的:

var flag int32
func sayHello() {
    if atomic.CompareAndSwapInt32(&flag, 0, 1) {
        fmt.Println("hello,world")
    }
}
func main() {
    for i := 0; i < 100; i++ {
        go sayHello()
    }


    // go中main goroutine退出,则整个进程就退出
    //所以这里我们简单休眠,等待子goroutine执行完毕
    time.Sleep(time.Minute)
}

如上代码,我们创建了一个flag变量,然后再sayHello方法内使用CAS设置flag的值,由于flag的初始化值为0,所以当多个goroutine执行CAS时,只有一个goroutine可以把flag的值从0设置为1,其他的goroutine执行CAS会返回FALSE,这保证了只有一个goroutine可以打印hello,world.

上面的实现我们把CAS *** 作放到了sayHello方法内部了,假如其他函数也需要实现只执行一次我们还需要修改其函数内容,显然这不可取。所以我们打算抽取一个方法,让执行一次的逻辑独立出来,对所有函数都适用,一个可能的实现如下:

var flag int32
func onceCas(f func()) {
    if atomic.CompareAndSwapInt32(&flag, 0, 1) {
        f()
    }
}
func main() {
    for i := 0; i < 100; i++ {
        go onceCas(sayHello)
    }
}

如上代码,我们创建了onceCas(即基于CAS实现的once执行功能)函数,其入参为func(),内部则基于CAS保证函数f只执行一次。这看起来很好,确实可以解决上面的一类问题,但是下面让我们看一个例子,看看其存在的一些不足:

func onceCas(f func()) {
    if atomic.CompareAndSwapInt32(&flag, 0, 1) {
        f()
    }
}


var u *user
func getInstance() {
    u = &user{Name: "jiaduo"}
}


func main() {
    //1.多goroutine并发调用获取实例
    for i := 0; i < 100; i++ {
        go onceCas(getInstance)
    }


    // 2如果flag为1则打印u实例
    if flag == 1 {
        fmt.Printf("user,%+v", u)
    }
}

如上代码声明了一个全局的user变量u,getInstance函数用来创建一个实例,代码1则多goroutine并发调用onceCase函数,意图只调用getInstance一次,保证u的唯一性。代码2则判断如果flag值为1,则打印u的内容,那么问题是,如果flag确实为1,那么打印的u的内容一定是user,{Name:jiaduo}?

其实不然,因为如下onceCas函数的CAS *** 作只可以保证函数f只执行一次,但是不能保证CAS成功后也就是flag=1时,函数f已经被执行完毕了。

func onceCas(f func()) {
    if atomic.CompareAndSwapInt32(&flag, 0, 1) {
        f()
    }
}

所以onceCas并不适合上面的场景。

那么假如我们不根据flag进行判断,而是根据u !=nil才打印那?

var u *user = nil


func getInstance() {
    u = &user{Name: "jiaduo"}
}


func main() {


    // 多goroutine并发调用获取实例
    for i := 0; i < 100; i++ {
        go onceCas(getInstance)
    }


    if u != nil {
        fmt.Printf("user,%+v", u)
    }
}

如上代码,我们可以保证 u != nil时,一定可以打印出user,{Name:jiaduo}?

答案其实也是否定的,这是因为函数getInstance中的

u = &user{Name: "jiaduo"}

*** 作不具有原子性,其执行可以分为三步:创建user对象分配内存,初始化user对象,然后把指针变量赋值为变量u。

由于指令重排序问题的存在,其执行顺序可能变成了:创建user对象分配内存,把指针变量赋值为变量u,初始化user对象。

所以即使我们判断 u!=nil但是打印出来的内容,可能还是u没初始化前的内容。

三、sync.once 3.1 sync.once 使用

go sdk内提供了sync.once函数可以帮我们解上面的问题,其可以保证传入sync.once的函数只执行一次,并且可以保证传入的函数执行期间,其他调用该once函数的goroutine必须阻塞等到传入的函数执行完毕。

修改代码如下:

var once sync.Once


func main() {


    // 多goroutine并发调用获取实例
    for i := 0; i < 100; i++ {
        go once.Do(getInstance)
    }


    if u != nil {
        fmt.Printf("user,%+v", u)
    }
}

如上我们通过调用once.Do方法保证getInstance方法只会被执行一次,并且在getInstance执行期间其他调用once.Do(getInstance)的goroutine会被阻塞,直到getInstance执行完毕。

3.2 sync.once 原理

sync.once内部原理很简单,如下:

type Once struct {
    //done变量用来标识函数是否执行完毕
    done uint32
        //m用来保证函数执行期间,其他goroutine阻塞
    m    Mutex
}

下面我们主看Do方法:

func (o *Once) Do(f func()) {
    //1.判断函数是否执行过,done=0说明没执行过
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}


func (o *Once) doSlow(f func()) {
    //2.加锁
    o.m.Lock()
    //6.函数返回后,释放锁
    defer o.m.Unlock()
    //3判断是否执行过
    if o.done == 0 {
        //5函数返回后,设置执行标识
        defer atomic.StoreUint32(&o.done, 1)
        //4具体执行函数
        f()
    }
}

如上代码1通过原子性方法LoadUint32判断标done是否为0,为0说明函数f还没被执行过,为1则说明f已经执行完毕。

如果done为0,则执行doSlow函数,代码2加锁保证多个goroutine执行时,只有一个goroutine可以执行f,其他goroutine要阻塞等待。

代码3双重检测判断函数f是否被执行过(因为阻塞的goroutine释放后也要执行该代码,所以需要判断),如果done=1说明f已经执行过,则返回,否则步骤4具体执行函数f。

在方法doSlow执行完毕后,会先执行步骤5调用原子性方法StoreUint32设置标done为1,标志函数f已经执行完毕。

然后执行步骤6释放锁,这时,其他阻塞的goroutine中会有一个goroutine获取到锁,然后执行步骤3,但是其发现done为1了,就直接返回了,然后会释放锁。这时,其他阻塞的goroutine中会有一个goroutine获取到锁,然后执行步骤3,但是其发现done为1了,就直接返回了,然后会释放锁...

一个问题,步骤5和步骤6是否可以互换顺序?答案是不可以。大家可以自行探究下,然后再评论区回复哦^^

四、总结

当我们需要让某个函数只执行一次时,go sdk内置的sync.once()方法是不错的选择。其内部通过原子变量的load/store *** 作,以及锁保证传入sync.once的函数只执行一次,并且保证传入的函数执行期间,其他调用该once函数的goroutine必须阻塞等到传入的函数执行完毕

戳下面阅读

👇

我的第三本书    我的第二本书    我的第一本书

golang并发教程    ForkJoinPool    K8s网络模型

  我的视频号  

点亮再看哦👇

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/995323.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-21
下一篇 2022-05-21

发表评论

登录后才能评论

评论列表(0条)

保存