《Go语言四十二章经》第二十三章 同步与锁

作者:李骁

23.1 同步锁

Go语言包中的sync包提供了两种锁类型:sync.Mutex和sync.RWMutex,前者是互斥锁,后者是读写锁。

互斥锁是传统的并发程序对共享资源进行访问控制的主要手段,在Go中,似乎更推崇由channel来实现资源共享和通信。它由标准库代码包sync中的Mutex结构体类型代表。只有两个公开方法:调用Lock()获得锁,调用unlock()释放锁。

  • 使用Lock()加锁后,不能再继续对其加锁(同一个goroutine中,即:同步调用),否则会panic。只有在unlock()之后才能再次Lock()。异步调用Lock(),是正当的锁竞争,当然不会有panic了。适用于读写不确定场景,即读写次数没有明显的区别,并且只允许只有一个读或者写的场景,所以该锁也叫做全局锁。

  • func (m *Mutex) Unlock()用于解锁m,如果在使用Unlock()前未加锁,就会引起一个运行错误。已经锁定的Mutex并不与特定的goroutine相关联,这样可以利用一个goroutine对其加锁,再利用其他goroutine对其解锁。

建议:同一个互斥锁的成对锁定和解锁操作放在同一层次的代码块中。
使用锁的经典模式:

  1. var lck sync.Mutex
  2. func foo() {
  3. lck.Lock()
  4. defer lck.Unlock()
  5. // ...
  6. }

lck.Lock()会阻塞直到获取锁,然后利用defer语句在函数返回时自动释放锁。

下面代码通过3个goroutine来体现sync.Mutex 对资源的访问控制特征:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "time"
  6. )
  7. func main() {
  8. wg := sync.WaitGroup{}
  9. var mutex sync.Mutex
  10. fmt.Println("Locking (G0)")
  11. mutex.Lock()
  12. fmt.Println("locked (G0)")
  13. wg.Add(3)
  14. for i := 1; i < 4; i++ {
  15. go func(i int) {
  16. fmt.Printf("Locking (G%d)\n", i)
  17. mutex.Lock()
  18. fmt.Printf("locked (G%d)\n", i)
  19. time.Sleep(time.Second * 2)
  20. mutex.Unlock()
  21. fmt.Printf("unlocked (G%d)\n", i)
  22. wg.Done()
  23. }(i)
  24. }
  25. time.Sleep(time.Second * 5)
  26. fmt.Println("ready unlock (G0)")
  27. mutex.Unlock()
  28. fmt.Println("unlocked (G0)")
  29. wg.Wait()
  30. }
  1. 程序输出:
  2. Locking (G0)
  3. locked (G0)
  4. Locking (G1)
  5. Locking (G3)
  6. Locking (G2)
  7. ready unlock (G0)
  8. unlocked (G0)
  9. locked (G1)
  10. unlocked (G1)
  11. locked (G3)
  12. locked (G2)
  13. unlocked (G3)
  14. unlocked (G2)

通过程序执行结果我们可以看到,当有锁释放时,才能进行lock动作,G0锁释放时,才有后续锁释放的可能,这里是G1抢到释放机会。

Mutex也可以作为struct的一部分,这样这个struct就会防止被多线程更改数据。

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "time"
  6. )
  7. type Book struct {
  8. BookName string
  9. L *sync.Mutex
  10. }
  11. func (bk *Book) SetName(wg *sync.WaitGroup, name string) {
  12. defer func() {
  13. fmt.Println("Unlock set name:", name)
  14. bk.L.Unlock()
  15. wg.Done()
  16. }()
  17. bk.L.Lock()
  18. fmt.Println("Lock set name:", name)
  19. time.Sleep(1 * time.Second)
  20. bk.BookName = name
  21. }
  22. func main() {
  23. bk := Book{}
  24. bk.L = new(sync.Mutex)
  25. wg := &sync.WaitGroup{}
  26. books := []string{"《三国演义》", "《道德经》", "《西游记》"}
  27. for _, book := range books {
  28. wg.Add(1)
  29. go bk.SetName(wg, book)
  30. }
  31. wg.Wait()
  32. }
  1. 程序输出:
  2. Lock set name: 《西游记》
  3. Unlock set name: 《西游记》
  4. Lock set name: 《三国演义》
  5. Unlock set name: 《三国演义》
  6. Lock set name: 《道德经》
  7. Unlock set name: 《道德经》

23.2 读写锁

读写锁是分别针对读操作和写操作进行锁定和解锁操作的互斥锁。在Go语言中,读写锁由结构体类型sync.RWMutex代表。

基本遵循原则:

  • 写锁定情况下,对读写锁进行读锁定或者写锁定,都将阻塞;而且读锁与写锁之间是互斥的;

  • 读锁定情况下,对读写锁进行写锁定,将阻塞;加读锁时不会阻塞;

  • 对未被写锁定的读写锁进行写解锁,会引发Panic;

  • 对未被读锁定的读写锁进行读解锁的时候也会引发Panic;

  • 写解锁在进行的同时会试图唤醒所有因进行读锁定而被阻塞的goroutine;

  • 读解锁在进行的时候则会试图唤醒一个因进行写锁定而被阻塞的goroutine。

与互斥锁类似,sync.RWMutex类型的零值就已经是立即可用的读写锁了。在此类型的方法集合中包含了两对方法,即:

RWMutex提供四个方法:

  1. func (*RWMutex) Lock // 写锁定
  2. func (*RWMutex) Unlock // 写解锁
  3. func (*RWMutex) RLock // 读锁定
  4. func (*RWMutex) RUnlock // 读解锁
  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "time"
  6. )
  7. var m *sync.RWMutex
  8. func main() {
  9. wg := sync.WaitGroup{}
  10. wg.Add(20)
  11. var rwMutex sync.RWMutex
  12. Data := 0
  13. for i := 0; i < 10; i++ {
  14. go func(t int) {
  15. rwMutex.RLock()
  16. defer rwMutex.RUnlock()
  17. fmt.Printf("Read data: %v\n", Data)
  18. wg.Done()
  19. time.Sleep(2 * time.Second)
  20. // 这句代码第一次运行后,读解锁。
  21. // 循环到第二个时,读锁定后,这个goroutine就没有阻塞,同时读成功。
  22. }(i)
  23. go func(t int) {
  24. rwMutex.Lock()
  25. defer rwMutex.Unlock()
  26. Data += t
  27. fmt.Printf("Write Data: %v %d \n", Data, t)
  28. wg.Done()
  29. // 这句代码让写锁的效果显示出来,写锁定下是需要解锁后才能写的。
  30. time.Sleep(2 * time.Second)
  31. }(i)
  32. }
  33. time.Sleep(5 * time.Second)
  34. wg.Wait()
  35. }

23.3 sync.WaitGroup

前面例子中我们有使用WaitGroup,它用于线程同步,WaitGroup等待一组线程集合完成,才会继续向下执行。 主线程(goroutine)调用Add来设置等待的线程(goroutine)数量。 然后每个线程(goroutine)运行,并在完成后调用Done。 同时,Wait用来阻塞,直到所有线程(goroutine)完成才会向下执行。Add(-1)和Done()效果一致。

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. var wg sync.WaitGroup
  8. for i := 0; i < 10; i++ {
  9. wg.Add(1)
  10. go func(t int) {
  11. defer wg.Done()
  12. fmt.Println(t)
  13. }(i)
  14. }
  15. wg.Wait()
  16. }

23.4 sync.Once

sync.Once.Do(f func())能保证once只执行一次,这个sync.Once块只会执行一次。

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "time"
  6. )
  7. var once sync.Once
  8. func main() {
  9. for i, v := range make([]string, 10) {
  10. once.Do(onces)
  11. fmt.Println("v:", v, "---i:", i)
  12. }
  13. for i := 0; i < 10; i++ {
  14. go func(i int) {
  15. once.Do(onced)
  16. fmt.Println(i)
  17. }(i)
  18. }
  19. time.Sleep(4000)
  20. }
  21. func onces() {
  22. fmt.Println("onces")
  23. }
  24. func onced() {
  25. fmt.Println("onced")
  26. }

23.5 sync.Map

随着Go1.9的发布,有了一个新的特性,那就是sync.map,它是原生支持并发安全的map。虽然说普通map并不是线程安全(或者说并发安全),但一般情况下我们还是使用它,因为这足够了;只有在涉及到线程安全,再考虑sync.map。

但由于sync.Map的读取并不是类型安全的,所以我们在使用Load读取数据的时候我们需要做类型转换。

sync.Map的使用上和map有较大差异,详情见代码。

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. var m sync.Map
  8. //Store
  9. m.Store("name", "Joe")
  10. m.Store("gender", "Male")
  11. //LoadOrStore
  12. //若key不存在,则存入key和value,返回false和输入的value
  13. v, ok := m.LoadOrStore("name1", "Jim")
  14. fmt.Println(ok, v) //false aaa
  15. //若key已存在,则返回true和key对应的value,不会修改原来的value
  16. v, ok = m.LoadOrStore("name", "aaa")
  17. fmt.Println(ok, v) //false aaa
  18. //Load
  19. v, ok = m.Load("name")
  20. if ok {
  21. fmt.Println("key存在,值是: ", v)
  22. } else {
  23. fmt.Println("key不存在")
  24. }
  25. //Range
  26. //遍历sync.Map
  27. f := func(k, v interface{}) bool {
  28. fmt.Println(k, v)
  29. return true
  30. }
  31. m.Range(f)
  32. //Delete
  33. m.Delete("name1")
  34. fmt.Println(m.Load("name1"))
  35. }