浅析Mutex(1)

93 阅读5分钟

背景

go语言开发过程中,我们经常会遇到多个goroutine并发更新同一个资源,从而导致数据混乱等等后果,那么我们就可以通过互斥锁来解决这个问题,在go语言中就是Mutex。

实现机制

 在并发编程中,如果程序中的一部分会被并发访问或修改,那么,为了避免并发访问导致的意想不到的结果,这部分程序需要被保护起来,这部分被保护起来的程序,就叫做临界区。
 如果很多线程同步访问临界区,就会造成访问或操作错误,这当然不是我们希望看到的结果。所以,我们可以使用互斥锁,限定临界区只能同时由一个线程持有。
 当临界区由一个线程持有的时候,其它线程如果想进入这个临界区,就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。

同步原语

同步原语,并没有一个严格的定义,你可以把它看作解决并发问题的一个基础的数据结构。主要有如下类型:
共享资源。并发地读写共享资源,会出现数据竞争(data race)的问题,所以需要Mutex、RWMutex 这样的并发原语来保护。
任务编排。需要 goroutine 按照一定的规律执行,而 goroutine 之间有相互等待或者依赖的顺序关系,我们常常使用 WaitGroup 或者 Channel 来实现。
消息传递。信息交流以及不同的 goroutine 之间的线程安全的数据交流,常常使用Channel 来实现。

基本用法

  1. sync/mutex.go.30
// A Locker represents an object that can be lockedand unlocked.
type Locker interface {
   Lock()
   Unlock()
}
定义了锁接口的方法集,就是请求锁和释放锁

2. sync/mutex.go.72 -> 进入临界区前调用

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
   // Fast path: grab unlocked mutex.
   if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
      if race.Enabled {
         race.Acquire(unsafe.Pointer(m))
      }
      return
   }
   // Slow path (outlined so that the fast path can be inlined)
   m.lockSlow()
}
当一个 goroutine 通过调用 Lock 方法获得了这个锁的拥有权后,其它请求锁的goroutine 就会阻塞在 Lock 方法的调用上,直到锁被释放并且自己获取到了这个锁的拥有权。
在这里我们可能觉得为什么要加锁,那么我们来举个栗子:
func TestMutexNoLock(t *testing.T) {
   count := 0
   var wg sync.WaitGroup
   for i := 0; i < 10; i++ {
      wg.Add(1)
      go func() {
         defer wg.Done()
         for j := 0; j < 100000; j++ {
            count++
         }
      }()
   }
   wg.Wait()
   fmt.Println(count)
}
理论上,count应该等于1000000,但是实际运行出来,会发现每次结果都不一样,这个原因就在于对于count不是原子操作,这就是并发访问共享数据的常见错误。
针对这种场景,go官方也有给提供了一个检测并发访问共享资源是否有问题的工具(Go race detector),可以帮我们自动发现程序有没有数据竞争(date race)的问题。
命令:go test -c -race -o

image.png 那么count作为共享资源,临界区是count++,只要在临界区前面获取锁,在离开临界区的时候释放锁,就能完美地解决 data race 的问题了。

func TestMutexLock(t *testing.T) {
  count := 0
  var wg sync.WaitGroup
  var mu sync.Mutex
  for i := 0; i < 10; i++ {
     wg.Add(1)
     go func() {
        defer wg.Done()
        for j := 0; j < 100000; j++ {
           mu.Lock()
           count++
           mu.Unlock()
        }
     }()
  }
  wg.Wait()
  fmt.Println(count)
}

Mutex 的零值是还没有 goroutine 等待的未加锁的状态,所以你不需要额外的初始化,直接声明变量(如 var mu sync.Mutex)即可

3.配合结构体使用

image.png

image.png

4.演变过程

image.png 4.1 通过一个 flag 变量,标记当前的锁是否被某个 goroutine 持有。如果 这个 flag 的值是 1,就代表锁已经被持有,那么,其它竞争的 goroutine 只能等待;如果 这个 flag 的值是 0,就可以通过 CAS(compare-and-swap,或者 compare-and-set) 将这个 flag 设置为 1,标识锁被当前的这个 goroutine 持有了 CAS 指令将给定的值和一个内存地址中的值进行比较,如果它们是同一个值,就使用新值 替换内存地址中的值,这个操作是原子性的。那啥是原子性呢?如果你还不太理解这个概念,那么在这里只需要明确一点就行了,那就是原子性保证这个指令总是基于最新的值进 行计算,如果同时有其它线程已经修改了这个值,那么,CAS 会返回失败。CAS 是实现互斥锁和同步原语的基础 参考文章:segmentfault.com/a/119000004…

4.2 Mutex 结构体包含两个字段:

1)字段key:是一个 flag,用来标识这个排外锁是否被某个 goroutine 所持有,如果 key 大于等于 1,说明这个排外锁已经被持有;

2)字段 sema:是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒

image.png

重点: Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的 goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的 goroutine 的信息,所以,Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至 今。所以我们尽量要遵循谁申请,谁释放的原则

4.3

image.png 改变了结构,第一个字段换成了state字段

image.png state 是一个复合型的字段,一个字段包含多个意义,这样可以通过尽可能少的内存来实现 互斥锁。这个字段的第一位(最小的一位)来表示这个锁是否被持有,第二位代表是否有 唤醒的 goroutine,剩余的位数代表的是等待此锁的 goroutine 数。所以,state 这一个 字段被分成了三部分,代表三个数据。

image.png

4.4

image.png Mutex 绝不容忍一个 goroutine 被落下,永远没有机会获取锁。不抛弃不放弃是它的宗旨,而且它也尽可能地 让等待较长的 goroutine 更有机会获取到 锁。