初见Go-Go并发之解决竞争状态

Go SDK 版本 :1.17.8

竞争状态

如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争状态(race candition)

安全访问共享资源

Java对共享资源安全访问提供了 synchronizedvolatile 关键字 ,分别对应锁机制和无锁机制,或者说是悲观锁和乐观锁机制

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

posted @ 2022-04-19 16:47  油虾条  阅读(48)  评论(0编辑  收藏  举报