在 Go 并发编程里,真正容易出错的环节往往不是创建 goroutine,而是多个执行单元同时碰到同一份数据时,程序还能不能保持结果正确。互斥锁、读写锁和等待组正是为这类问题准备的基础工具。下面从“为什么要加锁”“两种常用锁怎么用”“什么时候该选哪一种”几个问题入手,结合代码和场景把这套同步机制讲清楚,帮助你在写并发代码时更快做出判断。
为什么并发访问需要同步锁
多个 goroutine 同时访问共享资源时,如果没有同步机制,程序就可能出现数据竞争(Data Race)和状态不一致的问题。这里的共享资源可以是共享变量、内存中的数据结构,也可以是文件或某些全局状态。
一个典型例子是并发更新计数器。看起来每个 goroutine 都只是执行一次自增,但如果这些操作交错发生,没有互斥保护,最终结果就可能小于预期。在实际业务里,这类问题会表现为库存超卖、账户金额异常、统计数字不准等错误。
同步锁的作用,就是把对临界区的访问控制起来,让共享数据在并发环境下仍然保持一致和可预期。
`sync.Mutex`:最基础的互斥保护
它解决的是什么问题
sync.Mutex 是 Go 里最基本的同步锁。它的语义很直接:同一时刻,只允许一个 goroutine 进入被保护的临界区代码。只要某个 goroutine 持有了这把锁,其他 goroutine 就必须等待它释放后才能继续。

这类锁适合保护更新共享变量、修改结构体状态、写入共享容器等操作,重点是“同一时刻只能有一个写入者或访问者”。
基本写法
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。加上互斥锁后,所有自增都会按互斥方式执行,结果才能稳定落在预期值上。

使用时要注意什么
- 务必解锁:使用
defer mu.Unlock()可以确保锁被释放,即使中间发生 panic,也更不容易遗漏。 - 锁定范围尽量小:只把真正需要保护的代码放进临界区,避免长时间持有锁拖慢并发性能。
- 避免重复加锁:同一个 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完成
}
它的使用方式很固定:
- 启动 goroutine 之前调用
wg.Add(1) - 在 goroutine 内部结束前调用
defer wg.Done() - 在主流程中调用
wg.Wait()等待全部任务完成
可以把它理解成一个并发任务计数器:加一代表多了一个待完成任务,减一代表完成了一个任务,等到计数归零,主流程再继续往下走。
互斥锁和读写锁该怎么选
选锁的核心,不是哪个“更高级”,而是共享数据的访问模式是什么样。
- 读写频率接近,或者写操作较多:优先使用
sync.Mutex。因为sync.RWMutex的写锁获取和协调成本更高,读优势体现不出来时,简单的互斥锁通常更直接。 - 读远多于写:例如读写比例达到 10:1 以上,可以考虑
sync.RWMutex,让多个读取并发进行,提升吞吐。 - 锁竞争很激烈,goroutine 数量很多:除了选锁类型,更要考虑缩小临界区、降低共享状态规模,必要时拆分为更细粒度的锁。
换句话说,选型时至少要先回答两个问题:共享数据是“经常写”还是“主要读”,以及锁是否已经成为程序瓶颈。如果这两个问题都没有证据,先用 sync.Mutex 往往是更稳的起点。
常见问题与性能注意点
- 死锁风险:加锁后忘记释放,或者在锁内执行可能长时间阻塞的操作,都可能让程序卡住。
- 锁有性能成本:任何锁都会引入开销,因此要尽量减少持锁时间,避免把不相关逻辑塞进临界区。
- 读写锁可能出现写等待:当读操作特别频繁且持续时间较长时,写操作可能一直排队等待,表现为写延迟升高。
- 简单计数未必需要锁:如果只是做这类简单共享变量更新,可以考虑
sync/atomic包中的原子操作,通常开销更低。
并发程序里,“能跑”不等于“正确”,而“正确”也不自动等于“高效”。锁的使用既要保证结果可靠,也要控制临界区大小和等待成本。
怎么建立更稳妥的 Go 锁使用习惯
Go 提供的 sync.Mutex 和 sync.RWMutex,已经覆盖了大多数共享资源同步场景。前者适合通用互斥保护,后者适合读多写少的访问模式,而 sync.WaitGroup 则负责把并发任务的结束时机管理起来。
实际开发中,可以先从最简单、最容易验证正确性的方案出发:共享数据先保证有明确的加锁边界,再根据读写比例和性能表现决定是否升级为读写锁。把这几个基础工具用稳,Go 并发程序的正确性和可维护性就会好很多。













