有时候在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. }

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

Mutex 只能保证临界区内的操作是可观测的 即只有处于o.m.Lock() 和 defer o.m.Unlock()之间的代码

自旋锁是一种互斥锁的实现方式而已,相比一般的互斥锁会在等待期间放弃cpu,自旋锁(spinlock)则是不断循环并测试锁的状态,这样就一直占着cpu。

互斥锁:用于保护临界区,确保同一时间只有一个线程访问数据。对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。

临界区:每个进程中访问临界资源的那段程序称为临界区,每次只允许一个进程进入临界区,进入后不允许其他进程进入。

自旋锁:与互斥量类似,它不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。用在以下情况:锁持有的时间短,而且线程并不希望在重新调度上花太多的成本。”原地打转”。
自旋锁与互斥锁的区别:线程在申请自旋锁的时候,线程不会被挂起,而是处于忙等的状态。

信号量:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。