一. 并发安全
有时候在Go代码中可能存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。
1.1 互斥锁
互斥锁是一种常见的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。使用互斥锁来修复上面的代码:
使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁。当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒策略是随机的。
1.2 读写互斥锁
互斥锁是完全互斥的,当一个goroutine获取到锁时,其它的goroutine得等待锁的释放。但是实际情况下,是读多写少的情况,如果使用互斥锁效率会很低。实际当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的。这种场景下使用读写锁效率会更高一点。读写锁在Go语言中使用Sync包中的RWMutex类型。
读写锁分为两种:读锁和写锁。当一个goroutine获取到读锁,其它协程也可以获取该读锁,但是获取写锁会等待。当一个goroutine获取到写锁,其它goroutine无论是获取读锁还是写锁都会等待。
示例:
package main
import (
"fmt"
"sync"
"time"
)
var (
x int
wg sync.WaitGroup
rwlock sync.RWMutex
lock sync.Mutex
)
func write() {
//加互斥锁
//lock.Lock()
//加写锁
rwlock.Lock()
x = x + 1
time.Sleep(10 * time.Millisecond) //加锁读操作耗时10毫秒
//lock.Unlock() //释放互斥锁
rwlock.Unlock() //释放写锁
wg.Done()
}
func read() {
//加互斥锁
//lock.Lock()
rwlock.RLock() //加读锁
time.Sleep(time.Millisecond)
//lock.Unlock() //释放互斥锁
rwlock.RUnlock() //释放读锁
wg.Done()
}
func main() {
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go write()
}
for i := 0; i < 1000; i++ {
wg.Add(1)
go read()
}
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}
注意:读写锁适用于读多写少的情况,如果读和写操作差别不大,读写锁优势发挥不出来。
二. Sync包
2.1 sync.WaitGroup
我们直到当主协程结束,不论子协程是否执行完都会结束执行。但是在代码中生硬的使用time.Sleep肯定是不合适的,Go语言可以使用sync.WaitGroup来实现并发任务的同步。
sync.WaitGroup内部维护着计数器,计数器的值可以增加和减少。例如:当我们启动了N个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器为0时,表示所有并发任务已完成。
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func test() {
defer wg.Done()
fmt.Println("hello world")
}
func main() {
wg.Add(1)
go test()
fmt.Println("main goroutine done!")
wg.Wait()
}
需要注意:sync.WaitGroup是一个结构体,传递的时候,为了防止拷贝的开销,最好传递指针。
2.2 sync.Once
在编程的很多情况下我们需要确保某些操作在高并发场景下只执行一次,例如:只加载一次配置文件,只关闭一次通道等。
Go语言中的Sync包提供了一个针对只执行一次场景的解决方案——sync.Once
注意:Do方法中,如果要执行的函数f需要传递参数,就需要搭配闭包来使用。因为函数f没有参数,闭包获取外面的变量。
- 加载配置文件示例
延时一个开销很大的初始化操作,到真正用到它的时候执行是一个很好的实践。因为预先初始化一个变量(比如在init函数中完成初始化)会增加程序启动耗时,而且很有可能实际执行过程中这个变量没有用上,那么这个初始化操作就不是必须要做的。
多个goroutine并发调Icon函数时不是并发安全的,现代编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由的重排访问内存顺序。loadIcon函数可能会被重排成以下结果:
func loadIcond() {
icons = make(map[string]image.Image)
icons["left"] = loadIcon("left.png")
icons["right"] = loadIcon("right.png")
icons["up"] = loadIcon("up.png")
icons["down"] = loadIcon("down.png")
}
在这种情况下即使判断了icons不是nil也不意味着变量初始化完成了。因为可能一个协程执行到 icons = make(map[string]image.Image),另外一个协程执行到判断 icons == nil,此时该判断为false,但是icons还没有被赋值。
考虑到这种情况,我们能想到的办法就是添加互斥锁,保证初始化icons的时候不会被其他的goroutine操作,但是这样做会引发性能问题。
使用sync.Once改造示例代码
sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。(有点像C/C++里的单例模式),这样设计就能保证初始化操作的时候是并发安全的,并且初始化操作也不会被执行多次。
2.3 sync.Map
Go语言内置的的map不是并发安全的。
像上面这种情况就需要为map加锁来保证并发的安全性,Go语言的Sync包提供了一个开箱即用的并发安全的map——sync.Map,可以直接使用,不需要像内置map一样使用make函数初始化才能使用。同时sync.Map内置了诸如Store,Load,LoadOrStore,Delete,Range等函数。
三. 原子操作(atomic包)
代码中的加锁操作因为涉及内核态的上下文切换会比较耗时,代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法他在用户态就可以完成,因此性能比加锁操作更好。Go语言中原子操作有内置标准库sync/atomic提供。
可以通过标准库文档查看sync/atomic库中提供了哪些方法。Go语言标准库文档中文版
- 示例
比较原子操作和互斥锁
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var x int64
var mt sync.Mutex
var mg sync.WaitGroup
// 不是并发安全的
func add() {
x++
mg.Done()
}
// 加锁版,开销比较大
func mutexAdd() {
mt.Lock()
x++
mt.Unlock()
mg.Done()
}
// 原子操作版
func atomicAdd() {
atomic.AddInt64(&x, 1)
mg.Done()
}
func main() {
start := time.Now()
for i := 0; i < 1000; i++ {
//go add() 不是并发安全的
go mutexAdd()
mg.Add(1)
}
mg.Wait()
end := time.Now()
fmt.Println(x)
fmt.Println(end.Sub(start))
start = time.Now()
for i := 0; i < 1000; i++ {
go atomicAdd()
mg.Add(1)
}
mg.Wait()
end = time.Now()
fmt.Println(x)
fmt.Println(end.Sub(start))
}
atomic包提供了底层的原子级的操作,对于同步算法的实现很有用。这些函数必须谨慎并保证正确的使用。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好。