Go 语言中锁的使用 - 深度解析和实例教程

silverwq
2022-07-17 / 0 评论 / 282 阅读 / 正在检测是否收录...

共享内存

我们知道,channel 可以在在多个 goroutine 之间进行通信,其实对于并发还有一种较为常用通信方式,那就是共享内存,这是因为每个协程函数里都可以访问到全局变量。首先我们来看一个例子:

var name string

func printName() {
  log.Println("name is", name)
}

func main() {
    name = "小明"

   go printName() // 输出小明
  go printName() // 输出小明

  time.Sleep(time.Second)

 name = "小红"

   go printName() // 输出小红
  go printName() // 输出小红

  time.Sleep(time.Second)
}

可以看到在两个 goroutine 中我们都可以访问 name 这个变量,当修改它后,在不同的 goroutine 中都可以同时获取到最新的值。这就是一个最简单的通过共享变量(内存)的方式在多个 goroutine 进行通信的方式。

如上我们知道,对变量进行并行读问题不太大,但是对变量进行并行的写的时候如果不加锁就会出现问题:

func main() {
    var (
       wg      sync.WaitGroup
      numbers []int
   )

   for i := 0; i < 100; i++ {
      wg.Add(1)
       go func(i int) {
            numbers = append(numbers, i)
            wg.Done()
       }(i)
    }

   wg.Wait()

   fmt.Println("The numbers is", len(numbers))
}

上诉例子运行的之后,有的时候,输出的结果是是65,有的时候输出是80等等,反正输出的结果不固定,不符合预期结果100,可以看到当我们并发对同一个切片进行写操作的时候,会出现数据不一致的问题,这就是一个典型的共享变量的问题。

sync.Mutex互斥锁

针对这个问题我们可以使用 Mutex 锁(其它语言中也叫排他锁、写锁)来修复,sync.Mutex 一旦被锁住,其它的 Lock() 操作就无法再获取它的锁,只有通过 Unlock() 释放锁之后才能通过 Lock() 继续获取锁,从而保证数据的一致性。

另外需要注意,sync.Mutex 不区分读写锁,只有 Lock()Lock() 之间才会导致阻塞的情况,如果在一个地方 Lock(),在另一个地方不 Lock() 而是直接修改或访问共享数据,这对于 sync.Mutex 类型来说是允许的,因为 mutex 不会和 goroutine 进行关联。例如:

func main() {
   var (
       wg      sync.WaitGroup
      numbers []int

       mux sync.Mutex
  )

   for i := 0; i < 100; i++ {
      wg.Add(1)
       go func(i int) {
            mux.Lock()
          numbers = append(numbers, i)
            mux.Unlock()

            wg.Done()
       }(i)
    }

   wg.Wait()

   fmt.Println("The numbers is", len(numbers))
}

修改过后,我们再次运行代码,可以看到最后的 numbers 的数量始终是100个了。

可以使用 TryLock 来不阻塞判断是否被锁住

// serverAutoUnregisterLocker 防止本任务还没执行完成,下个任务又开始,拖垮系统
var serverAutoUnregisterLocker sync.Mutex

func Run() {
    ctx := context.Background()
    tqtlog.Info(ctx, fmt.Sprintf("auto unregister cron job start exec"))
    if serverAutoUnregisterLocker.TryLock() {
        defer serverAutoUnregisterLocker.Unlock()
        err := a.service.AutoUnregisterCronJob(ctx)
        if err != nil {
            tqtlog.Error(ctx, fmt.Sprintf(`AutoUnregisterCronJob exec failed, err:%v`, err))
        }
    } else {
        tqtlog.Warn(ctx, fmt.Sprintf("AutoUnregisterCronJob 获取锁失败,存在任务正在执行"))
    }
    tqtlog.Info(ctx, fmt.Sprintf("auto unregister cron job exec end"))
}

sync.RWMutex读写互斥锁

sync.Mutex 是互斥锁,只有一个信号标量;在 Go 中还有一种读写锁 sync.RWMutex,对于我们的共享对象,如果可以分离出读和写两个互斥信号的情况,可以考虑使用它来提高读的并发性能。

RWMutex 是基于 Mutex 的,在 Mutex 的基础之上增加了读、写的信号量,并使用了类似引用计数的读锁数量:

  1. 读锁与读锁兼容,读锁与写锁互斥,写锁与写锁互斥,只有在锁释放后才可以继续申请互斥的锁:
  2. 可以同时申请多个读锁
  3. 有读锁时申请写锁将阻塞,有写锁时申请读锁将阻塞
  4. 只要有写锁,后续申请读锁和写锁都将阻塞

有以下几个方法:

func (rw *RWMutex) Lock()
func (rw *RWMutex) RLock()
func (rw *RWMutex) RLocker() Locker
func (rw *RWMutex) RUnlock()
func (rw *RWMutex) Unlock()

Lock()Unlock() 用于申请和释放写锁;RLock()RUnlock() 用于申请和释放读锁,一次 RUnlock() 操作只是对读锁数量减1,即减少一次读锁的引用计数;如果不存在写锁,则 Unlock() 引发 panic,如果不存在读锁,则 RUnlock() 引发 panic;RLocker() 用于返回一个实现了 Lock()Unlock() 方法的 Locker 接口。

由于读锁之间是可以没有阻塞的,所以效率会比较高,如下所示:

func main() {
    var (
       mux    sync.Mutex
       state1 = map[string]int{
            "a": 65,
      }
       muxTotal uint64

     rw     sync.RWMutex
     state2 = map[string]int{
            "a": 65,
      }
       rwTotal uint64
  )

   for i := 0; i < 10; i++ {
       go func() {
         for {
               mux.Lock()
              _ = state1["a"]
               mux.Unlock()
                atomic.AddUint64(&muxTotal, 1)
          }
       }()
 }

   for i := 0; i < 10; i++ {
       go func() {
         for {
               rw.RLock()
              _ = state2["a"]
               rw.RUnlock()
                atomic.AddUint64(&rwTotal, 1)
           }
       }()
 }

   time.Sleep(time.Second)

 fmt.Println("sync.Mutex readOps is", muxTotal)
    fmt.Println("sync.RWMutex readOps is", rwTotal)
}

运行代码,可以得到以下结果,明显看到同样对变量进行读的话,读锁的效率比写锁高的多:

sync.Mutex readOps is 485 9713
sync.RWMutex readOps is 359 94874
0

评论 (0)

取消