wx

关注公众号

「Go语言面试题」10 - Go中的互斥锁(Mutex)和读写锁(RWMutex)有什么区别?


在并发编程中,管理对共享资源的访问是核心挑战。Go语言通过sync包提供了强大的同步原语,其中最常用的就是互斥锁(Mutex)和读写锁(RWMutex)。很多开发者对二者的区别和适用场景感到困惑。今天,我们就来彻底讲清楚。

核心概念

1. 互斥锁 (Mutex - Mutual Exclusion Lock)

  • 行为:就像一间只有一个钥匙的“单人卫生间”。每次只允许一个goroutine进入(加锁),其他所有goroutine,无论是想读还是想写,都必须在门口排队等待(阻塞),直到当前的goroutine用完出来(解锁)。
  • 特点:完全排他,保证同一时刻只有一个goroutine能访问共享资源,保证了最强的数据一致性,但可能牺牲一些性能。

2. 读写锁 (RWMutex - Read-Write Mutex)

  • 行为:更像一间“公共图书馆”。
    • 读锁 (RLock):允许很多读者(goroutine)同时进入阅读(读取数据)。只要没有人在写,读者们可以共享资源。
    • 写锁 (Lock):当有管理者要整理书架(写入数据)时,会获得写锁。此时,所有新的读者会被拦在门外,并且会等待所有现有的读者离开。然后,写锁保证图书馆里只有他一个人工作,防止任何人(读者或其他写者)进入,直到工作完成。
  • 特点:区分了读和写操作,在高读低写的场景下能极大提升性能。

区别对比表

特性 互斥锁 (sync.Mutex) 读写锁 (sync.RWMutex)
访问模式 完全互斥,不区分读写 区分读和写访问
读操作 串行,读的时候也不能并发读 并行,多个goroutine可以同时持有读锁
写操作 串行 串行,且写锁是排他的
性能倾向 读写操作比例相近,或写操作频繁时 读多写少时性能优势巨大
饥饿风险 公平锁(新版本Go实现了饥饿模式) 写操作可能被大量读操作“饿死”(需要等待所有读锁释放)

代码示例

让我们通过一个简单的“银行账户”例子来感受两者的区别。

1. 使用互斥锁 (Mutex)

package main

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

type Account struct {
	balance int
	mutex   sync.Mutex // 保护 balance 的互斥锁
}

func (a *Account) ReadBalance() int {
	a.mutex.Lock()         // 加锁(即使是读,也要抢锁)
	defer a.mutex.Unlock() // 确保函数返回时解锁
	return a.balance
}

func (a *Account) Deposit(amount int) {
	a.mutex.Lock()
	defer a.mutex.Unlock()
	a.balance += amount
}

func main() {
	account := &Account{}

    // 启动100个goroutine并发读余额
	for i := 0; i < 100; i++ {
		go func() {
			_ = account.ReadBalance()
            // time.Sleep 只是为了增大并发冲突的可能性,便于观察现象
			time.Sleep(time.Millisecond)
		}()
	}

    // 启动10个goroutine并发写余额
	for i := 0; i < 10; i++ {
		go func() {
			account.Deposit(100)
			time.Sleep(time.Millisecond)
		}()
	}

	time.Sleep(2 * time.Second)
	fmt.Println("Final balance:", account.ReadBalance())
}

在这个例子中,所有的读操作和写操作都是串行的,因为它们都在竞争同一把Mutex锁。

2. 使用读写锁 (RWMutex)

package main

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

type Account struct {
	balance int
	rwMutex sync.RWMutex // 保护 balance 的读写锁
}

func (a *Account) ReadBalance() int {
	a.rwMutex.RLock()   // 加读锁(允许多个读锁同时存在)
	defer a.rwMutex.RUnlock() // 解读锁
	return a.balance
}

func (a *Account) Deposit(amount int) {
	a.rwMutex.Lock()    // 加写锁(排他锁)
	defer a.rwMutex.Unlock()
	a.balance += amount
}

func main() {
	account := &Account{}

    // 启动1000个goroutine并发读余额
    // 注意:这里数量可以开得更大,因为读锁是并发的,开销很小
	for i := 0; i < 1000; i++ {
		go func() {
			_ = account.ReadBalance()
		}()
	}

    // 启动10个goroutine并发写余额
	for i := 0; i < 10; i++ {
		go func() {
			account.Deposit(100)
		}()
	}

	time.Sleep(time.Second) // 等待所有goroutine完成
	fmt.Println("Final balance:", account.ReadBalance()) // 输出 Final balance: 1000
}

在这个例子中,1000个读goroutine可以同时执行ReadBalance函数,而不会被彼此阻塞,它们只会被Deposit操作中的写锁阻塞。这在高并发读取的场景下,性能提升是数量级的。


如何选择?记住这几点!

  1. 默认选择 Mutex:如果你不确定该用哪个,或者读写操作比例差不多,优先使用Mutex。它的逻辑更简单,不易出错。
  2. 读多写少用 RWMutex:当你明确知道代码中存在大量的读操作(例如:缓存系统、配置读取、热点数据查询),而写操作相对很少时,RWMutex是你的不二之选。
  3. 写多用 Mutex:如果你的场景是写操作非常频繁,甚至比读操作还多,那么RWMutex的复杂性和额外的开销(维护读者计数等)可能反而使其性能不如简单的Mutex。因为写锁的排他性会导致读锁也无法获取,优势丧失殆尽。
  4. 关注临界区耗时:临界区(被锁保护的代码块)本身的执行时间也很关键。如果临界区非常短,那么所有锁的竞争都不会太激烈,MutexRWMutex的性能差异可能不明显。如果临界区操作耗时(例如一次复杂的查询),RWMutex的优势就会凸显。

注意事项

  • 不要复制锁:锁一旦被使用,就不要再复制它。应该通过指针传递包含锁的结构体。
  • 避免嵌套死锁:确保加锁和解锁成对出现,并小心在持有锁的情况下再去申请另一个锁,这很容易导致死锁。
  • 使用 defer:尽可能使用defer来解锁,特别是在有多个返回分支的函数中,这能有效避免忘记解锁而导致死锁。

总结

Mutex是“全能型”的卫士,简单粗暴,保证绝对安全;RWMutex是“智慧型”的管家,懂得灵活变通,在合适的场景(读多写少)下能极大地提升效率。

选择哪一个,取决于你的具体业务场景和对性能的要求。希望本文能帮助你在下一次面对并发挑战时,做出最明智的选择。

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo