Go中并发控制的实现方式总结

 更新时间:2023年12月21日 09:15:23   作者:Serena  
在Go实际开发中,并发安全是老生常谈的事情,在并发下,goroutine之间的存在数据资源等方面的竞争,为了保证数据一致性、防止死锁等问题的出现,在并发中需要使用一些方式来实现并发控制,本文给大家总结了几种实现方式,需要的朋友可以参考下

脚本之家 / 编程助手:解决程序员“几乎”所有问题!
脚本之家官方知识库 → 点击立即使用

Go的并发控制

在Go实际开发中,并发安全是老生常谈的事情,在并发下,goroutine之间的存在数据资源等方面的竞争。

为了保证数据一致性、防止死锁等问题的出现,在并发中需要使用一些方式来实现并发控制。

并发控制的目的是确保在多个并发执行的线程或进程中,对共享资源的访问和操作能够正确、有效地进行,并且避免出现竞态条件和数据不一致的问题

在Go中,可以通过以下几种方式来实现并发控制:

1、channel

channel通道主要用于于goroutine之间通信和同步的机制。通过使用channel,可以在不同的goroutine之间进行数据的发送与接收,从而实现协调和控制并发,以达到并发控制。

根据channel的类型,可以实现不同的并发控制效果:

无缓冲channel

当使用make初始化时,不指定channel的容量大小,即初始化无缓冲channel

当发送方向无缓冲channel发送消息数据时,如果发送后channel的数据未被接收方获取,则当前goroutine会阻塞在发送语句中,直到有接收者准备好接收数据为止,即无缓冲通道要求发送操作和接收操作同时准备好才能完成通信。这样做是确保了发送和接收的同步,避免了数据竞争和不确定性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    // 创建一个无缓冲通道
    ch := make(chan int)
 
    // 启动一个 goroutine 接收数据
    go func() {
       time.Sleep(time.Second * 5)
       fmt.Println("等待接收数据")
       data := <-ch // 接收数据
       fmt.Println("接收到数据:", data)
    }()
 
    fmt.Println("发送数据")
    // 发送数据,由于匿名函数goroutine睡眠,无缓冲通道内数据没有goroutine接收,因此会阻塞。5s后被接收则继续执行
    ch <- 100
    time.Sleep(time.Second)
    fmt.Println("程序结束")
}
  • 在上述代码中,创建了一个无缓冲通道 ch。然后在一个单独的 goroutine 中启动了一个接收操作,等待从通道 ch 中接收数据。

  • 接下来,在main goroutine中执行发送操作,向通道 ch 发送数据 100

  • 由于无缓冲通道的特性,当发送语句 ch <- 100 执行时,由于没有接收者准备好接收数据(单独的goroutine处于5s睡眠),发送操作会被阻塞。

  • 接收方的 goroutine 在接收数据之前会一直等待。

  • 当接收方的 goroutine 准备好之后,发送操作完成,数据被成功发送并被接收方接收,然后程序继续执行后续语句,打印出相应的输出。

需要注意的是,在使用无缓冲channel时,如果没有接收者,发送操作将会永久阻塞,可能会导致死锁,因此在使用无缓冲通道时,需要确保发送和接收操作能够匹配。

有缓冲channel

当使用make初始化时,可以指定channel的容量大小,即初始化有缓冲channel通道的容量表示通道中最大能存放的元素数量

  • 当发送方发送数据到有缓存channel时,如果缓冲区满了,则发送方会被阻塞直到有缓冲空间可以接收这个消息数据;

  • 当接收方在有缓冲channel接收数据时,如果缓冲区为空,则接收方会被阻塞直到channel有数据可读;

无论是缓存 channel 还是无缓冲 channel,都是并发安全的,即多个 goroutine 可以同时发送和接收数据,而不需要额外的同步机制。

但是,由于缓存 channel 具有缓存空间,因此在使用时需要特别注意缓存空间的大小,避免过度消耗内存或者发生死锁等问题。

2、sync.WaitGroup

sync包中,sync.WaitGroup可以在并发goroutine之间起到执行屏障的效果。WaitGroup提供了用于创建多个goroutine时,能够等待多个并发执行的代码块在达到WaitGroup显示指定的同步条件后,才可以继续执行Wait的后续代码。在使用sync.WaitGroup实现同步模式下,从而起到并发控制的效果。

在Go中,sync.WaitGroup类型提供了如下几个方法:

方法名功能说明
func (wg * WaitGroup) Add(delta int)等待组计数器 + delta
(wg *WaitGroup) Done()等待组计数器-1
(wg *WaitGroup) Wait()阻塞直到等待组计数器变为0

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main
 
import (
    "fmt"
    "sync"
)
 
// 声明全局等待组变量
var wg sync.WaitGroup
 
func printHello() {
    fmt.Println("Hello World")
    wg.Done() // 完成一个任务后,调用Done()方法,等待组减1,告知当前goroutine已经完成任务
}
 
func main() {
    wg.Add(1) // 等待组加1,表示登记一个goroutine
    go printHello()
    fmt.Println("main")
    wg.Wait() // 阻塞当前goroutine,直到等待组中的所有goroutine都完成任务
}
 
// 执行结果
main
Hello World

3、sync.Mutex

sync.Mutex 是 Go 语言中的一个互斥锁(Mutex)类型,用于实现对共享资源的互斥访问。

互斥锁是一种常见的并发控制机制,它能够确保在同一时刻只有一个 goroutine 可以访问被保护的资源,从而避免数据竞争和不确定的结果。

互斥锁的作用可以有以下几个方面:

  • 保护共享资源:当多个 goroutine并发访问共享资源时,通过使用互斥锁可以限制只有一个 goroutine 可以访问共享资源,从而避免竞态条件和数据不一致的问题。
  • 实现临界区:互斥锁可以将一段代码标记为临界区,只有获取了锁的 goroutine 才能执行该临界区的代码,其他 goroutine 则需要等待解锁,才能够访问临界区内的代码块。

互斥锁的基本使用方式是,通过调用 Lock() 方法获取锁,执行临界区代码,然后调用 Unlock() 方法释放锁。在获取锁之后,其他 goroutine 将会被阻塞,直到当前 goroutine 释放锁为止。Lock() 方法与Unlock() 底层的实现原理是使用原子操作来维护Mutexstate状态。

sync.Mutex中,除了最基本的互斥锁外,还提供读写锁,在读多写少的场景下,相比互斥锁性能上能够有所提升。

channel 与 Mutex 对比例子

在自增操作x++中,该操作并非原子操作,因此在多个goroutine对全局变量x进行自增时,会出现数据覆盖的情况,因此可以通过一些方法来实现并发控制,例如channel互斥锁原子操作

可以对比一下channel互斥锁在实现并发控制时的执行时间:

  • 使用channel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main
 
import (
    "fmt"
    "sync"
    "time"
)
 
var x int64
var wg sync.WaitGroup
 
func main() {
    startTime := time.Now()
    ch := make(chan struct{}, 1)
 
    for i := 0; i < 10000; i++ {
       wg.Add(1)
       go func() {
          defer wg.Done()
          ch <- struct{}{}
          x++
          <-ch
       }()
    }
    wg.Wait()
    endTime := time.Now()
    fmt.Println(x)                      // 10000
    fmt.Println(endTime.Sub(startTime)) // 6.2933ms
}
  • 使用Mutex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main
 
import (
    "fmt"
    "sync"
    "time"
)
 
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
 
func main() {
    startTime := time.Now()
    for i := 0; i < 10000; i++ {
       wg.Add(1)
       go func() {
          defer wg.Done()
          lock.Lock()
          x++
          lock.Unlock()
       }()
    }
    wg.Wait()
    endTime := time.Now()
    fmt.Println(x)                      // 10000
    fmt.Println(endTime.Sub(startTime)) // 3.0835ms
}

可以对比两种方法的执行时间,在启动10000goroutine执行10000次全局变量x++时,channel实现并发控制全局变量x++的执行时间为6.2933ms(存在波动),而使用Mutex提供的互斥锁实现并发控制全局变量x++的执行时间为3.0835ms(存在波动),大约在两倍左右,这是为什么呢?

原因在于channel的操作涉及到**goroutine之间的调度和上下文的切换**,而互斥锁底层使用了Go的原子操作,执行时间较短,因为互斥锁的操作相对轻量,不涉及goroutine的调度以及上下文的切换。

在开发过程中,选择使用通道还是互斥锁取决于具体的场景与需求,并不是一定说使用锁就好,需要根据实际的业务场景来进行选择。如果需要更细粒度的控制和更高的并发性能,可以优先考虑使用互斥锁。

4、atomic原子操作

Go语言提供了原子操作用于对内存中的变量进行同步访问,避免了多个goroutine同时访问同一个变量时可能产生的竞态条件。

sync/atomic包提供了原子加操作、比较并交换等方法提供一系列原子操作,这些方法利用底层的原子指令,确保对内存中的变量进行原子级别的访问和修改,从而实现并发控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main
 
import (
    "fmt"
    "sync"
    "sync/atomic"
)
 
var x int64
var wg sync.WaitGroup
 
// 使用原子操作
func atomicAdd() {
    atomic.AddInt64(&x, 1)
    wg.Done()
}
 
func main() {
    for i := 0; i < 10000; i++ {
       wg.Add(1)
       go atomicAdd() // 原子操作add函数
    }
    wg.Wait()
    fmt.Println(x) // 10000
}

一些常用的原子操作函数:

  • Add函数:AddInt32AddInt64AddUint32AddUint64等方法,用于对变量进行原子加操作
  • CompareAndSwap函数:CompareAndSwapInt32CompareAndSwapInt64CompareAndSwapUint32CompareAndSwapUint64等,用于比较并交换操作,当旧值等于给定值时,将新值赋值到指定地址中。
  • Load函数:LoadInt32LoadInt64LoadUint32LoadUint64等,用于加载操作,返回指定地址中存储的值。
  • Store函数:StoreInt32StoreInt64StoreUint32StoreUint64等,用于存储操作,将给定的值存储到指定地址中。
  • Swap函数:SwapInt32SwapInt64SwapUint32SwapUint64等,用于交换操作,将指定地址中存储的值和给定的值进行交换,并返回原值。

以上就是Go中并发控制的实现方式总结的详细内容,更多关于Go并发控制实现的资料请关注脚本之家其它相关文章!

蓄力AI

微信公众号搜索 “ 脚本之家 ” ,选择关注

程序猿的那些事、送书等活动等着你

原文链接:https://juejin.cn/post/7313834600719564863

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若内容造成侵权/违法违规/事实不符,请将相关资料发送至 reterry123@163.com 进行投诉反馈,一经查实,立即处理!

相关文章

  • Go语言学习之操作MYSQL实现CRUD

    Go语言学习之操作MYSQL实现CRUD

    Go官方提供了database包,database包下有sql/driver。该包用来定义操作数据库的接口,这保证了无论使用哪种数据库,操作方式都是相同的。本文就来和大家聊聊Go语言如何操作MYSQL实现CRUD,希望对大家有所帮助
    2023-02-02
  • Go保证并发安全底层实现详解

    Go保证并发安全底层实现详解

    这篇文章主要为大家介绍了Go保证并发安全底层实现详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-09-09
  • Go之接口型函数用法

    Go之接口型函数用法

    这篇文章主要介绍了Go之接口型函数用法,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-02-02
  • Golang有类型常量和无类型常量的区别

    Golang有类型常量和无类型常量的区别

    本文主要介绍了Golang有类型常量和无类型常量的区别,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-04-04
  • Go语言中GORM存取数组/自定义类型数据

    Go语言中GORM存取数组/自定义类型数据

    在使用gorm时往往默认的数据类型不满足我们的要求,需要使用一些自定义数据类型作为字段类型,下面这篇文章主要给大家介绍了关于Go语言中GORM存取数组/自定义类型数据的相关资料,需要的朋友可以参考下
    2023-01-01
  • Go 1.22中的for循环新特性详解

    Go 1.22中的for循环新特性详解

    在 Go 语言中,for 循环是实现迭代的主要方式,Go 中的 for 循环非常灵活,有多种使用方式,本文将给大家详细的介绍一下Go 1.22中的for循环新特性,感兴趣的朋友可以参考下
    2024-02-02
  • GoLang之go build命令的具体使用

    GoLang之go build命令的具体使用

    本文主要介绍了GoLang之go build命令的具体使用,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-08-08
  • golang如何使用sarama访问kafka

    golang如何使用sarama访问kafka

    这篇文章主要介绍了golang如何使用sarama访问kafka,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-12-12
  • go语言 全局变量和局部变量实例

    go语言 全局变量和局部变量实例

    这篇文章主要介绍了go语言 全局变量和局部变量实例,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-12-12
  • Go语言中日志的规范使用建议分享

    Go语言中日志的规范使用建议分享

    在任何服务端的语言项目中,日志是至关重要的组成部分,本文为大家整理了一些如何规范使用GO语言日志的建议,以及相应的实际示例,希望对大家有事帮助
    2024-01-01

最新评论