并发安全和锁

Go 并发:互斥锁、读写互斥锁和并发安全

作者:刘俊 Bingo Gophist

参考文档

在 Go 中,可能会存在多个 Goroutine 同时操作一个资源,会发生竞态问题。类比现实生活中,有十字路口,被多个方向的汽车竞争;多人同时要上一辆地铁。

比如,下面这个例子:

点这里,在线试试!

package main

import (
	"fmt"
	"sync"
)

var x int64
var wg sync.WaitGroup

func add() {
	for i := 0; i < 500000; i++ {
		x = x + 1
	}
	wg.Done()
}

func main() {
	wg.Add(2)
	go add()
	go add()
	wg.Wait()
	fmt.Println(x)
}

我们用两个 goroutine 去累加变量x的值,这两个goroutine 在访问和修改 x 变量时,会存在数据竞争(比如同时拿到了同一个值,就会造成同样的 +1 操作,进行了两遍),导致最后的结果和预期不符。

Go 的同步工具主要由 sync 包提供,互斥锁 (Mutex) 与读写锁 (RWMutex) 就是sync 包中的方法。

互斥锁 Mutex

互斥锁可以用来保护一个临界区,保证同一时刻只有一个线程 goroutine 处于该临界区内,其它的 goroutine 则在等待锁。当互斥锁释放后,等待的 goroutine 才可以获取锁进入临界区。多个 goroutine 同时等待一个锁时,唤醒的策略时随机的。

主要包括锁定 lock()unlock() ,首先对进入临界区的 goroutine进行锁定,离开时进行解锁。

点这里,在线试试!

使用互斥锁 (Mutex)时要注意以下几点:

  • 不要重复锁定互斥锁,否则会阻塞,也可能会导致死锁(deadlock);

  • 要对互斥锁进行解锁,这也是为了避免重复锁定; 不要对未锁定或者已解锁的互斥锁解锁;

  • 不要在多个函数之间直接传递互斥锁,sync.Mutex类型属于值类型,将它传给一个函数时,会产生一个副本,在函数中对锁的操作不会影响原锁。

总之,一个互斥锁只用来保护一个临界区,加锁后记得解锁,对于每一个锁定操作,都要有且只有一个对应的解锁操作,也就是加锁和解锁要成对出现,最保险的做法是使用defer语句解锁。

互斥锁通常用于读锁和写锁差不多的情况,而现实使用场景中,更多的场景是读多写少的。如果在这种情况下,读和写都加锁,会大幅影响性能。

读写互斥锁

而由此读写互斥锁应运而生了。

读写互斥锁能实现:在读锁占用的情况下,阻止写,但不阻止读。

也就是说,如果多个协程 goroutine 只涉及读,则可同时获取读锁 RLock() ,多个 goroutine 可同时进行;而写锁 Lock() 则和互斥锁一样,会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由一个 协程 goroutine 独占,离开时才解锁,让下一个协程 goroutine 开始。

以下案例是互斥锁和读写互斥锁 示例的对比:

并发安全的 map

如果按通常的方法去并发地修改一个 map ,例如: 点这里,在线试试!

则会报如下错误:

可见,Go 原生的 map 不能保证并发安全。

可以用 sync.Map 代替 Map实现并发安全,也可以用读写互斥锁来实现。值得注意的是,sync.Map 和 原生的 map 在用法上是不一致的。 sync.Map 内置了诸如 StoreLoadLoadOrStoreDeleteRange 等操作方法。

  • sync.Map 的代码实现

点这里,在线试试!

  • 用读写互斥锁和 Go 原生 map 实现并发安全的 map

点这里,在线试试!

sync.Once 是 Golang package 中使方法只执行一次的对象实现,作用与 init 函数类似。但也有所不同。

  • init 函数是在文件包首次被加载的时候执行,且只执行一次

  • sync.Onc 是在代码运行中需要的时候执行,且只执行一次

当一个函数不希望程序在一开始的时候就被执行的时候,我们可以使用 sync.Once

点这里,在线试试!

最后更新于

这有帮助吗?