详解Go sync 同步原语

 更新时间:2023年12月27日 12:04:21   作者:Schuyler_yuan  
Go 中不仅有 channel 这种 CSP 同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步原语,使用它们,可以更灵活的控制数据同步和多协程并发,这篇文章主要介绍了Go sync 同步原语,需要的朋友可以参考下

Go 中不仅有 channel 这种 CSP 同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步原语。使用它们,可以更灵活的控制数据同步和多协程并发。

  • sync.Mutex
  • sync.RWMutex
  • sync.WaitGroup
  • sync.Once
  • sync.Cond
  • sync.Map

在一个 goroutine 中,如果分配的内存没有被其他 goroutine 访问,只在该 goroutine 中被使用,不存在资源竞争的问题。但如果同一块内存被多个 goroutine 同时访问,就会不知道谁先访问,也无法预料最后结果。这就产生了资源竞争,这块内存就是共享资源。channel 是并发安全的,内部自加了锁,但是很多变量或者资源没有加锁,就需要 sync 同步原语了。

eg. 启动100个协程,让 nSum 加10,期待的结果是1000。

package main
import (
        "fmt"
        "time"
)
var nSum = 0
func add(i int) {
        nSum += i
}
func main() {
        for i := 0; i < 100; i++ {
                go add(10)
        }
        time.Sleep(time.Second)
        fmt.Println("sum=", nSum)
}

运行完之后,输出的结果可能是1000,也可能是990,或是980。

$ while true; do go run gosrc.go; done;

类似 go build、go run、go test,这种 Go 工具链命令,添加 -race 标识,帮助检查 Go 语言代码是否存在资源竞争。

$ go run -race gosrc.go

导致这种现象的原因是,资源 nSum 并不是并发安全的,因为同时会有多个协程执行 nSum += i,产生不可预料的结果。所以需要确保同时只有一个协程执行 nSum += i 操作,互斥锁可以实现。

sync.Mutex

互斥锁,是指在同一时刻只有一个协程执行某段代码,其他协程都要等待该协程执行完毕后才能继续执行。

下面的实例中,声明一个互斥锁,然后修改 add 函数,对 nSum += i 执行加锁保护,这样这段代码在并发的时候就安全了,可以得到正确的结果。

上面这段加锁保护的代码,称为临界区。在同步程序设计中,临界区指的是一个访问共享资源的程序片段,而这些共享资源又无法同时被多个协程访问的特性。当一个协程获得了锁后,其他的协程只有等待锁释放,才能再去获得锁。锁的 Lock 和 Unlock 方法总是成对的出现。

package main
import (
        "fmt"
        "sync"
        "time"
)
var (
        nSum int
        mutex sync.Mutex
)
func add(i int) {
        mutex.Lock()
        defer mutex.Unlock()
        nSum += i
}
func main() {
        for i := 0; i < 100; i++ {
                go add(10)
        }
        time.Sleep(2*time.Second)
        fmt.Println("nSum=", nSum)
}

运行结果如下,

$ count=0;while (($count < 10)); do go run gomutex.go;((count=$count+1)); done

sync.RWMutex

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在 Go 语言中使用 sync.RWMutex 类型。

读写锁分为两种:读锁和写锁。当一个 goroutine 获取读锁之后,其他 goroutine 如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个 goroutine 获取写锁之后,其他 goroutine 无论是获取读锁还是写锁都会等待。

这里有一个性能问题,每次读写共享资源都要加锁,性能低下,怎么解决?现在分析这个特殊的场景,会有以下三种情况,写的时候不能同时读(读未提交读的时候不能同时写(读已提交读的时候可以同时读(可重复读

  • 可能读到脏数据,脏读
  • 会产生不可预料的结果,幻读
  • 不管多少协程读,都是并发安全的,可重复读。

可以通过读写锁提升性能,对比互斥锁,读写锁改动有两个地方,

  • 把锁的声明换成读写锁 RWMutex
  • 把读取数据的代码(函数 readSum)换成读锁

这样性能有很大提升,多个协程可以同时读取数据,不用相互等待。

 sync.WaitGroup

用于最终完成的场景,关键点在于一定是等待所有协程都执行完毕。

在前面的程序里边,为了防止主函数返回,使用了 time.Sleep 语句强制程序睡眠,因为一旦 main goroutine 返回,函数就退出了。

但这里是有问题的。如果这100个协程在两秒内执行完毕,main 函数本该提前返回,但是还是要等够两秒才能返回,存在性能问题。如果执行超过2秒,函数返回,有些协程不会执行,产生不可预知的结果。

有没有办法监听所有 goroutine 的执行?一旦全部执行完毕,程序马上退出,既可以保证所有协程执行完毕,又可以及时退出节省时间,提升性能。

通道 channel 可以实现,但比较复杂。所以,Go 提供了 WaitGroup。对上面的例子代码进行改造,分三步执行,

  • 声明一个 WaitGroup,通过 Add 方法设置一个计数器的值,需要跟踪多少协程就设置多少。
  • 每个协程在执行完毕的时候,一定要调 Done 方法,让计数器减1,告诉 WaitGroup 该协程已经执行完毕。
  • 最后调用 Wait 方法,一直等待,直到计数器的值变为0,也就是所有跟踪的协程执行完毕了。

通过 WaitGroup 可以很好地跟踪协程,在协程执行完毕后,整个 main 函数才能执行完毕。

package main
import (
        "fmt"
        "sync"
)
var (
        nSum int
        mutex sync.RWMutex
)
func add(i int) {
        mutex.Lock()
        defer mutex.Unlock()
        nSum += i
}
func main() {
        var wg sync.WaitGroup
        wg.Add(100)
        for i := 0; i < 100; i++ {
                go func() {
                        defer wg.Done()
                        add(10)
                }()
        }
        wg.Wait()
        fmt.Println("nSum=", nSum)
}

运行结果,会发现输出执行速度方面会清爽很多。

sync.WaitGroup适合协调多个goroutine共同做一件事情的场景。比如下载较大的文件时,为了加快下载速度,我们会使用多线程(协程)下载。假设使用10个协程,每个协程下载文件的1/10大小,只有10个协程都下载好了整个文件才算是下载好了。再比如流水线上,下个阶段需要上个阶段把所有数据准备好,10个协程准备数据,等所有协程处理完后,统一进入下个阶段继续执行.....

sync.Once

让代码只执行一次,哪怕是在高并发的情况下,比如创建一个单例。

先看个例子

package main
import (
        "fmt"
        "sync"
)
func main() {
        var once sync.Once
        onceBody := func() {
                fmt.Println("Only once")
        }
        done := make(chan bool)        // 用于等待协程执行完毕
        for i := 0; i < 10; i++ {        // 启动 10 个协程
                go func(n int) {
                        fmt.Println(n)
                        once.Do(onceBody)
                        done<-true
                }(i)
        }
        for i := 0; i < 10; i++ {
                <-done
        }
}

运行结果如下,

使用 WaitGroup 来保证子协程执行完毕,也可以这样写, 

package main
import (
        "fmt"
        "sync"
)
func main() {
        var once sync.Once
        onceBody := func() {
                fmt.Println("Only once")
        }
        var wg sync.WaitGroup
        wg.Add(10)
        for i := 0; i < 10; i++ {
                go func(n int) {
                        fmt.Println(n)
                        once.Do(onceBody)
                        wg.Done()
                }(i)
        }
        wg.Wait()
}

sync.Cond

可以用做发令枪,关键点在于 goroutine 开始的时候是等待的。Cond 一声令下,所有 goroutine 都开始执行。sync.Cond 从字面意思看是条件变量,除此之外,还具有阻塞和唤醒协程的功能,所以可以在满足一定条件的情况下唤醒协程。

sync.Cond有三个方法,

  • Wait,阻塞当前协程,直到其他协程调用signal或broadcast来唤醒,使用时需要加锁
  • Signal,唤醒一个等待时间最长的协程
  • Broadcast就是广播,唤醒所有等待的协程

注意,在调用 Signal 或者 Broadcast 之前,一定要确保目标协程要处于等待 Wait 阻塞状态,不然会出现死锁问题。和 java 里边的 wait、notify、notifyall 类似。

package main
import (
        "fmt"
        "sync"
        "time"
)
func main() {
        cond := sync.NewCond(&sync.Mutex{})
        var wg sync.WaitGroup
        wg.Add(11)
        for i := 0; i < 10; i++ {
                go func(n int) {
                        defer wg.Done()
                        fmt.Println("ready", n)
                        cond.L.Lock()
                        cond.Wait()
                        fmt.Println("go", n)
                        cond.L.Unlock()
                }(i)
        }
        time.Sleep(time.Second)
        go func() {
                defer wg.Done()
                fmt.Println("beng beng...")    // 发令枪响
                cond.Broadcast()
        }()
        wg.Wait()
}

运行结果如下,

sync.Map

Go 中的 map 类型是并发不安全的,在实际开发中,这种类型不能用在并发写的场景,并发读还是可以的。不过 slice 是并发安全的,有时候可以使用 slice 来代替 map,但需要迭代元素进行转换。这时 sync.Map 也是一个不错的选择。

  • Store,存储一对 kv;
  • Load,根据 key 获取对应的 value,并可以判断 key 是否存在;
  • LoadOrStore,如果 key 对应的 value 存在,则返回 value;否则存储相应的value;
  • Delete,删除一对 kv;
  • Range,循环迭代 sync.Map,效果与 for range 一样。

到此这篇关于Go sync 同步原语的文章就介绍到这了,更多相关Go sync 同步原语内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Golang处理内存溢出方式

    Golang处理内存溢出方式

    本文介绍了Golang中分析内存溢出问题的三种工具:pprof、GoMemstats和程序crash时自动创建dump文件,通过这些工具,可以对程序的内存使用情况进行详细分析,从而找出内存溢出的原因
    2024-12-12
  • 一文教你如何优雅处理Golang中的异常

    一文教你如何优雅处理Golang中的异常

    我们在使用Golang时,不可避免会遇到异常情况的处理,与Java、Python等语言不同的是,Go中并没有try...catch...这样的语句块,这个时候我们如何才能更好的处理异常呢?本文来教你正确方法
    2022-11-11
  • Go语言中的数据库操作大全从SQL到ORM

    Go语言中的数据库操作大全从SQL到ORM

    本文将详细介绍Go语言中的数据库操作,从基础的SQL操作到高级的ORM框架,帮助你构建更加高效、可靠的数据库应用,感兴趣的朋友一起看看吧
    2026-04-04
  • golang结合mysql设置最大连接数和最大空闲连接数

    golang结合mysql设置最大连接数和最大空闲连接数

    本文介绍golang 中连接MySQL时,如何设置最大连接数和最大空闲连接数,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-02-02
  • golang mysql的连接池的具体使用

    golang mysql的连接池的具体使用

    本文主要介绍了golang mysql的连接池的具体使用,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-02-02
  • GO语言结构体面向对象操作示例

    GO语言结构体面向对象操作示例

    这篇文章主要介绍了GO语言编程中结构体面向对象的操作示例,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步早日升职加薪
    2022-04-04
  • Golang 函数执行时间统计装饰器的一个实现详解

    Golang 函数执行时间统计装饰器的一个实现详解

    这篇文章主要介绍了Golang 函数执行时间统计装饰器的一个实现详解,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2019-03-03
  • 在Go语言单元测试中解决HTTP网络依赖问题

    在Go语言单元测试中解决HTTP网络依赖问题

    在 Go 语言中,我们需要找到一种可靠的方法来测试 HTTP 请求和响应,本文将探讨在 Go 中进行 HTTP 应用测试时,如何解决应用程序的依赖问题,以确保我们能够编写出可靠的测试用例,需要的朋友可以参考下
    2023-07-07
  • 详解golang中发送http请求的几种常见情况

    详解golang中发送http请求的几种常见情况

    这篇文章主要介绍了详解golang中发送http请求的几种常见情况,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-12-12
  • 详解如何使用Go的Viper来解析配置信息

    详解如何使用Go的Viper来解析配置信息

    Viper库为Golang语言开发者提供了对不同数据源和不同格式的配置文件的读取,是Go项目读取配置的神器,我们今天就来讲讲如何使用Viper来解析配置信息,文中通过代码示例讲解非常详细,需要的朋友可以参考下
    2024-01-01

最新评论