Go 语言学习入门系列:互斥锁和读写锁

要知道的是在 Go 语言中,信道的地位非常高,面对并发问题,我们始终应该优先考虑使用信道。

但是如果通过信道解决不了的,不得不使用共享内存来实现并发编程的,那 Golang 中的锁机制必须会使用

而在 Golang 里有专门的方法来实现锁,在 sync 包里面。这个包有两个很重要的锁类型

  • 一个叫 Mutex, 利用它可以实现互斥锁。
  • 一个叫 RWMutex,利用它可以实现读写锁。

互斥锁 :Mutex

使用互斥锁(Mutex,全称 mutual exclusion)是为了来保护一个资源不会因为并发操作而引起冲突导致数据不准确。

举个栗子,就像下面这段代码,我开启了三个协程,每个协程分别往 count 这个变量加1000次 1,理论上看,最终的 count 值应试为 3000。

package main

import (
    "fmt"
    "sync"
)

func add(count *int, wg *sync.WaitGroup) {
    for i := 0; i < 1000; i++ {
        *count = *count + 1
    }
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    count := 0
    wg.Add(3)
    go add(&count, &wg)
    go add(&count, &wg)
    go add(&count, &wg)

    wg.Wait()
    fmt.Println("count 的值为:", count)
}

可运行多次的结果,都不相同

// 第一次
count 的值为: 2854

// 第二次
count 的值为: 2673

// 第三次
count 的值为: 2840

原因就在于这三个协程在执行时,先读取 count 再更新 count 的值,而这个过程并不具备原子性(同一时刻多个协程读取了count,而某一时刻的count值是一样的,同一时刻 count 就少加了),所以导致了数据的不准确。

解决这个问题的方法,就是给 add 这个函数加上 Mutex 互斥锁,要求同一时刻,仅能有一个协程能对 count 操作。

在写代码前,先了解一下 Mutex 锁的两种定义方法

// 第一种
var lock *sync.Mutex
lock = new(sync.Mutex)

// 第二种
lock := &sync.Mutex{}

修改上面的代码,如下所示

import (
    "fmt"
    "sync"
)

func add(count *int, wg *sync.WaitGroup, lock *sync.Mutex) {
    for i := 0; i < 1000; i++ {
        lock.Lock()
        *count = *count + 1 //必须是引用
        lock.Unlock()
    }
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    lock := &sync.Mutex{}
    count := 0
    wg.Add(3)
    go add(&count, &wg, lock)
    go add(&count, &wg, lock)
    go add(&count, &wg, lock)

    wg.Wait()
    fmt.Println("count 的值为:", count)
}

此时,不管执行多少次,输出都只有一个结果

count 的值为: 3000

使用 Mutext 锁虽然很简单,但需要注意:

  • 同一协程里,不要在尚未解锁时再次使加锁

  • 同一协程里,不要对已解锁的锁再次解锁

  • 加了锁后,别忘了解锁,必要时使用 defer 语句

读写锁:RWMutex

Mutex 是一种傻瓜式的操作的锁类型,加锁解锁加锁解锁,很简单。但是简单 同时意味着在某些特殊情况下有可能会造成时间上的浪费,导致程序性能低下。

举个例子,我们平时去图书馆,要么是去借书,要么去还书,借书的流程繁锁,没有办卡的还要让管理员给你办卡,因此借书通常都要排老长的队,假设图书馆里只有一个管理员,按照 Mutex(互斥锁)的思想, 这个管理员同一时刻只能服务一个人,这就意味着,还书的也要跟借书的一起排队。

可还书的步骤非常简单,可能就把书给管理员扫下码就可以走了。如果让还书的人,跟借书的人一起排队,那估计有很多人都不乐意了。因此,图书馆为了提高整个流程的效率,就允许还书的人,不需要排队,可以直接自助还书。图书管将馆里的人分得更细了,对于读者的不同需求提供了不同的方案。提高了效率。(或者是寄取快递的栗子)

RWMutex,也是如此,它将程序对资源的访问分为读操作和写操作

  • 为了保证数据的安全,它规定了当有人还在读取数据(即读锁占用)时,不允计有人更新这个数据(即写锁会阻塞)。有人在更新数据(即写锁占用)的时候,不允许其他的协程读取数剧(读锁将阻塞)和更新数据(即写锁阻塞)

  • 为了保证程序的效率,多个人(线程)读取数据(拥有读锁)时,互不影响不会造成阻塞,它不会像 Mutex 那样只允许有一个人(线程)读取同一个数据。

 

也就是说读锁与读锁兼容,读锁与写锁互斥,写锁与写锁互斥,只有在锁释放后才可以继续申请互斥的锁

理解了这个后,再来看看,如何使用 RWMutex?

定义一个 RWMuteux 锁,同样有两种方法

// 第一种
var lock *sync.RWMutex
lock = new(sync.RWMutex)

// 第二种
lock := &sync.RWMutex{}

RWMutex 里提供了两种锁,每种锁分别对应两个方法,为了避免死锁,两个方法应成对出现,必要时请使用 defer。

  • 读锁:调用 RLock 方法开启锁,调用 RUnlock 释放锁

  • 写锁:调用 Lock 方法开启锁,调用 Unlock 释放锁(和 Mutex类似)

接下来,直接看一下例子吧

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    lock := &sync.RWMutex{}
    lock.Lock()

    for i := 0; i < 4; i++ {
        go func(i int) {
            fmt.Printf("第 %d 个协程准备开始... \n", i)
            lock.RLock()
            fmt.Printf("第 %d 个协程获得读锁, sleep 1s 后,释放锁\n", i)
            time.Sleep(time.Second)
            lock.RUnlock()
        }(i)
    }

    time.Sleep(time.Second * 2)

    fmt.Println("准备释放写锁,读锁不再阻塞")
    // 写锁一释放,读锁就自由了
    lock.Unlock()

    // 由于会等到读锁全部释放,才能获得写锁
    // 因为这里一定会在上面 4 个协程全部完成才能往下走
    lock.Lock()
    fmt.Println("程序退出...")
    lock.Unlock()
}

输出如下

第 1 个协程准备开始... 
第 0 个协程准备开始... 
第 3 个协程准备开始... 
第 2 个协程准备开始... 
准备释放写锁,读锁不再阻塞
第 2 个协程获得读锁, sleep 1s 后,释放锁
第 3 个协程获得读锁, sleep 1s 后,释放锁
第 1 个协程获得读锁, sleep 1s 后,释放锁
第 0 个协程获得读锁, sleep 1s 后,释放锁
程序退出...
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章