本文参考 《Go 语言实战》
1. 竞争状态简述
如果两个或者多个 goroutine
在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争状态(race candition)。
对一个共享资源的读和写操作必须是原子化的,换句话说,同一时刻只能有一个 goroutine
对共享资源进行读和写操作。
当某些东西被认为是原子的,或者具有原子性的时候,这意味着在它运行的环境中,它是不可分割的或不可中断的。
// 这个示例程序展示如何在程序里造成竞争状态
// 实际上不希望出现这种情况
package main
import (
"fmt"
"runtime"
"sync"
)
var (
// counter是所有goroutine都要增加其值的变量
counter int
// wg用来等待程序结束
wg sync.WaitGroup
)
// main是所有Go程序的入口
func main() {
// 计数加2,表示要等待两个goroutine
wg.Add(2)
// 创建两个goroutine
go incCounter(1)
go incCounter(2)
// 等待goroutine结束
wg.Wait()
fmt.Println("Final Counter:", counter)
}
// incCounter增加包里counter变量的值
func incCounter(id int) {
// 在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for count := 0; count < 2; count++ {
// 捕获counter的值
value := counter
// 当前goroutine从线程退出,并放回到队列
/*
用于将 goroutine 从当前线程退出,给其他 goroutine 运行的机会。
在两次操作中间这样做的目的是强制调度器切换两个 goroutine,以便让竞争状态的效果变得更明显。
*/
runtime.Gosched()
// 增加本地value变量的值
value++
// 将该值保存回counter
counter = value
}
}
输出:
Final Counter: 2
变量 counter
会进行 4 次读和写操作,每个 goroutine
执行两次。但是,程序终止时, counter
变量的值为2。
每个 goroutine
都会覆盖另一个 goroutine
的工作。这种覆盖发生在 goroutine
切换的时候。每个 goroutine
创造了一个 counter
变量的副本,之后就切换到另一个 goroutine
。
当这个 goroutine
再次运行的时候, counter
变量的值已经改变了,但是 goroutine
并没有更新自己的那个副本的值,而是继续使用这个副本的值,用这个值递增,并存回 counter
变量,结果覆盖了另一个 goroutine
完成的工作。
图: 竞争状态下程序行为的图像表达
2. 锁住共享资源
Go
语言提供了传统的同步 goroutine
的机制,就是对共享资源加锁。如果需要顺序访问一个整型变量或者一段代码, atomic
和 sync
包里的函数提供了很好的解决方案。
下面我们了解一下 atomic
包里的几个函数以及 sync
包里的 mutex
类型。
2.1 原子函数
原子函数能够以很底层的加锁机制来同步访问整型变量和指针.
示例 1:
package main
import (
"fmt"
"sync/atomic"
)
var (
// 序列号
seq int64
)
// 序列号生成器
func GenID() int64 {
// 尝试原子的增加序列号
// 这里故意没有使用 atomic .Addlnt64()的返回值作 为 GenID () 函数的返// 回值,因此会造成一个竞态问题
atomic.AddInt64(&seq, 1)
return seq
}
func main() {
// 10个并发序列号生成
for i := 0; i < 10; i++ {
go GenID()
}
fmt.Println(GenID())
}
在运行程序时,为运行参数加入 -race
参数,开启运行时( runtime )对竞态问题的分析,命令如下:
wohu@wohu-dev:~/gocode/src$ go run -race temp.go
==================
WARNING: DATA RACE
Write at 0x0000005f8178 by goroutine 8:
sync/atomic.AddInt64()
/usr/local/go/src/runtime/race_amd64.s:276 +0xb
main.GenID()
/home/wohu/gocode/src/temp.go:16 +0x43
Previous read at 0x0000005f8178 by goroutine 7:
main.GenID()
/home/wohu/gocode/src/temp.go:17 +0x53
Goroutine 8 (running) created at:
main.main()
/home/wohu/gocode/src/temp.go:23 +0x4f
Goroutine 7 (finished) created at:
main.main()
/home/wohu/gocode/src/temp.go:23 +0x4f
==================
4
Found 1 data race(s)
exit status 66
修改该函数为下面即可正常。
// 序列号生成器
func GenID() int64 {
// 尝试原子的增加序列号
return atomic.AddInt64(&seq, 1)
}
示例代码 2:
// 这个示例程序展示如何使用atomic包来提供
// 对数值类型的安全访问
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
var (
// counter是所有goroutine都要增加其值的变量
counter int32
// wg用来等待程序结束
wg sync.WaitGroup
)
// main是所有Go程序的入口
func main() {
// 计数加2,表示要等待两个goroutine
wg.Add(2)
// 创建两个goroutine
go incCounter(1)
go incCounter(2)
// 等待goroutine结束
wg.Wait()
fmt.Println("Final Counter:", counter)
}
// incCounter增加包里counter变量的值
func incCounter(id int) {
// 在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for count := 0; count < 2; count++ {
// 安全地对counter加1
atomic.AddInt32(&counter, 1)
// 当前goroutine从线程退出,并放回到队列
/*
用于将 goroutine 从当前线程退出,给其他 goroutine 运行的机会。
在两次操作中间这样做的目的是强制调度器切换两个 goroutine,以便让竞争状态的效果变得更明显。
*/
runtime.Gosched()
}
}
输出:
Final Counter: 4
程序的第43行使用了 atmoic
包的 AddInt64
函数。这个函数会同步整型值的加法,方法是强制同一时刻只能有一个 goroutine
运行并完成这个加法操作。
当 goroutine
试图去调用任何原子函数时,这些 goroutine
都会自动根据所引用的变量做同步处理。
另外两个有用的原子函数是 LoadInt64
和 StoreInt64
。这两个函数提供了一种安全地读和写一个整型值的方式。
如下代码示例程序使用 LoadInt64
和 StoreInt64
来创建一个同步标志,这个标志可以向程序里多个 goroutine
通知某个特殊状态。
// 这个示例程序展示如何使用atomic包里的
// Store和Load类函数来提供对数值类型
// 的安全访问
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var (
// shutdown是通知正在执行的goroutine停止工作的标志
shutdown int64
// wg用来等待程序结束
wg sync.WaitGroup
)
// main是所有Go程序的入口
func main() {
// 计数加2,表示要等待两个goroutine
wg.Add(2)
// 创建两个goroutine
go doWork("A")
go doWork("B")
// 给定goroutine执行的时间
time.Sleep(1 * time.Second)
// 该停止工作了,安全地设置shutdown标志
fmt.Println("Shutdown Now")
atomic.StoreInt64(&shutdown, 1)
// 等待goroutine结束
wg.Wait()
}
// doWork用来模拟执行工作的goroutine,
// 检测之前的shutdown标志来决定是否提前终止
func doWork(name string) {
// 在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for {
fmt.Printf("Doing %s Work\n", name)
time.Sleep(250 * time.Millisecond)
// 要停止工作了吗?
if atomic.LoadInt64(&shutdown) == 1 {
fmt.Printf("Shutting %s Down\n", name)
break
}
}
}
2.2 互斥锁
另一种同步访问共享资源的方式是使用互斥锁( mutex
)。互斥锁这个名字来自互斥(mutual exclusion
)的概念。
互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine
可以执行这个临界区代码。
// 这个示例程序展示如何使用互斥锁来
// 定义一段需要同步访问的代码临界区
// 资源的同步访问
package main
import (
"fmt"
"runtime"
"sync"
)
var (
// counter是所有goroutine都要增加其值的变量
counter int
// wg用来等待程序结束
wg sync.WaitGroup
// mutex 用来定义一段代码临界区
mutex sync.Mutex
)
// main是所有Go程序的入口
func main() {
// 计数加2,表示要等待两个goroutine
wg.Add(2)
// 创建两个goroutine
go incCounter(1)
go incCounter(2)
// 等待goroutine结束
wg.Wait()
fmt.Printf("Final Counter: %d\n", counter)
}
// incCounter使用互斥锁来同步并保证安全访问,
// 增加包里counter变量的值
func incCounter(id int) {
// 在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for count := 0; count < 2; count++ {
// 同一时刻只允许一个goroutine进入
// 这个临界区
mutex.Lock()
{ // 使用大括号只是为了让临界区看起来更清晰,并不是必需的。
// 捕获counter的值
value := counter
// 当前goroutine从线程退出,并放回到队列
runtime.Gosched()
// 增加本地value变量的值
value++
// 将该值保存回counter
counter = value
}
mutex.Unlock()
// 释放锁,允许其他正在等待的goroutine
// 进入临界区
}
}
对 counter
变量的操作在第 46 行和第 60 行的 Lock()
和 Unlock()
函数调用定义的临界区里被保护起来。
同一时刻只有一个 goroutine
可以进入临界区。之后,直到调用 Unlock()
函数之后,其他 goroutine
才能进入临界区。当第 52 行强制将当前 goroutine
退出当前线程后,调度器会再次分配这个 goroutine
继续运行。当程序结束时,我们得到正确的值 4,竞争状态不再存在。
2.3 读写互斥锁 sync.RWMutex
当然,互斥锁也并不总是最好的。由于在同一时间内只能有一个协程获取互斥锁并执行操作,那么在多读少写的情况下,如果长时间没有写操作,读取到的会是完全相同的值,使用互斥锁就显得没有必要了。这个时候,使用读写锁则更加恰当。
读写锁通过两种锁来实现,一种为读锁,另一种为写锁。当进行读取操作时,需要加读锁,而进行写入操作时则需要加写锁。多个协程可以同时获得读锁并执行。如果此时有协程申请了写锁,那么该协程会等待所有的读锁都释放后,才能获取写锁并执行。如果当前的协程申请读锁时已经存在写锁,那么读锁会等待写锁释放后再获取读锁并执行。
总之,读锁必须能观察到上一次写锁写入的值,写锁则要在之前的读锁释放后才能写入。可以有多个协程获得读锁,但只有一个协程可以获得写锁。
在读多写少的环境中,可以优先使用读写互斥锁, sync
包中的 RWMutex
提供了读写互斥锁的封装。
package main
import (
"fmt"
"sync"
"time"
)
var (
count int
// 变量对应的读写互斥锁
countGuard sync.RWMutex
)
func GetCount() int {
countGuard.RLock()
defer countGuard.RUnlock()
return count
}
func SetCount(c int) {
countGuard.Lock()
{
count += c
}
countGuard.Unlock()
}
func main() {
// 可以进行并发安全的设置
for i := 0; i < 10; i++ {
go SetCount(2)
}
time.Sleep(2 * time.Second)
// 可以进行并发安全的读取
fmt.Println(GetCount())
}