Skip to content

4.6 学习 Go 协程:互斥锁和读写锁

在 Golang 里有专门的方法来实现锁,还是上一节里介绍的 sync 包。

这个包有两个很重要的锁类型

一个叫 sync.Mutex 利用它可以实现互斥锁。

一个叫 sync.RWMutex,利用它可以实现读写锁。

一、互斥锁(Mutex)

1. 概念与作用

互斥锁(Mutex,Mutual Exclusion)是一种最常用的控制共享资源访问的方法。它确保在任何时间点,只有一个 goroutine 能够访问被保护的共享资源(也称为临界区)。

  • 特点: 完全互斥。一旦一个 goroutine 获得锁,其他所有试图获取该锁的 goroutine(无论读写)都会被阻塞,直到锁被释放。

2. Mutex 的操作方法

sync.Mutex 提供了两个核心方法:

方法作用描述
Lock()加锁锁定互斥锁。如果锁已经被其他 goroutine 占用,则当前 goroutine 会被阻塞,直到锁被释放。
Unlock()解锁释放互斥锁。将锁交给等待中的 goroutine(唤醒策略是随机的)。

3. 使用示例

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

go
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 的值为: 2850
count 的值为: 2966
count 的值为: 2959

原因就在于这三个协程在执行时,先读取 count 再更新 count 的值,而这个过程并不具备原子性,所以导致了数据的不准确。解决这个问题的方法,就是给 add 这个函数加上 Mutex 互斥锁,要求同一时刻,仅能有一个协程能对 count 操作。

go
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

4. 使用注意事项

  • 加解锁配对: 必须在 Lock() 之后调用 Unlock()
  • defer 推荐: 强烈建议使用 defer lock.Unlock() 来确保锁的释放,即使函数提前返回或发生 panic
  • 禁止重复操作:
    • 不要对一个尚未解锁的锁再次加锁(会导致死锁)。
    • 不要对一个已解锁的锁再次解锁(会导致 panic)。

二、读写锁(RWMutex)

1. 概念与作用

读写锁(RWMutex,Read-Write Mutex)适用于读多写少的场景。它对操作进行了区分,允许多个 goroutine 同时进行读操作,但写操作必须是独占的。

  • 特性:
    • 读锁共享: 当一个 goroutine 获得读锁后,其他 goroutine 仍然可以获得读锁(共享访问)。
    • 写锁独占: 当一个 goroutine 获得写锁后,其他 goroutine 无论是想获取读锁还是写锁,都必须等待(独占访问)。

2. RWMutex 的操作方法

sync.RWMutex 提供了四种方法,分别对应读操作和写操作的加锁和解锁:

类型方法作用描述
写锁Lock()加写锁阻塞所有读写操作。用于写入数据前加锁。
写锁Unlock()解写锁释放写锁。
读锁RLock()加读锁允许其他 goroutine 同时持有读锁。用于读取数据前加锁。
读锁RUnlock()解读锁释放读锁。

3. 读写锁的工作机制

状态谁可以进入临界区?试图进入的 goroutine 会发生什么?
正在(持有写锁)只有当前写 goroutine所有新的读/写操作都会阻塞
正在(持有读锁)当前读 goroutine 和其他读 goroutine新的读操作可以继续;新的写操作会阻塞
空闲任何读或写操作读/写操作都可以立即获得相应的锁

4. 使用示例(读多写少)

使用读写锁可以大幅提升读操作的并发性能,尤其在读操作耗时较长时。

go
package main

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

var (
    data int64
    wg   sync.WaitGroup
    rwlock sync.RWMutex // 1. 定义一个读写锁
)

// 写操作:耗时较长
func write() {
    rwlock.Lock() // 2. 加写锁
    defer rwlock.Unlock() // 3. 释放写锁
    
    data++
    fmt.Printf("执行写操作,Data = %d\n", data)
    time.Sleep(10 * time.Millisecond) // 模拟写操作耗时
}

// 读操作:耗时较短
func read(id int) {
    rwlock.RLock() // 4. 加读锁
    defer rwlock.RUnlock() // 5. 释放读锁
    
    fmt.Printf("Reader %d 读取 Data = %d\n", id, data)
    time.Sleep(time.Millisecond) // 模拟读操作耗时
}

func main() {
    // 启动 10 个写操作(修改数据)
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            write()
            wg.Done()
        }()
    }

    // 启动 100 个读操作(查询数据)
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func(id int) {
            read(id)
            wg.Done()
        }(i)
    }

    wg.Wait()
    fmt.Println("所有操作完成。")
}
// 在执行时,你会发现多个 Reader 可以同时打印结果,
// 而 Mutex 则会强制所有操作排队,RWMutex 显著提高了读并发性。

三、零值可用性

sync.Mutexsync.RWMutex,可以只定义(声明),不需要显式地实例化(比如使用 new()& 运算符)就可以直接使用。

在 Go 语言中,sync.Mutexsync.RWMutex 都是结构体。它们遵循 Go 接口和并发原语设计的一个重要原则:零值可用性

当您仅声明一个 sync.Mutexsync.RWMutex 变量时,Go 语言会将其初始化为其类型的零值。对于这两种锁结构体,它们的零值被设计成一个 未锁定的(unlocked) 可用状态。

1. sync.Mutex 示例

go
package main

import "sync"

// 仅声明,使用零值
var lock sync.Mutex 

func main() {
    // 零值(未锁定)可以直接调用 Lock()
    lock.Lock() 
    
    // ... 临界区代码
    
    // 零值可以直接调用 Unlock()
    lock.Unlock() 
}

2. sync.RWMutex 示例

go
package main

import "sync"

// 仅声明,使用零值
var rwLock sync.RWMutex 

func main() {
    // 零值可以直接调用读锁方法
    rwLock.RLock()
    
    // ... 读取操作
    
    rwLock.RUnlock()

    // 零值可以直接调用写锁方法
    rwLock.Lock()
    
    // ... 写入操作
    
    rwLock.Unlock()
}

总结与推荐用法

声明方式示例状态推荐场景
零值声明var lock sync.Mutex零值(未锁定),可用作为全局变量结构体的字段时,最常用且推荐。
显式初始化lock := &sync.Mutex{}零值(未锁定),可用仅在你希望明确创建一个指针时使用,但不必要。

最佳实践: 当将锁作为结构体的字段使用时,只需将其定义为字段类型即可,无需显式初始化,它会自动初始化为零值并可用。

go
type SafeData struct {
    mu    sync.Mutex // 零值可用,不需要写成 mu: sync.Mutex{}
    data  int
}

四、总结与选择

特性sync.Mutex(互斥锁)sync.RWMutex(读写锁)
并发读独占(只能一个 goroutine 读)共享(可以多个 goroutine 同时读)
并发写独占(只能一个 goroutine 写)独占(只能一个 goroutine 写)
使用场景共享资源被频繁修改,或者读写比例接近。读多写少的场景,可以最大化读的并发效率。
性能开销较低(简单)较高(内部实现更复杂)

选择建议:

  1. 首选 Channel:如果可以使用 Channel 实现数据安全交换,优先使用 Channel。
  2. 写多/读写均衡:使用 sync.Mutex
  3. 读多写少:使用 sync.RWMutex 以获得更好的性能。

希望这份关于 Go 语言锁机制的教程能帮助您更好地理解并发安全!