详解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中的net/http路由注册与请求处理

    浅析Golang中的net/http路由注册与请求处理

    这篇文章主要为大家详细介绍了Golang中的net/http路由注册与请求处理的相关知识,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下
    2023-12-12
  • 利用Go实现一个简易DAG服务的示例代码

    利用Go实现一个简易DAG服务的示例代码

    DAG的全称是Directed Acyclic Graph,即有向无环图,DAG广泛应用于表示具有方向性依赖关系的数据,如任务调度、数据处理流程、项目管理以及许多其他领域,下面,我将用Go语言示范如何实现一个简单的DAG服务,需要的朋友可以参考下
    2024-03-03
  • golang 中 channel 的详细使用、使用注意事项及死锁问题解析

    golang 中 channel 的详细使用、使用注意事项及死锁问题解析

    这篇文章主要介绍了golang 中 channel 的详细使用、使用注意事项及死锁分析,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-03-03
  • Go slice切片make生成append追加copy复制示例

    Go slice切片make生成append追加copy复制示例

    这篇文章主要为大家介绍了Go使用make生成切片、使用append追加切片元素、使用copy复制切片使用示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • Golang微服务框架Kratos实现Kafka消息队列的方法

    Golang微服务框架Kratos实现Kafka消息队列的方法

    消息队列是大型分布式系统不可缺少的中间件,也是高并发系统的基石中间件,所以掌握好消息队列MQ就变得极其重要,在本文当中,您将了解到:什么是消息队列?什么是Kafka?怎样在微服务框架Kratos当中应用Kafka进行业务开发,需要的朋友可以参考下
    2023-09-09
  • 详解golang 模板(template)的常用基本语法

    详解golang 模板(template)的常用基本语法

    这篇文章主要介绍了详解golang 模板(template)的常用基本语法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-08-08
  • Go语言中JSON文件的读写操作

    Go语言中JSON文件的读写操作

    本文主要介绍了Go语言JSON文件的读写操作,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-04-04
  • golang结合mysql设置最大连接数和最大空闲连接数

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

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

    goland Duration 和time的区别说明

    这篇文章主要介绍了goland Duration 和time的区别说明,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-12-12
  • go time.Sleep睡眠指定时间实例详解(小时级到纳秒级)

    go time.Sleep睡眠指定时间实例详解(小时级到纳秒级)

    golang的休眠可以使用time包中的sleep,下面这篇文章主要给大家介绍了关于go time.Sleep睡眠指定时间(小时级到纳秒级)的相关资料,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2022-11-11

最新评论