深入理解go sync.Once的具体使用

 更新时间:2024年01月09日 15:06:20   作者:rubys007  
在很多情况下,我们可能需要控制某一段代码只执行一次,go 为我们提供了 sync.Once 对象,它保证了某个动作只被执行一次,本文主要介绍了深入理解go sync.Once的具体使用,感兴趣的可以了解一下

在很多情况下,我们可能需要控制某一段代码只执行一次,比如做某些初始化操作,如初始化数据库连接等。

对于这种场景,go 为我们提供了 sync.Once 对象,它保证了某个动作只被执行一次。

当然我们也是可以自己通过 Mutex 实现 sync.Once 的功能,但是相比来说繁琐了那么一点,因为我们不仅要自己去控制锁,还要通过一个标识来标志是否已经执行过。

Once 的实现

Once 的实现非常简单,如下,就只有 20 来行代码,但里面包含了 go 并发、同步的一些常见处理方法。

package sync

import (
	"sync/atomic"
)

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

简要说明:

  • done 字段指示了操作是否已执行,也就是我们传递给 Do 的函数是否已经被执行。
  • Do 方法接收一个函数参数,这个函数参数只会被执行一次。
  • Once 内部是通过 Mutex 来实现不同协程之间的同步的。

使用示例

在下面的例子中,once.Do(test) 被执行了 3 次,但是最终 test 只被执行了一次。

package sync

import (
	"fmt"
	"sync"
	"testing"
)

var once sync.Once
var a = 0

func test() {
	a++
}

func TestOnce(t *testing.T) {
	var wg sync.WaitGroup
	wg.Add(3)

	for i := 0; i < 3; i++ {
		go func() {
			// once.Do 会调用 3 次,但最终只会执行一次
			once.Do(test)

			wg.Done()
		}()
	}

	wg.Wait()

	fmt.Println(a) // 1
}

Once 的一些工作机制

  • Once 的 Do 方法可以保证,在多个 goroutine 同时执行 Do 方法的时候,
    在第一个抢占到 Do 执行权的 goroutine 执行返回之前,其他 goroutine 都会阻塞在 Once.Do 的调用上,
    只有第一个 Do 调用返回的时候,其他 goroutine 才可以继续执行下去,并且其他所有的 goroutine
    不会再执行传递给 Do 的函数。(如果是初始化的场景,这可以避免尚未初始化完成就执行其他的操作)

  • 如果 Once.Do 发生 panic 的时候,传递给 Do 的函数依然被标记为已完成。后续对 Do 的调用也不会再执行传给 Do 的函数参数。

  • 我们不能简单地通过 atomic.CompareAndSwapUint32 来决定是否执行 f(),因为在多个 goroutine 同时执行的时候,它无法保证 f() 只被执行一次。所以 Once 里面用了 Mutex,这样就可以有效地保护临界区。

// 错误实现,这不能保证 f 只被执行一次
if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    f()
}
  • Once.Do 的函数参数是没有参数的,如果我们需要传递一些参数,可以再对 f 做一层包裹。
config.once.Do(func() { config.init(filename) })

Once 详解

hotpath

这里说的 hotpath 指的是 Once 里的第一个字段 done

type Once struct {
	// hotpath
	done uint32
	m    Mutex
}

Once 结构体的第一个字段是 done,这是因为 done 的访问是远远大于 Once 中另外一个字段 m 的,
放在第一个字段中,编译器就可以做一些优化,因为结构体的地址其实就是结构体第一个字段的地址,
这样一来,在访问 done 字段的时候,就不需要通过结构体地址 + 偏移量的方式来访问,
这在一定程度上提高了性能。

结构体地址计算示例:

type person struct {
	name string
	age  int
}

func TestStruct(t *testing.T) {
	var p = person{
		name: "foo",
		age:  10,
	}
	// p 和 p.name 的地址相同
	// 0xc0000100a8, 0xc0000100a8
	fmt.Printf("%p, %p\n", &p, &p.name)

	// p.age 的地址
	// 0xc0000100b8
	fmt.Printf("%p\n", &p.age)
	// p.age 的地址也可以通过:结构体地址 + age 字段偏移量 计算得出。
	// 0xc0000100b8
	fmt.Println(unsafe.Add(unsafe.Pointer(&p), unsafe.Offsetof(p.age)))
}

atomic.LoadUint32

func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 {
		o.doSlow(f)
	}
}

在 Do 方法中,是通过 atomic.LoadUint32 的方式来判断 done 是否等于 0 的,
这是因为,如果直接使用 done == 0 的方式的话,就有可能导致在 doSlow 里面对 done 设置为 1 之后,
在 Do 方法里面无法正常观测到。因此用了 atomic.LoadUint32

而在 doSlow 里面是可以通过 done == 0 来判断的,这是因为 doSlow 里面已经通过 Mutex 保护起来了。
唯一设置 done = 1 的地方就在临界区里面,所以 doSlow 里面通过 done == 0 来判断是完全没有问题的。

atomic.StoreUint32

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

在 doSlow 方法中,设置 done 为 1 也是通过 atomic.StoreUint32 来设置的。
这样就可以保证在设置了 done 为 1 之后,可以及时被其他 goroutine 看到。

Mutex

doSlow 的实现里面,最终还是要通过 Mutex 来保护临界区,
通过 Mutex 可以实现 f 只被执行一次,并且其他的 goroutine 都可以使用这一次 f 的执行结果。
因为其他 goroutine 在第一次 f 调用未返回之前,都阻塞在获取 Mutex 锁的地方,
当它们获取到 Mutex 锁的时候,得以继续往下执行,但这个时候 f 已经执行完毕了,
所以当它们获取到 Mutex 锁之后其实什么也没有干。

但是它们的阻塞状态被解除了,可以继续往下执行。

总结

  • Once 保证了传入的函数只会执行一次,这常常用在一些初始化的场景、或者单例模式。
  • Once 可以保证所有对 Do 的并发调用都是安全的,所有对 Once.Do 调用之后的操作,一定会在第一次对 f 调用之后执行。(没有获取到 f 执行权的 goroutine 会阻塞)
  • 即使 Once.Do 里面的 f 出现了 panic,后续也不会再次调用 f

到此这篇关于深入理解go sync.Once的具体使用的文章就介绍到这了,更多相关go sync.Once内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家! 

相关文章

  • Go语言中一定要知道的切片使用注意事项总结

    Go语言中一定要知道的切片使用注意事项总结

    了解和掌握切片的使用注意事项,可以避免意外的程序行为,所以本文就来和大家深入探讨一下Go语言切片常见的注意事项,希望对大家有所帮助
    2023-06-06
  • 使用Go语言实现远程传输文件

    使用Go语言实现远程传输文件

    本文主要介绍如何利用Go语言实现远程传输文件的功能,有需要的小伙伴们可以参考学习。下面跟着小编一起来学习学习。
    2016-08-08
  • golang并发之使用sync.Pool优化性能

    golang并发之使用sync.Pool优化性能

    在Go提供如何实现对象的缓存池功能,常用一种实现方式是sync.Pool, 其旨在缓存已分配但未使用的项目以供以后重用,从而减轻垃圾收集器(GC)的压力,下面我们就来看看具体操作吧
    2023-10-10
  • Go语言中的Array、Slice、Map和Set使用详解

    Go语言中的Array、Slice、Map和Set使用详解

    这篇文章主要介绍了Go语言中的Array、Slice、Map和Set使用详解,本文给出了它们的创建、使用、多维等代码实例,需要的朋友可以参考下
    2014-10-10
  • golang中"var"与":="的区别解析

    golang中"var"与":="的区别解析

    这篇文章主要介绍了golang中“var”与“:=”的区别,使用var关键字是Go最基本的定义变量方式,有时也会使用到:=来定义变量,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2023-04-04
  • 浅析Go语言中的方法集合与选择receiver类型

    浅析Go语言中的方法集合与选择receiver类型

    这篇文章主要为大家详细介绍了Go语言中的方法集合与选择receiver类型的相关知识,文中的示例代码讲解详细,对我们深入学习go语言有一定的帮助,需要的可以参考下
    2023-11-11
  • go性能分析工具pprof的用途及使用详解

    go性能分析工具pprof的用途及使用详解

    刚开始接触go就遇到了一个内存问题,在进行内存分析的时候发现了一下比较好的工具,在此留下记录,下面这篇文章主要给大家介绍了关于go性能分析工具pprof的用途及使用的相关资料,需要的朋友可以参考下
    2023-01-01
  • Go语言面试题之select和channel的用法

    Go语言面试题之select和channel的用法

    金九银十面试季到了(PS:貌似今年一年都是面试季),就业环境很差,导致从业人员不得不卷。本文将重点讲解一下Go面试进阶知识点之select和channel,需要的可以参考一下
    2022-09-09
  • GO语言基本类型分析

    GO语言基本类型分析

    这篇文章主要介绍了GO语言基本类型,较为详细的分析了整形、浮点型、字符串、指针等类型的具体用法,是深入学习GO语言所必须掌握的重要基础,需要的朋友可以参考下
    2014-12-12
  • 在Go语言项目中使用Zap日志库的操作过程

    在Go语言项目中使用Zap日志库的操作过程

    本文将先介绍Go语言原生的日志库的使用,然后详细介绍非常流行的Uber开源的zap日志库,同时会介绍如何搭配·Lumberjack·实现日志的切割和归档,对Go使用Zap日志库相关知识感兴趣的朋友一起看看吧
    2024-03-03

最新评论