Go 并发编程里的互斥锁与读写锁:怎么用、何时选、要避开什么坑

何川
何川
发布时间:2026-08-25 | 55人浏览过
原创

前言

在 Go 并发编程里,真正容易出错的环节往往不是创建 goroutine,而是多个执行单元同时碰到同一份数据时,程序还能不能保持结果正确。互斥锁、读写锁和等待组正是为这类问题准备的基础工具。下面从“为什么要加锁”“两种常用锁怎么用”“什么时候该选哪一种”几个问题入手,结合代码和场景把这套同步机制讲清楚,帮助你在写并发代码时更快做出判断。

在 Go 并发编程里,真正容易出错的环节往往不是创建 goroutine,而是多个执行单元同时碰到同一份数据时,程序还能不能保持结果正确。互斥锁、读写锁和等待组正是为这类问题准备的基础工具。下面从“为什么要加锁”“两种常用锁怎么用”“什么时候该选哪一种”几个问题入手,结合代码和场景把这套同步机制讲清楚,帮助你在写并发代码时更快做出判断。

为什么并发访问需要同步锁

多个 goroutine 同时访问共享资源时,如果没有同步机制,程序就可能出现数据竞争(Data Race)和状态不一致的问题。这里的共享资源可以是共享变量、内存中的数据结构,也可以是文件或某些全局状态。

一个典型例子是并发更新计数器。看起来每个 goroutine 都只是执行一次自增,但如果这些操作交错发生,没有互斥保护,最终结果就可能小于预期。在实际业务里,这类问题会表现为库存超卖、账户金额异常、统计数字不准等错误。

同步锁的作用,就是把对临界区的访问控制起来,让共享数据在并发环境下仍然保持一致和可预期。

`sync.Mutex`:最基础的互斥保护

它解决的是什么问题

sync.Mutex 是 Go 里最基本的同步锁。它的语义很直接:同一时刻,只允许一个 goroutine 进入被保护的临界区代码。只要某个 goroutine 持有了这把锁,其他 goroutine 就必须等待它释放后才能继续。

互斥锁与读写锁访问方式对比图,用纸张卡片展示独占写入、并发读取和临界区概念。
互斥锁和读写锁到底差在哪用卡片化方式对比 `sync.Mutex` 与 `sync.RWMutex` 的访问模型。

这类锁适合保护更新共享变量、修改结构体状态、写入共享容器等操作,重点是“同一时刻只能有一个写入者或访问者”。

基本写法

var mu sync.Mutex
var counter int

func increment() {
    mu.Lock()         // 加锁
    defer mu.Unlock() // 函数返回时自动解锁
    counter++         // 修改共享变量
}

这段代码的关键点是先 Lock(),完成修改后再 Unlock()。如果函数里存在多个返回路径,使用 defer mu.Unlock() 通常更稳妥,可以降低忘记解锁的风险。

并发计数示例

package main

import (
    "fmt"
    "sync"
)

var (
    counter int
    mutex   sync.Mutex
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mutex.Lock()
            counter++
            mutex.Unlock()
        }()
    }
    wg.Wait()
    fmt.Println("Final Counter:", counter) // 输出: 1000
}

这里启动了 1000 个 goroutine 同时执行自增操作。如果没有 mutex.Lock() 和 mutex.Unlock() 这对保护,counter 的最终值很可能不是 1000。加上互斥锁后,所有自增都会按互斥方式执行,结果才能稳定落在预期值上。

Go 并发锁选型与风险提示图,用便签和箭头梳理何时选 Mutex、何时选 RWMutex,以及死锁和写等待问题。
锁选型和常见坑一图看清这一图不重复讲定义,而是聚焦选型条件和常见风险,适合放在决策章节之后帮助读者建立判断框架。

使用时要注意什么

  1. 务必解锁:使用 defer mu.Unlock() 可以确保锁被释放,即使中间发生 panic,也更不容易遗漏。
  2. 锁定范围尽量小:只把真正需要保护的代码放进临界区,避免长时间持有锁拖慢并发性能。
  3. 避免重复加锁:同一个 goroutine 如果在未解锁前再次对同一把互斥锁加锁,通常会直接把自己卡死,形成死锁。

`sync.RWMutex`:读多写少场景下更合适的锁

它和互斥锁有什么区别

sync.RWMutex 可以理解为“区分读和写的锁”。它允许多个读操作同时进行,但写操作必须独占。也就是说:

  • 多个读者之间可以并发
  • 写操作执行时不能有其他读或写同时发生
  • 一旦有写入需求,就需要等待当前读操作结束后再独占进入

这让它在“读明显多于写”的场景下有性能优势,因为读取不必像 sync.Mutex 那样完全串行化。

基本用法

var rwMu sync.RWMutex
var data map[string]string

// 读操作
func read(key string) string {
    rwMu.RLock()        // 加读锁
    defer rwMu.RUnlock() // 解读锁
    return data[key]
}

// 写操作
func write(key, value string) {
    rwMu.Lock()         // 加写锁
    defer rwMu.Unlock() // 解写锁
    data[key] = value
}

读操作使用 RLock() / RUnlock(),写操作使用 Lock() / Unlock()。这里最重要的判断标准不是“代码看起来有读取”,而是“这次访问是否会改变共享状态”。只要涉及修改,就必须走写锁。

哪些场景更适合读写锁

如果系统里存在大量并发读取,而写操作只是偶尔发生,sync.RWMutex 往往更合适。常见例子包括:

  • 数据库连接池:多个 goroutine 可以同时读取状态或配置,但更新时需要独占访问。
  • 缓存系统:热点数据会被频繁读取,写入通常只在缓存更新时发生。
  • 配置管理:大多数时候是读取配置,只有变更配置时才发生写入。
  • 状态监控:多个监控 goroutine 持续读取服务状态,而主业务逻辑只是偶尔更新它。

不过,读写锁并不天然优于互斥锁。如果读写比例接近,或者写操作并不少,sync.RWMutex 的管理成本未必划算,甚至可能更慢。

`sync.WaitGroup`:和锁经常一起出现的并发配套工具

sync.WaitGroup 严格来说不是锁,它不负责保护共享数据,而是负责等待一组 goroutine 执行完成。因为实际并发代码往往既要控制共享资源访问,又要等待任务结束,所以它经常和各种锁一起出现。

基本用法

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1) // 增加计数
        go func(id int) {
            defer wg.Done() // 完成一个goroutine
            // 执行一些工作
        }(i)
    }
    wg.Wait() // 等待所有goroutine完成
}

它的使用方式很固定:

  1. 启动 goroutine 之前调用 wg.Add(1)
  2. 在 goroutine 内部结束前调用 defer wg.Done()
  3. 在主流程中调用 wg.Wait() 等待全部任务完成

可以把它理解成一个并发任务计数器:加一代表多了一个待完成任务,减一代表完成了一个任务,等到计数归零,主流程再继续往下走。

互斥锁和读写锁该怎么选

选锁的核心,不是哪个“更高级”,而是共享数据的访问模式是什么样。

  1. 读写频率接近,或者写操作较多:优先使用 sync.Mutex。因为 sync.RWMutex 的写锁获取和协调成本更高,读优势体现不出来时,简单的互斥锁通常更直接。
  2. 读远多于写:例如读写比例达到 10:1 以上,可以考虑 sync.RWMutex,让多个读取并发进行,提升吞吐。
  3. 锁竞争很激烈,goroutine 数量很多:除了选锁类型,更要考虑缩小临界区、降低共享状态规模,必要时拆分为更细粒度的锁。

换句话说,选型时至少要先回答两个问题:共享数据是“经常写”还是“主要读”,以及锁是否已经成为程序瓶颈。如果这两个问题都没有证据,先用 sync.Mutex 往往是更稳的起点。

常见问题与性能注意点

  1. 死锁风险:加锁后忘记释放,或者在锁内执行可能长时间阻塞的操作,都可能让程序卡住。
  2. 锁有性能成本:任何锁都会引入开销,因此要尽量减少持锁时间,避免把不相关逻辑塞进临界区。
  3. 读写锁可能出现写等待:当读操作特别频繁且持续时间较长时,写操作可能一直排队等待,表现为写延迟升高。
  4. 简单计数未必需要锁:如果只是做这类简单共享变量更新,可以考虑 sync/atomic 包中的原子操作,通常开销更低。

并发程序里,“能跑”不等于“正确”,而“正确”也不自动等于“高效”。锁的使用既要保证结果可靠,也要控制临界区大小和等待成本。

怎么建立更稳妥的 Go 锁使用习惯

Go 提供的 sync.Mutex 和 sync.RWMutex,已经覆盖了大多数共享资源同步场景。前者适合通用互斥保护,后者适合读多写少的访问模式,而 sync.WaitGroup 则负责把并发任务的结束时机管理起来。

实际开发中,可以先从最简单、最容易验证正确性的方案出发:共享数据先保证有明确的加锁边界,再根据读写比例和性能表现决定是否升级为读写锁。把这几个基础工具用稳,Go 并发程序的正确性和可维护性就会好很多。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表