1. 并发安全和锁

有时候在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。类比现实生活中的例子有十字路口被各个方向的的汽车竞争;还有火车上的卫生间被车厢里的人竞争。

举个例子:

  1. var x int64
  2. var wg sync.WaitGroup
  3. func add() {
  4. for i := 0; i < 5000; i++ {
  5. x = x + 1
  6. }
  7. wg.Done()
  8. }
  9. func main() {
  10. wg.Add(2)
  11. go add()
  12. go add()
  13. wg.Wait()
  14. fmt.Println(x)
  15. }

上面的代码中我们开启了两个goroutine去累加变量x的值,这两个goroutine在访问和修改x变量的时候就会存在数据竞争,导致最后的结果与期待的不符。

1.1.1. 互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。 使用互斥锁来修复上面代码的问题:

  1. var x int64
  2. var wg sync.WaitGroup
  3. var lock sync.Mutex
  4. func add() {
  5. for i := 0; i < 5000; i++ {
  6. lock.Lock() // 加锁
  7. x = x + 1
  8. lock.Unlock() // 解锁
  9. }
  10. wg.Done()
  11. }
  12. func main() {
  13. wg.Add(2)
  14. go add()
  15. go add()
  16. wg.Wait()
  17. fmt.Println(x)
  18. }

使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。

1.1.2. 读写互斥锁

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。

读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

读写锁示例:

  1. var (
  2. x int64
  3. wg sync.WaitGroup
  4. lock sync.Mutex
  5. rwlock sync.RWMutex
  6. )
  7. func write() {
  8. // lock.Lock() // 加互斥锁
  9. rwlock.Lock() // 加写锁
  10. x = x + 1
  11. time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
  12. rwlock.Unlock() // 解写锁
  13. // lock.Unlock() // 解互斥锁
  14. wg.Done()
  15. }
  16. func read() {
  17. // lock.Lock() // 加互斥锁
  18. rwlock.RLock() // 加读锁
  19. time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
  20. rwlock.RUnlock() // 解读锁
  21. // lock.Unlock() // 解互斥锁
  22. wg.Done()
  23. }
  24. func main() {
  25. start := time.Now()
  26. for i := 0; i < 10; i++ {
  27. wg.Add(1)
  28. go write()
  29. }
  30. for i := 0; i < 1000; i++ {
  31. wg.Add(1)
  32. go read()
  33. }
  34. wg.Wait()
  35. end := time.Now()
  36. fmt.Println(end.Sub(start))
  37. }

需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。