初见Go-Go并发之解决竞争状态
Go SDK 版本 :1.17.8
竞争状态
如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争状态(race candition)
安全访问共享资源
Java对共享资源安全访问提供了 synchronized
和 volatile
关键字 ,分别对应锁机制和无锁机制,或者说是悲观锁和乐观锁机制
go 对安全访问共享资源也提供了原子操作包 atomic ,mutex 互斥锁 ,除此之外,go还提供了基于通道的协程通信方式
atomic
atomic 是 Go 内置的原子操作包 sync/atomic
包内官方注释
Package atomic provides low-level atomic memory primitives useful for implementing synchronization algorithms.
These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package. Share memory by communicating; don't communicate by sharing memory.
atomic 包提供了底层的原子内存原语来实现同步机制
同时,官方文档强调atomic包这些功能需要非常小心才能正确使用。除了特殊的低级应用外,同步最好使用通道(channels)或同步包(the facilities of the sync package)的设施来完成。通过通信共享内存;不要通过共享内存进行通信。
所以,在实现同步时我们应该首先考虑用 channel,而非 atomic
原子函数
atomic 包提供了五类原子操作
- SwapT (交换)
- CompareAndSwapT (CAS)
- AddT (增加|减少)
- LoadT (读取)
- StoreT (写入)
支持的类型 T 有 int32、int64、uint32、uint64、uintptr、unsafe.Pointer
互斥锁 (mutex)
mutex 由 syn
包提供
另一种同步访问共享资源的方式是使用互斥锁(mutex)。互斥锁这个名字来自互斥(mutual exclusion)的概念。互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以 执行这个临界区代码。
通道 (channel)
原子函数和互斥锁都能工作,但是依靠它们都不会让编写并发程序变得更简单,更不容易出错,或者更有趣。在 Go 语言里,你不仅可以使用原子函数和互斥锁来保证对共享资源的安全访 问以及消除竞争状态,还可以使用通道,通过发送和接收需要共享的资源,在 goroutine 之间做同步。这也是go推荐的做法
使用 make 创建通道
// 无缓冲的整型通道
unbuffered := make(chan int)
// 有缓冲的字符串通道
buffered := make(chan string, 10)
无缓冲的通道
无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。
这种类型的通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。
如果两个 goroutine 没有同时准备好,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。
这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。
// 无缓冲区的情况, 造成死锁
func TestUnbuffered() {
c1 := make(chan int)
// 当前线程进入阻塞
c1 <- 1
// 由于阻塞,下面这步一直不会执行,产生死锁
<-c1
}
有缓冲的通道
有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个值的通道。
这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。
只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。
// 带缓冲区的情况,程序正常运行
func TestBuffered() {
c1 := make(chan int, 1)
c1 <- 1
a := <-c1
fmt.Println(a)
}
实现生产者消费者模式
用带缓冲的通道可以很方便的实现生产者消费者模式
package main
import (
"fmt"
"sync"
)
// 定义一个缓存为 3 的通道
var buffer = make(chan int, 3)
var (
// 多个消费者队列
customerList []Customer
// 多个生产者队列
producerList []Producer
)
// 初始化消费者和生产者
// 2 个 生产者
// 3 个 消费者
func init() {
for i := 0; i < 3; i++ {
customerList = append(customerList, Customer{id: i})
}
for i := 0; i < 2; i++ {
producerList = append(producerList, Producer{id: i})
}
}
type Customer struct {
id int
}
type Producer struct {
id int
}
func main() {
var wg sync.WaitGroup
wg.Add(len(producerList) + len(customerList))
for _, p := range producerList {
go p.produce(&wg)
}
for _, c := range customerList {
go c.custom(&wg)
}
wg.Wait()
}
// 消费者从通道中拿一个值
func (c *Customer) get() {
fmt.Printf("customer %d get %d \n", c.id, <-buffer)
}
// 生产者放一个值进通道
func (p *Producer) put(v int) {
fmt.Printf("producer %d put %d \n", p.id, v)
buffer <- v
}
// 每个消费者消费2个资源
func (c *Customer) custom(wg *sync.WaitGroup) {
for i := 0; i < 2; i++ {
c.get()
}
wg.Done()
}
// 每个生产者生产3个资源
func (p *Producer) produce(wg *sync.WaitGroup) {
for i := 0; i < 3; i++ {
p.put(i)
}
wg.Done()
}
实现信号量和互斥
用带缓冲的通道可以实现PV操作
package semaphore
// 用带缓冲通道实现一个信号量
// EMPTY 我们不关心通道里的值, 将其设为空接口
type EMPTY interface{}
type semaphore chan EMPTY
// P 获得n个资源
func (s semaphore) P(n int) {
e := new(EMPTY)
for i := 0; i < n; i++ {
s <- e
}
}
// V 释放n个资源
func (s semaphore) V(n int) {
for i := 0; i < n; i++ {
<-s
}
}
// 使用PV实现互斥
func (s semaphore) Lock() {
s.P(1)
}
func (s semaphore) UnLock() {
s.V(1)
}
func (s semaphore) Wait(n int) {
s.P(n)
}
func (s semaphore) Signal() {
s.V(1)
}
实现两个协程安全访问并修改变量
存在竞争的代码
package main
import (
"fmt"
"runtime"
"sync"
)
var resource int64 = 0
var wg sync.WaitGroup
func main() {
// 只设置一个逻辑处理器
runtime.GOMAXPROCS(1)
// 等待两个 goroutine
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(resource)
}
func add() {
// 获取资源值, 模拟从内存中取值的操作
value := resource
// 切换 goroutine
runtime.Gosched()
// 更改资源值
value++
// 更新值, 模拟把值存回内存的操作
resource = value
wg.Done()
}
上面这段代码我们用两个协程并发地修改一个共享变量,resource最终结果应当为2,实际打印结果为1,存在竞争问题
- main() 函数中的
runtime.GOMAXPROCS(1)
的作用是限制一个逻辑处理器给调度器使用,让程序能够并发执行,否则程序并行执行结果依旧是正确的。操作系统会在物理处理器上调度线程来运行,而 Go 语言的运行时会在逻辑处理器上调度 goroutine来运行。,Go语言的运行时默认会为每个可用的物理处理器分配一个逻辑处理器。 - add() 函数中模拟了cpu对内存中的一个变量的操作过程,即从内存中读取数据,修改数据,将数据写回内存。
- add() 函数中的
runtime.Gosched()
作用是切换 goruntine ,模拟发生线程上下文切换的场景 - 当两个协程对一个资源做读写操作时,流程如下图
使用atomic解决
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
var resource int64 = 0
var wg sync.WaitGroup
func main() {
// 只设置一个逻辑处理器
runtime.GOMAXPROCS(1)
// 等待两个 goroutine
wg.Add(2)
go atomicAdd()
go atomicAdd()
wg.Wait()
fmt.Println(resource)
}
// 使用atomic包解决
func atomicAdd() {
//CAS
atomic.CompareAndSwapInt64(&resource, resource, resource+1)
// 切换 goroutine
runtime.Gosched()
wg.Done()
}
使用mutex解决
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
var resource int64 = 0
var wg sync.WaitGroup
// 定义临界区
var mutex sync.Mutex
func main() {
// 只设置一个逻辑处理器
runtime.GOMAXPROCS(1)
// 等待两个 goroutine
wg.Add(2)
go mutexAdd()
go mutexAdd()
wg.Wait()
fmt.Println(resource)
}
// 使用互斥锁解决
func mutexAdd() {
mutex.Lock()
{
// 获取资源值, 模拟从内存中取值的操作
value := resource
// 切换 goroutine
runtime.Gosched()
// 更改资源值
value++
// 更新值, 模拟把值存回内存的操作
resource = value
}
mutex.Unlock()
wg.Done()
}
使用带缓冲的通道解决
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
var resource int64 = 0
var wg sync.WaitGroup
// 创建一个通道
var c = make(chan int, 1)
func main() {
// 只设置一个逻辑处理器
runtime.GOMAXPROCS(1)
// 等待两个 goroutine
wg.Add(2)
c <- int(resource)
go channelAdd()
go channelAdd()
wg.Wait()
fmt.Println(<-c)
}
func channelAdd() {
value := <-c
value++
c <- value
wg.Done()
}
参考
《Go语言实战》By William Kennedy
《the-way-to-go》https://github.com/unknwon/the-way-to-go_ZH_CN