Go并发编程之sync.Once使用实例详解

目录
  • 一.序
  • 二. 源码分析
    • 2.1结构体
    • 2.2 接口
  • 三. 使用场景案例
    • 3.1 单例模式
    • 3.2 加载配置文件示例
  • 四.总结
  • 五. 参考

一.序

单从库名大概就能猜出其作用。sync.Once使用起来很简单, 下面是一个简单的使用案例

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		once sync.Once
		wg   sync.WaitGroup
	)

	for i := 0; i < 10; i++ {
		wg.Add(1)
		// 这里要注意讲i显示的当参数传入内部的匿名函数
		go func(i int) {
			defer wg.Done()
			// fmt.Println("once", i)
			once.Do(func() {
				fmt.Println("once", i)
			})
		}(i)
	}

	wg.Wait()
	fmt.Printf("over")
}

输出:

❯ go run ./demo.go
once 9

测试如果不添加once.Do 这段代码,则会输出如下结果,并且每次执行的输出都不一样。

once 9
once 0
once 3
once 6
once 4
once 1
once 5
once 2
once 7
once 8

从两次输出不同,我们可以得知 sync.Once的作用是:保证传入的函数只执行一次

二. 源码分析

2.1结构体

Once的结构体如下

type Once struct {
    done uint32
    m    Mutex
}

每一个 sync.Once 结构体中都只包含一个用于标识代码块是否执行过的 done 以及一个互斥锁 sync.Mutex

2.2 接口

sync.Once.Dosync.Once 结构体对外唯一暴露的方法,该方法会接收一个入参为空的函数:

  • 如果传入的函数已经执行过,会直接返回
  • 如果传入的函数没有执行过, 会调用sync.Once.doSlow执行传入的参数
func (o *Once) Do(f func()) {
	// Note: Here is an incorrect implementation of Do:
	//
	//	if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
	//		f()
	//	}
	//
	// Do guarantees that when it returns, f has finished.
	// This implementation would not implement that guarantee:
	// given two simultaneous calls, the winner of the cas would
	// call f, and the second would return immediately, without
	// waiting for the first's call to f to complete.
	// This is why the slow path falls back to a mutex, and why
	// the atomic.StoreUint32 must be delayed until after f returns.

	if atomic.LoadUint32(&o.done) == 0 {
		// Outlined slow-path to allow inlining of the fast-path.
		o.doSlow(f)
	}
}

代码注释中特别给了一个说明: 很容易犯错的一种实现

if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
	f()
}

如果这么实现最大的问题是,如果并发调用,一个 goroutine 执行,另外一个不会等正在执行的这个成功之后返回,而是直接就返回了,这就不能保证传入的方法一定会先执行一次了

正确的实现方式

if atomic.LoadUint32(&o.done) == 0 {
    // Outlined slow-path to allow inlining of the fast-path.
    o.doSlow(f)
}

会先判断 done 是否为 0,如果不为 0 说明还没执行过,就进入 doSlow

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 当中使用了互斥锁来保证只会执行一次

具体的逻辑

  • 为当前Goroutine获取互斥锁
  • 执行传入的无入参函数;
  • 运行延迟函数, 将成员变量done更新为1

三. 使用场景案例

3.1 单例模式

原子操作配合互斥锁可以实现非常高效的单件模式。互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数字型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。

type singleton struct {}

var (
    instance    *singleton
    initialized uint32
    mu          sync.Mutex
)

func Instance() *singleton {
    if atomic.LoadUint32(&initialized) == 1 {
        return instance
    }

    mu.Lock()
    defer mu.Unlock()

    if instance == nil {
        defer atomic.StoreUint32(&initialized, 1)
        instance = &singleton{}
    }
    return instance
}

而使用sync.Once能更简单实现单例模式

type singleton struct {}

var (
    instance *singleton
    once     sync.Once
)

func Instance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

3.2 加载配置文件示例

延迟一个开销很大的初始化操作到真正用到它的时候再执行是一个很好的实践。因为预先初始化一个变量(比如在init函数中完成初始化)会增加程序的启动耗时,而且有可能实际执行过程中这个变量没有用上,那么这个初始化操作就不是必须要做的。我们来看一个例子:

var icons map[string]image.Image

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 被多个goroutine调用时不是并发安全的
// 因为map类型本就不是类型安全数据结构
func Icon(name string) image.Image {
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}

多个goroutine并发调用Icon函数时不是并发安全的,编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons函数可能会被重排为以下结果:

func loadIcons() {
    icons = make(map[string]image.Image)
    icons["left"] = loadIcon("left.png")
    icons["up"] = loadIcon("up.png")
    icons["right"] = loadIcon("right.png")
    icons["down"] = loadIcon("down.png")
}

在这种情况下就会出现即使判断了icons不是nil也不意味着变量初始化完成了。考虑到这种情况,我们能想到的办法就是添加互斥锁,保证初始化icons的时候不会被其他的goroutine操作,但是这样做又会引发性能问题。

可以使用sync.Once 改造代码

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 是并发安全的,并且保证了在代码运行的时候才会加载配置
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

四.总结

作为用于保证函数执行次数的 sync.Once 结构体,它使用互斥锁和 sync/atomic 包提供的方法实现了某个函数在程序运行期间只能执行一次的语义。在使用该结构体时,我们也需要注意以下的问题:

  • sync.Once.Do 方法中传入的函数只会被执行一次,哪怕函数中发生了 panic;
  • 两次调用 sync.Once.Do 方法传入不同的函数只会执行第一次调传入的函数;

五. 参考

到此这篇关于Go并发编程--sync.Once的文章就介绍到这了,更多相关Go并发编程内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Go并发编程之goroutine使用正确方法

    目录 1. 对创建的gorouting负载 1.1 不要创建一个你不知道何时退出的 goroutine 1.2 不要帮别人做选择 1.3 不要作为一个旁观者 1.4 不要创建不知道什么时候退出的 goroutine 1.5 不要创建都无法退出的 goroutine 1.6 确保创建出的goroutine工作已经完成 2. 总结 3. 参考 并发(concurrency): 指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时

  • Go语言并发编程 sync.Once

    sync.Once用于保证某个动作只被执行一次,可用于单例模式中,比如初始化配置.我们知道init()函数也只会执行一次,不过它是在main()函数之前执行,如果想要在代码执行过程中只运行某个动作一次,可以使用sync.Once,下面来介绍一下它的使用方法. 先来看下面的代码: package main import ( "fmt" "sync" ) func main() { var num = 6 var once sync.Once add_one := fu

  • Go并发编程实践

    前言 并发编程一直是Golang区别与其他语言的很大优势,也是实际工作场景中经常遇到的.近日笔者在组内分享了我们常见的并发场景,及代码示例,以期望大家能在遇到相同场景下,能快速的想到解决方案,或者是拿这些方案与自己实现的比较,取长补短.现整理出来与大家共享. 简单并发场景 很多时候,我们只想并发的做一件事情,比如测试某个接口的是否支持并发.那么我们就可以这么做: func RunScenario1() { count := 10 var wg sync.WaitGroup for i := 0;

  • golang 并发编程之生产者消费者详解

    golang 最吸引人的地方可能就是并发了,无论代码的编写上,还是性能上面,golang 都有绝对的优势 学习一个语言的并发特性,我喜欢实现一个生产者消费者模型,这个模型非常经典,适用于很多的并发场景,下面我通过这个模型,来简单介绍一下 golang 的并发编程 go 并发语法 协程 go 协程是 golang 并发的最小单元,类似于其他语言的线程,只不过线程的实现借助了操作系统的实现,每次线程的调度都是一次系统调用,需要从用户态切换到内核态,这是一项非常耗时的操作,因此一般的程序里面线程太多会

  • GO语言并发编程之互斥锁、读写锁详解

    在本节,我们对Go语言所提供的与锁有关的API进行说明.这包括了互斥锁和读写锁.我们在第6章描述过互斥锁,但却没有提到过读写锁.这两种锁对于传统的并发程序来说都是非常常用和重要的. 一.互斥锁 互斥锁是传统的并发程序对共享资源进行访问控制的主要手段.它由标准库代码包sync中的Mutex结构体类型代表.sync.Mutex类型(确切地说,是*sync.Mutex类型)只有两个公开方法--Lock和Unlock.顾名思义,前者被用于锁定当前的互斥量,而后者则被用来对当前的互斥量进行解锁. 类型sy

  • golang并发编程的实现

    go main函数的执行本身就是一个协程,当使用go关键字的时候,就会创建一个新的协程 channel channel 管道,用于在多个协程之间传递信号 无缓存管道 当对无缓冲通道写的时候,会一直阻塞等到某个协程对这个缓冲通道读 阻塞场景: 通道中无数据,但执行读通道. 通道中无数据,向通道写数据,但无协程读取. 综上,无缓存通道的读写必须同时存在,且读写分别在两个不同的协程 func main(){ ch := make(chan int) go func(ch chan int){ ch <

  • Go并发编程之sync.Once使用实例详解

    目录 一.序 二. 源码分析 2.1结构体 2.2 接口 三. 使用场景案例 3.1 单例模式 3.2 加载配置文件示例 四.总结 五. 参考 一.序 单从库名大概就能猜出其作用.sync.Once使用起来很简单, 下面是一个简单的使用案例 package main import ( "fmt" "sync" ) func main() { var ( once sync.Once wg sync.WaitGroup ) for i := 0; i < 10;

  • Java并发编程之Semaphore(信号量)详解及实例

    Java并发编程之Semaphore(信号量)详解及实例 概述 通常情况下,可能有多个线程同时访问数目很少的资源,如客户端建立了若干个线程同时访问同一数据库,这势必会造成服务端资源被耗尽的地步,那么怎样能够有效的来控制不可预知的接入量呢?及在同一时刻只能获得指定数目的数据库连接,在JDK1.5 java.util.concurrent 包中引入了Semaphore(信号量),信号量是在简单上锁的基础上实现的,相当于能令线程安全执行,并初始化为可用资源个数的计数器,通常用于限制可以访问某些资源(物

  • Python编程之string相关操作实例详解

    本文实例讲述了Python编程之string相关操作.分享给大家供大家参考,具体如下: #coding=utf8 ''''' 字符串是Python中最常见的类型.可以通过引号见包含字符的方式创建. Python里面单引号和双引号的作用是相同的. 字符串是不可变类型,就是说改变一个字符串的元素需要新建一个新的字符串. 字符串是由独立的字符组成,并且这些字符可以通过切片操作顺序地访问. ''' class StringClass(object): ''''' 创建一个字符串就像使用一个标量一样简单.

  • Java Swing组件编程之JTable表格用法实例详解

    本文实例讲述了Java Swing组件编程之JTable表格用法.分享给大家供大家参考,具体如下: 表格是GUI编程中使用较多,但也是最麻烦的一个控件之一.表格是用来显示二维数据,提供编辑,选择等功能.如果只是显示数据,这还是非常easy的,只要在JTable中传入二维数组或集合就可以了. 示例1: package awtDemo; import javax.swing.JFrame; import javax.swing.JScrollPane; import javax.swing.JTab

  • java 并发中的原子性与可视性实例详解

    java 并发中的原子性与可视性实例详解 并发其实是一种解耦合的策略,它帮助我们把做什么(目标)和什么时候做(时机)分开.这样做可以明显改进应用程序的吞吐量(获得更多的CPU调度时间)和结构(程序有多个部分在协同工作).做过java Web开发的人都知道,Java Web中的Servlet程序在Servlet容器的支持下采用单实例多线程的工作模式,Servlet容器为你处理了并发问题. 原子性 原子是世界上的最小单位,具有不可分割性.比如 a=0:(a非long和double类型) 这个操作是不

  • go语言编程之select信道处理示例详解

    目录 select信道处理 fibonacci数列监听 select监听协程 select信道处理 注意:有default就不会阻塞 package main func main() { var chan1 = make(chan int) var chan2 = make(chan int) select { case <-chan1: // 如果chan1成功读到数据,则进行该case处理语句 case chan2: // 如果chan2成功读到数据,则进行该case处理语句 default

  • socket编程之bind()函数使用示例详解

    目录 正文 端口号具体是怎么绑定 老代码 端口被占用的问题解决 正文 当你创建了socket之后,你会想要把这个socket和你本机上的某个端口号(port)进行关联. 端口号是内核用来确认将收到的数据包交给哪个具体进程的socket descriptor的依据. 通常在写服务端程序的时候我们才需要进行关联,客户端程序不需要我们手动绑定端口,直接connect()就好了. 端口号具体是怎么绑定 #include <sys/types.h> #include <sys/socket.h&g

  • android编程之XML文件解析方法详解(附源码)

    本文实例讲述了android编程之XML文件解析方法.分享给大家供大家参考,具体如下: 在android开发中,经常用到去解析xml文件,常见的解析xml的方式有一下三种:SAX.Pull.Dom解析方式.最近做了一个android版的CSDN阅读器,用到了其中的两种(sax,pull),今天对android解析xml的这三种方式进行一次总结. 今天解析的xml示例(channels.xml)如下: <?xml version="1.0" encoding="utf-8

  • JavaScript异步编程之Promise的初步使用详解

    1. 概述 Promise对象是ES6提出的的异步编程的规范.说到异步编程,就不得不说说同步和异步这两个概念. 从字面意思理解同步编程的话,似乎指的是两个任务同步运行,如果这样理解就错了(至少笔者再没有接触到这个概念的时候有这种误解).同步和异步指的是代码指定执行的顺序(结构化编程范式的执行顺序总是由上至下,由前往后的),如果执行的顺序与代码的相同,就是同步:如果不同,就是异步. 最初,操作系统都是基于命令行的,所有的的语言设计出来也天然是同步的语句,在这种情况下,也不需要异步编程.但是很快,图

  • Java并发教程之Callable和Future接口详解

    刚把Thread 的知识理了一遍. Runnable是一个接口,而Thread是Runnable的一个实现类. 所以也就有了之前创建线程的两种方法 继承Thread 实现Runnable 我们看一下新建线程的方法: 都是得传入一个Runnable对象(这句话很关键) 所以传入一个Runnble和Thread对象都行. 现在引入创建线程的第三种方法:Callable 为了实现 Runnable,需要实现不返回任何内容的 run()方法,而对于 Callable,需要实现在完成时返回结果的 call

随机推荐