Go语言提供了sync
包和channel
机制来解决并发机制中不同goroutine
之间的同步和通信
锁 Locker
Go语言使用go
语句开启新的goroutine
,由于goroutine
非常轻量除了对其分配栈空间外,所占的空间也是微乎其微的。但当多个goroutine
同时处理时会遇到比如同时抢占一个资源,某个goroutine
会等待等一个goroutine
处理完毕某才能继续执行的问题。对于这种情况,官方并不希望依靠共享内存的方式来实现进程的协同操作,而是希望通过channel
信道的方式来处理。但在某些特殊情况下,依然需要使用到锁,为此sync
包提供了锁。
当在并发情况下,多个goroutine
同时修改某一个变量时,就会出现资源抢占,因此会导致数据不一致的问题。
package main
import (
"fmt"
"time"
)
func main() {
var num = 0
for i := 0; i < 100000; i++ {
go func(i int) {
num++
fmt.Printf("goroutine %d: num = %d\n", i, num)
}(i)
}
time.Sleep(time.Second)//主goroutine等待1秒以确保所有工作goroutine执行完毕
}
...
goroutine 10018: num = 98635
goroutine 12646: num = 98635
goroutine 12844: num = 98635
goroutine 12950: num = 98635
...
上例中goroutine
一依次从寄存器中读取num
的值后做加法运算,然后将其结果回写到寄存器中。运行中会发现存在一个goroutine
取出num
的值时做加法运算时,另一个goroutine
也取出了num
的值。因为上一个goroutine
运行结果还没有回写到寄存器,最终导致多个goroutine
产生的相同的结果。
并发编程中同步原语-锁,为了保证多个线程或goroutine
在访问同一块内存时不出现混乱,Go语言的sync
包提供了常见的并发编程同步原语的控制锁。
sync
包围绕着Locker
锁接口展开,Locker
接口中提供了两个方法Lock()
和Unlock()
。
type Locker interface {
Lock()
Unlock()
}
Go语言标准库sync
中提供了两种锁分别是互斥锁sync.Mutex
和读写互斥锁sync.RWMutex
互斥锁sync.Mutex
sync.Mutex是一个互斥锁,可以由不同的goroutine加锁和解锁。
sync.Mutex
是Golang标准库提供的一个互斥锁,当一个goroutine
获得互斥锁权限后,其他请求锁的goroutine
会阻塞在Lock()
方法的调用上,直到调用Unlock()
方法被释放。
例如:10个并发的goroutine
打印同一个数字100,为避免重复打印,实现printOnce(num int)
函数,使用集合set
记录已打印过的数字。若数字已经打印过,则不再打印。
$ vim mutex_test.go
package test
import (
"fmt"
"testing"
"time"
)
var set = make(map[int]bool, 0)
func printOnce(index int, num int) {
if _, ok := set[num]; !ok {
fmt.Println(index, num)
}
set[num] = true
}
func TestPrint(t *testing.T) {
for i := 0; i < 10; i++ {
go printOnce(i, 100)
}
time.Sleep(time.Second)
}
$ go test -v mutex_test.go
=== RUN TestPrint
9 100
3 100
--- PASS: TestPrint (1.00s)
PASS
ok command-line-arguments 1.304s
程序多次运行后会发现打印次数多次,因为对同一个数据结构set
的访问发生了冲突。
并发访问中比如多个goroutine
并发更新同一个资源,比如计时器、账户余额、秒杀系统、向同一个缓存中并发写入数据等等。如果没有互斥控制,很容易会出现异常,比如计时器计数不准确、用户账户可能出现透支、秒杀系统出现超卖、缓存出现数据缓存等等,后果会很严重。
互斥锁是并发控制的一种基本手段,是为了避免竞争而建立的一种并发控制机制。学习前首先需要弄清楚一个概念-临界区。在并发编程中,如果程序中的一部分会被并发访问或修改,为了避免并发访问导致的意想不到的结果,这部分程序需要被保护起来,这部分被保护起来的程序就叫做临界区。
临界区是一个被共享的资源,或者说是一个整体的共享资源,比如对数据库的访问,对某个共享数据结构的操作。对一个I/O设备的使用,对一个连接池中的连接的调用等等。
如果很多线程同步访问临界区就会造成访问或操作错误,这并不是我们希望看到的结果。所以,使用互斥锁,限定临界区只能同时由一个线程持有。当临界区由一个线程持有的时候,其他线程如果想进入临界区就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。
互斥锁可以很好的解决资源竞争的问题,因此也有人称之为排它锁。Golang标准库中使用Mutex来实现互斥锁。根据2019年分析Go并发Bug的论文Understanding Real-World Concurrency Bugs in Go中,Mutex是使用最为广泛的同步原语(Synchronization primitives, 并发原语或同步原语)。关于同步原语并没有一个严格的定义,可将其看作是解决并发问题的一个基础的数据结构。
type Mutex struct {
state int32 //状态标识
sema uint32 //信号量
}
Go标准库提供了sync.Mutex
互斥锁类型以及两个方法分别是Lock
加锁和Unlock
释放锁。可以通过在代码前调用Lock
方法,在代码后调用Unlock
方法来保证一段代码的互斥执行,也可以使用defer
语句来保证互斥锁一定会被解锁。当一个goroutine
调用Lock
方法获得锁后,其它请求的goroutine
都会阻塞在Lock
方法直到锁被释放。
一个互斥锁只能同时被一个goroutine
锁定,其它goroutine
将阻塞直到互斥锁被解锁,也就是重新争抢对互斥锁的锁定。需要注意的是,对一个未锁定的互斥锁解锁时将会产生运行时错误。
sync.Mutex
不区分读写锁,只有Lock()
和Lock()
之间才会导致阻塞的情况。若在一个地方Lock()
,在另一个地方不Lock()
而是直接修改或访问共享数据,对于sync.Mutext
类型是允许的,因为mutex
不会和goroutine
进行关联。若要区分读锁和写锁,可使用sync.RWMutex
类型。
在Lock()
和Unlock()
之间的代码段成为资源临界区(critical section),在这一区间内的代码是严格被Lock()
保护的,是线程安全的,任何一个时间点都只能有一个goroutine
执行这段区间的代码。
例如:使用互斥锁的Lock()
和Unlock()
方法将冲突包裹
package test
import (
"fmt"
"sync"
"testing"
"time"
)
var m sync.Mutex
var set = make(map[int]bool, 0)
func printOnce(index int, num int) {
m.Lock()
defer m.Unlock()
if _, ok := set[num]; !ok {
fmt.Println(index, num)
}
set[num] = true
}
func TestPrint(t *testing.T) {
for i := 0; i < 10; i++ {
go printOnce(i, 100)
}
time.Sleep(time.Second)
}
相同的数字只会比打印一次,当一个goroutine
调用了Lock()
方法时,其他goroutine
被阻塞了,直到Unlock()
调用将锁释放。因此被包裹部分的代码就能避免冲突,实现互斥。
互斥即不能同时运行,使用互斥锁的两个代码片段相互排斥,只有其中一个代码片段执行完毕后,另一个才能执行。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var num = 0
var locker sync.Mutex
for i := 0; i < 100000; i++ {
go func(i int) {
locker.Lock()
defer locker.Unlock()
num++
fmt.Printf("goroutine %d: num = %d\n", i, num)
}(i)
}
time.Sleep(time.Second)
}
从上例中可以发现,添加互斥锁后,不仅没有出现抢占资源导致的重复输出,而且输出结果顺序递增。
Go语言中的Mutex类型的互斥锁Lock()
锁定与其它语言不同的是,Lock()
锁定的是互斥锁而非一段代码。其他语言比如Java中使用同步锁锁定的是一段代码,以确保多线程并发誓只有一个线程可以控制运行此代码块直到释放同步锁。Go语言是在goroutine
中锁定互斥锁,其它goroutine
执行到有锁的位置时,由于获取不到互斥锁的锁定,因此会发生阻塞而等待,从而达到控制同步的目的。
race detector
Golang提供了一个检测并发访问共享资源是否存在问题的工具 - race detector,它可以帮助自动发现城中有没有data race的问题。
Golang的race detector是基于Google的C/C++ sanitizers技术实现的,编译器通过探测所有的内存访问,加入代码能监视对这些内存地址的访问(读或是写)。代码运行时race detector能监控对共享变量的非同步访问,出现race时就会打印出警告信息。
https://blog.golang.org/race-detector
读写锁sync.RWMutex
在银行存取钱时,对账户余额的修改是需要加锁的,因此此时可能有人汇款到你的账户,如果对金额的修改不加锁,很可能导致最后的金额发生错误。读取账户余额也需要等待修改操作结束,才能读取到正确的余额。大部分情况下,读取余额的操作会更加频繁,如果能保证读取余额的操作能并发执行,程序的效率会很大地提升。
保证读操作的安全,只需要保证并发读时没有写操作在进行就行。在这种场景下就需要一种特殊类型的锁,即允许多个只读操作并行执行,但写操作会完全互斥。这种锁称之为多读单写锁(multiple readers, single writer lock),简称读写锁。读写锁分为读锁和写锁,读锁允许同时执行,但写锁是互斥的。
sync.RWMutex
读写锁是基于sync.Mutex
实现的,读写锁的特点是针对读写操作的互斥锁,读写锁与互斥锁最大不同之处在于分别对读、写进行了锁定。一般用在大量读操作少量写操作中。
- 同时只能具有一个
goroutine
能够获得写锁定 - 同时可以具有任意多个
goroutine
获得读锁定 - 同时只能存在写锁定或读锁定,即读和写互斥。
换句话说
- 当只有一个
goroutine
获得写锁定时,其它无论是读锁定还是写锁定都将会阻塞直到写解锁。 - 当只有一个
goroutine
获得读锁定时,其它读锁定仍然可以继续执行。 - 当有一个或多个读锁定时,写锁定将等待所有读锁定解锁之后才能进行写锁定。
这里所谓的读锁定(RLock)目的是为了告诉写锁定(Lock),此时有很多人正在读取数据,写锁定需要排队等待。
一般来说,读写锁会分为几种情况:
- 读锁之间不互斥,在没有写锁的情况下,读锁是无堵塞的,多个
goroutine
可以同时获得读锁。 - 写锁之间是互斥的,当存在写锁时,其它写锁会阻塞。
- 写锁与读锁互斥,若存在读锁则写锁阻塞,若存在写锁则读锁阻塞。
Go标准库sync.RWMutex
读写互斥锁提供了四个方法
读写互斥锁 | 描述 |
---|---|
Lock | 添加写锁 |
Unlock | 释放写锁 |
RLock | 添加读锁 |
RUnlock | 释放读锁 |