Golang中sync-RWMutex详解

遇见梦中人之前,上天总会安排我们先遇见别的人。等到终于遇见对的那个,便该心存感激

Posted by yishuifengxiao on 2026-12-21

sync.RWMutex 是 Go 标准库提供的读写互斥锁,它允许多个读操作并发进行,但写操作必须独占。理解它的关键在于:读锁是共享的,写锁是排他的。下面从核心方法、典型示例、适用场景和注意事项几个方面展开说明。

sync.RWMutex核心方法与作用

sync.RWMutex 提供了两组锁方法,分别对应读和写:

读锁(共享锁)

  • RLock():获取读锁。如果当前没有写锁被持有,多个 goroutine 可以同时获取读锁,并发执行读操作。
  • RUnlock():释放读锁。必须与 RLock() 配对使用,否则可能导致 panic 或死锁。

写锁(独占锁)

  • Lock():获取写锁。一旦写锁被持有,任何其他读锁或写锁的请求都会被阻塞,直到写锁被释放。
  • Unlock():释放写锁。

此外,Go 还提供了非阻塞的 TryRLock() 和 TryLock()(成功返回 true,失败返回 false),但在常规业务中较少使用。

RWMutex 的零值就是可用的未锁定状态,可以直接声明使用,无需额外初始化。

底层设计特点:RWMutex 采用 Write-preferring(写优先) 策略——一旦有写者在等待,后续新来的读者会被阻塞,以避免写操作被源源不断的读操作“饿死”。

详细示例:读多写少场景

以下示例展示了一个共享配置的读写场景:多个 goroutine 频繁读取配置,偶尔有 goroutine 更新配置。

package main

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

type Config struct {
value string
mu sync.RWMutex
}

// 读操作:使用读锁,多个读可以并发
func (c *Config) Get() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}

// 写操作:使用写锁,独占访问
func (c *Config) Set(newValue string) {
c.mu.Lock()
defer c.mu.Unlock()
c.value = newValue
}

func main() {
config := &Config{value: "初始配置"}
var wg sync.WaitGroup

// 启动 5 个并发读者
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("读者%d 读到: %s\n", id, config.Get())
time.Sleep(100 * time.Millisecond) // 模拟读耗时
}(i)
}

// 启动 1 个写者(在读操作进行期间请求写锁,会被阻塞直到所有读完成)
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(50 * time.Millisecond) // 让部分读操作先拿到锁
fmt.Println("写者: 开始更新配置")
config.Set("更新后的配置")
fmt.Println("写者: 配置更新完成")
}()

wg.Wait()
fmt.Println("最终配置:", config.Get())
}

关键点:

  • Get() 中的 RLock() 允许多个读者同时进入,5 个读 goroutine 可以并发执行。
  • Set() 中的 Lock() 是独占的:当写者拿到写锁时,任何读者(包括尚未获得锁的)都会被阻塞,直到写者释放锁。
  • 使用 defer 确保锁的释放,这是避免死锁的基本实践。

适用场景

RWMutex 最核心的适用场景是 “读多写少” :

适合的场景

  • 配置中心/热加载配置:读请求极度频繁(每秒数十万次),写请求非常稀少(几分钟甚至几天一次)。
  • 缓存系统:大量查询、少量更新。
  • 路由映射表/黑白名单:绝大多数操作是查询,偶尔更新。
  • 简单的并发安全 map:对于读写比例没有极端偏向、但希望代码简单可靠的业务,RWMutex + 普通 map 是默认首选方案之一。

不适合的场景

  • 写操作频繁:如果写操作和读操作频率相当,甚至写多于读,RWMutex 的读并发优势无法发挥,反而可能因为写优先策略导致读者排队,性能不如直接使用 sync.Mutex。
  • 极端高并发读(数十个核以上) :RWMutex 底层的读计数器每次 RLock() 都需要原子自增,在多核 CPU 上会引发 Cache 抖动(Cache Bouncing) ——多个核心争抢修改同一个缓存行,导致缓存频繁失效,读的并发优势可能被原子操作的开销抵消。在这种场景下,可以考虑无锁方案(如 atomic.Value 存储不可变数据的指针,读时直接加载)。

注意事项与常见陷阱

1. 锁的配对与 defer
RLock/RUnlock 和 Lock/Unlock 必须严格配对。推荐在获取锁后立即 defer 释放,确保即使发生 panic 也能解锁。

2. 不可重入
Go 的 RWMutex 不是可重入锁。同一个 goroutine 在持有写锁的情况下再次请求写锁,会永久阻塞(死锁)。同样,在持有读锁的情况下直接请求写锁,也会因为读锁未释放、写锁需要等待所有读完成而陷入死锁。

3. 读锁中不要执行耗时或可能阻塞的操作
如果某个 goroutine 持有读锁并执行长时间操作,写者会被一直阻塞。反过来,如果写者已经排队,后续读者也会被阻塞(写优先策略),可能造成连锁等待。

4. 避免锁嵌套
不要在持有一把锁的时候去获取另一把不同的锁,除非有严格的全局加锁顺序,否则极易死锁。

5. 零值可用,无需初始化
直接声明 var mu sync.RWMutex 即可使用,不要尝试复制一个已经使用过的 RWMutex(复制会导致锁状态错乱)。

sync.Mutex核心作用与语义

sync.Mutex 用于保护共享资源(变量、map、结构体字段、文件句柄、连接池等)不被多个 goroutine 同时读写,从而避免数据竞争(data race)。

  • 零值可用:var mu sync.Mutex 声明即可用,无需初始化。
  • 不可重入:同一个 goroutine 重复 Lock() 会永久阻塞(死锁)。
  • 不可复制:一旦使用过,就不能复制(复制会破坏内部状态)。含 Mutex 的结构体应通过指针传递。
  • 排他性:Lock() 成功后,其他任何 goroutine 的 Lock() 都会被阻塞,直到 Unlock()。

Go 的 Mutex 内部采用正常模式 + 饥饿模式的混合策略:正常模式下允许新来的 goroutine 抢锁(提升吞吐),若等待超过 1ms 则切换到饥饿模式,保证等待者按 FIFO 顺序获得锁,避免饿死。


核心方法

方法 说明
Lock() 获取锁。若锁已被占用则阻塞,直到可用。
Unlock() 释放锁。必须由持有锁的 goroutine 调用,否则 panic。
TryLock() 非阻塞尝试获取锁,成功返回 true,失败返回 false(Go 1.18+)。

Unlock() 一个未加锁的 Mutex 会直接 panic,这是运行时保护机制。


详细示例

保护计数器(最典型的用法)

多个 goroutine 并发自增同一个计数器,不加锁会产生数据竞争。

package main

import (
"fmt"
"sync"
)

type Counter struct {
mu sync.Mutex
value int
}

func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++ // 临界区:读-改-写,必须原子化
}

func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}

func main() {
c := &Counter{}
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println("最终计数:", c.Get()) // 稳定输出 1000
}

关键点:

  • value++ 不是原子操作,它包含”读、加、写”三步,必须放在锁内。
  • 用 defer 释放锁,保证即使 Inc 内部 panic 也能解锁,避免死锁。
  • 如果去掉 mu,用 go run -race 运行会报告 data race,且最终结果通常小于 1000。

保护 map(Go 内置 map 非并发安全)

package main

import (
"fmt"
"sync"
)

type SafeMap struct {
mu sync.Mutex
m map[string]int
}

func NewSafeMap() *SafeMap {
return &SafeMap{m: make(map[string]int)}
}

func (s *SafeMap) Set(k string, v int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = v
}

func (s *SafeMap) Get(k string) (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.m[k]
return v, ok
}

func (s *SafeMap) Delete(k string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, k)
}

func main() {
sm := NewSafeMap()
var wg sync.WaitGroup

// 并发写
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
sm.Set(fmt.Sprintf("key%d", n), n)
}(i)
}
wg.Wait()

fmt.Println(sm.Get("key42")) // 42 true
}

说明:Go 内置 map 在并发读写时会直接触发 fatal error: concurrent map writes,程序崩溃且无法 recover。因此只要 map 可能被多个 goroutine 访问,就必须加锁(或使用 sync.Map,但 sync.Map 只适合特定模式,见后文对比)。

TryLock 的使用(避免阻塞等待)

package main

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

func main() {
var mu sync.Mutex

mu.Lock()
go func() {
// 锁已被主 goroutine 持有,TryLock 立即返回 false
if mu.TryLock() {
fmt.Println("子 goroutine 拿到锁")
mu.Unlock()
} else {
fmt.Println("子 goroutine 未拿到锁,做其他事")
}
}()

time.Sleep(100 * time.Millisecond)
mu.Unlock()
time.Sleep(100 * time.Millisecond)
}

TryLock 适用于”拿不到锁就走备用逻辑”的场景(如尝试刷新缓存,失败则直接返回旧值),但绝大多数场景不应使用——它容易掩盖并发设计问题,且官方文档明确说明使用 TryLock 的场景极少。常规场景请直接用 Lock()。

锁的粒度——只保护必要的临界区

// ❌ 错误:锁范围过大,把耗时 IO 也包进临界区
func (s *Service) BadUpdate(id int) {
s.mu.Lock()
defer s.mu.Unlock()
data := s.fetchFromDB(id) // 耗时操作,阻塞其他所有 goroutine
s.cache[id] = data
}

// ✅ 正确:只保护共享数据的写入
func (s *Service) GoodUpdate(id int) {
data := s.fetchFromDB(id) // 锁外执行

s.mu.Lock()
s.cache[id] = data
s.mu.Unlock()
}

原则:临界区应尽可能小。锁内不要做 IO、网络调用、长时间计算,否则会把”互斥”放大成”串行瓶颈”。


适用场景

适合 sync.Mutex 的场景

  1. 读写频率相当,或写多读少
    例如:任务队列的入队/出队、计数器的增减、状态机转换。此时读没有并发优势,Mutex 更简单高效。

  2. 临界区很小、操作简单
    例如:map 的单次写入、单个字段赋值。加锁开销远小于业务逻辑,用 Mutex 足够。

  3. 写操作需要维护复杂不变量
    当一次写操作要同时更新多个字段、保持一致性时,独占锁语义最清晰,不容易出现”读到中间状态”的问题。

  4. 希望代码简单、不易出错
    Mutex 只有 Lock/Unlock,没有读写锁的配对复杂度和死锁陷阱(如读锁中请求写锁),维护成本低。

不适合的场景

  • 极端读多写少:读操作本可并发,用 Mutex 会让读也串行,浪费并发能力 → 考虑 RWMutex 或 atomic.Value。
  • 极高频的单值读写:如只读一个指针/整数,用 atomic 系列更轻量,无需锁。

注意事项与常见陷阱

  1. 必须配对,推荐 defer
    Lock() 后立即 defer mu.Unlock(),是避免死锁最可靠的习惯。

  2. 不可重入

    mu.Lock()
    mu.Lock() // 永久阻塞,死锁

    若一个函数在持锁状态下调用另一个也会加同一把锁的函数,就会死锁。设计时要把”加锁”和”内部实现”分层。

  3. 不要复制含 Mutex 的值

    type T struct{ mu sync.Mutex }
    func f(t T) { ... } // ❌ 传值复制了锁
    func f(t *T) { ... } // ✅ 传指针

    可以用 go vet 检测此类问题。

  4. Unlock 未加锁的 Mutex 会 panic
    这既是保护也是提醒:锁的配对必须严格。

  5. 锁的嵌套要固定顺序
    若必须持有多把锁,所有代码路径都按同一全局顺序加锁,否则 ABBA 死锁。

  6. 用 -race 验证

    go test -race ./...
    go run -race main.go

    数据竞争是”可能不出现”的 bug,必须靠工具检测。


sync.Mutex 与 sync.RWMutex 对比

核心差异

维度 sync.Mutex sync.RWMutex
锁语义 完全排他,读读、读写、写写全互斥 读读共享,读写、写写互斥
方法 Lock / Unlock / TryLock Lock/Unlock(写),RLock/RUnlock(读),TryLock/TryRLock
并发读 ❌ 不允许 ✅ 允许多个读者同时进入
实现复杂度 低(一个状态字 + 信号量) 高(读计数器 + 写信号量 + 写优先逻辑)
单次加锁开销 较小 略大(读锁需原子增减计数器)
写优先 不涉及 是,写者排队后新读者会被阻塞,防写饿死
误用风险 较低(只需配对) 较高(读锁中请求写锁会死锁;读写方法混用易错)
零值可用 ✅ ✅

性能特征的关键区别

RWMutex 的优势只在”读远多于写且读临界区有一定耗时”时才成立。原因:

  • 每次 RLock 都要对读计数器做原子操作。多核高并发读时,多个 CPU 核心争抢同一缓存行,产生 Cache Bouncing,读并发的收益可能被原子开销抵消甚至反超。
  • 写优先策略下,一旦有写者排队,后续读者也会被阻塞,读吞吐会骤降。
  • 如果临界区极短(如只读一个字段),Mutex 的简单快速路径往往比 RWMutex 的原子计数更快。

经验法则:临界区里有实际工作(如遍历、计算、序列化)且读:写 ≥ 10:1 时,RWMutex 才明显划算;否则优先 Mutex 或原子操作。

代码层面对比

同一份”配置读写”需求,两种写法:

// ---- 用 Mutex:读写都串行 ----
type ConfigM struct {
mu sync.Mutex
v string
}
func (c *ConfigM) Get() string { c.mu.Lock(); defer c.mu.Unlock(); return c.v }
func (c *ConfigM) Set(s string) { c.mu.Lock(); defer c.mu.Unlock(); c.v = s }

// ---- 用 RWMutex:读可并发 ----
type ConfigRWM struct {
mu sync.RWMutex
v string
}
func (c *ConfigRWM) Get() string { c.mu.RLock(); defer c.mu.RUnlock(); return c.v }
func (c *ConfigRWM) Set(s string) { c.mu.Lock(); defer c.mu.Unlock(); c.v = s }

两者 API 形态相同,区别只在读路径用的是 RLock/RUnlock。但注意:RWMutex 的读锁和写锁必须严格配对使用对应的方法,RLock 配 RUnlock,Lock 配 Unlock,混用会 panic 或死锁。

选型决策表

场景 推荐
读写频率相当 / 写多读少 sync.Mutex
读多写少,且读临界区有实际工作量 sync.RWMutex
单值(指针/int/bool)高频读写 atomic / atomic.Value(无锁)
map 读多写少、键集合相对稳定 sync.Map(注意:仅适合”读多写少、写后少改”模式)
需要简单可靠、团队维护成本低 sync.Mutex 默认首选
读锁内需要请求写锁 ❌ 禁止(必然死锁),重构设计

一个容易踩的坑:RWMutex 不等于”读一定并发”

var mu sync.RWMutex
// 写者持续排队时
go func() { for { mu.Lock(); work(); mu.Unlock() } }()
// 读者的 RLock 会被写优先策略阻塞,实际几乎不并发

RWMutex 的读并发是”在没有写者或写者未排队时”才成立。若写压力不小,它的表现会退化甚至不如 Mutex。这也是官方建议”不要盲目用 RWMutex,先 benchmark”的原因。


总结

  • sync.Mutex 是最通用、最不易误用的互斥原语,默认首选。规则只有一条:Lock 后 defer Unlock,临界区尽量小,不复制、不重入。
  • sync.RWMutex 是针对”读多写少”的优化,收益依赖读写比例和临界区耗时,不是 Mutex 的自动升级版。用之前先问:读真的需要并发吗?临界区够长吗?写压力大吗?
  • 对于单值高频读写,优先考虑 atomic;对于特定模式的 map,评估 sync.Map;其余情况,Mutex 或 RWMutex 二选一,并用 -race 和 benchmark 验证。
  • 无论选哪个,数据竞争用 go test -race / go run -race 检测,这是 Go 并发编程的必备习惯。

为什么他们使用mu sync.Mutex定义后就可以使用,不需要使用new或make来创建

因为 sync.Mutex 的”零值”恰好就是”未锁定”这个有效初始状态——这是 Go 语言”零值可用”(zero-value usable)设计原则的体现,而 make 和 new 都解决的是另外的问题,与 Mutex 无关。

先厘清 var 声明做了什么

var mu sync.Mutex

在 Go 中,任何变量声明后,Go 运行时都会把它的内存清零(zeroing)。也就是说:

var mu sync.Mutex
// 等价于把 mu 所在内存全部写成 0

这不是”没初始化”,而是”初始化为零值”。不同类型的零值不同:

类型 零值
int / int32 / uint32 0
bool false
string ""
指针 / interface nil
slice / map / channel nil
结构体 每个字段取其类型的零值

sync.Mutex 是一个结构体,它的源码定义(简化)是:

type Mutex struct {
state int32 // 锁状态位
sema uint32 // 信号量
}

所以 var mu sync.Mutex 之后,mu.state == 0、mu.sema == 0。


为什么”全零”恰好是 Mutex 的有效初始状态

Mutex.state 是一个按位使用的 int32:

bit 0: mutexLocked    // 是否已加锁
bit 1: mutexWoken // 是否有被唤醒的等待者
bit 2: mutexStarving // 是否进入饥饿模式
bit 3+: 等待者数量

当 state == 0 时,意味着:

  • 未加锁(bit 0 = 0)→ 任何 goroutine 调 Lock() 都能成功
  • 无唤醒者(bit 1 = 0)
  • 非饥饿模式(bit 2 = 0)
  • 无等待者(计数位 = 0)

sema == 0 对于运行时信号量也是合法的初始值(表示没有可用的许可,需要等待时才会去排队)。

结论:Mutex 的设计者有意让”内存全零”就等于”一把干净的、可用的、未被持有的锁”。 这不是巧合,而是 Go 标准库的通用设计约束——标准库中几乎所有的同步原语都遵循”零值可用”。

对照一下 sync 包里其他类型:

类型 零值可用? 说明
sync.Mutex ✅ 零值 = 未锁定
sync.RWMutex ✅ 零值 = 无读者无写者
sync.WaitGroup ✅ 零值 = 计数 0
sync.Once ✅ 零值 = 未执行
sync.Pool ✅ 零值 = 空池
sync.Map ✅ 零值 = 空 map
sync.Cond ❌ 必须 sync.NewCond(&mu),因为它要绑定一个 Locker,零值里没有

sync.Cond 是明显的反例——它的零值不包含”关联的锁”,所以无法工作,必须通过构造函数 NewCond 初始化。而 Mutex 没有这种外部依赖,零值天然完整。


make 和 new 到底解决什么问题?为什么这里用不上

make 只能用于三种类型:slice、map、channel

make 是类型专用的初始化函数,只接受这三种引用类型:

make([]int, 0, 10)       // slice
make(map[string]int) // map
make(chan int, 5) // channel

为什么它们需要 make 而 Mutex 不需要? 关键在于”零值能否直接使用”:

// slice 零值 nil:读可以,append 可以(会自动分配),但直接索引赋值会 panic
var s []int
s = append(s, 1) // ✅
// s[0] = 1 // ❌ panic: index out of range

// map 零值 nil:读可以,写入会 panic
var m map[string]int
_ = m["k"] // ✅ 返回零值
// m["k"] = 1 // ❌ panic: assignment to entry in nil map
m = make(map[string]int) // 必须 make 才能写
m["k"] = 1 // ✅

// channel 零值 nil:发送/接收会永久阻塞
var ch chan int
// ch <- 1 // ❌ 永久阻塞
// <-ch // ❌ 永久阻塞
ch = make(chan int) // 必须 make

所以 make 的存在原因是:这三种类型的零值虽然合法,但不能完整承担”写入/发送”职责,需要一次真正的”分配 + 初始化内部结构”(map 要建 hash 表,channel 要建环形缓冲和锁,slice 要分配底层数组)。

而 Mutex 的零值就完整可用,自然不需要 make。

new(T) 只是”分配零值内存并返回指针”

p := new(sync.Mutex)   // p 是 *sync.Mutex,指向一块清零的内存

new(sync.Mutex) 和 &sync.Mutex{} 完全等价:

p1 := new(sync.Mutex)
p2 := &sync.Mutex{}
// *p1 和 *p2 都是可用的 Mutex

但问题在于:我们用 Mutex 通常不需要显式地”分配指针”,因为 Go 有两点语法机制:

(1)方法接收者是指针,但可寻址变量会自动取地址

Lock() 和 Unlock() 的方法签名是:

func (m *Mutex) Lock()
func (m *Mutex) Unlock()

当你写:

var mu sync.Mutex
mu.Lock()

Go 会自动把它翻译成 (&mu).Lock()——因为 mu 是一个可寻址的变量,编译器自动取地址。所以即使 mu 是值类型变量,也能直接调用指针方法。

(2)结构体字段就是”就地分配”

type Counter struct {
mu sync.Mutex // 字段本身就是内嵌的 Mutex 值
n int
}
c := &Counter{} // mu 字段已经是零值可用状态
c.mu.Lock() // 自动取 &c.mu

Counter{} 的 mu 字段在结构体分配时就被清零了,不需要单独 new。

所以,new 对 Mutex 来说技术上可行,但实践中几乎用不上——它只是多绕了一层指针,没有任何好处。你正常写 var mu sync.Mutex 或把它作为结构体字段,是最自然、最符合 Go 习惯的做法。


完整示例:三种写法其实等价

package main

import (
"fmt"
"sync"
)

// 写法 1:直接声明值(最常用、最推荐)
func demo1() {
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
fmt.Println("demo1: 值声明")
}

// 写法 2:结构体字段(实际业务最常见的形态)
type Counter struct {
mu sync.Mutex
n int
}

func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}

func demo2() {
c := &Counter{} // 零值即完整可用
c.Inc()
fmt.Println("demo2: 结构体字段,n =", c.n)
}

// 写法 3:用 new 或 &T{} 得到指针(少见,但可行)
func demo3() {
mu := new(sync.Mutex) // 等价于 &sync.Mutex{}
mu.Lock()
defer mu.Unlock()
fmt.Println("demo3: new 出来的指针")
}

func main() {
demo1()
demo2()
demo3()
}

三种都能正常工作,但写法 1 和写法 2 是社区标准做法,写法 3 基本只在某种动态分配需求(如放进 map、指针容器)时才会出现。


为什么 Go 要这样设计?——“零值可用”哲学

这背后是 Go 的一条重要设计格言(Go Proverbs 之一):

“Make the zero value useful.”(让零值有用。)

以及另一条相关的:

“Don’t communicate by sharing memory, share memory by communicating.”(与本题关系不大,但同属 Go 哲学)

Go 没有构造函数、没有 class、没有 new 出来的”构造器”概念(不像 C++ 的 constructor、Java 的 new ClassName() 会调用构造逻辑)。在这种语言里,”零值可用”是让结构体开箱即用的唯一出路。如果 sync.Mutex 零值不可用,每个使用它的地方就得写 mu := sync.NewMutex(),代码会繁琐很多,也违背 Go 的简洁哲学。

标准库大量遵循这个原则:bytes.Buffer、strings.Builder、sync.WaitGroup、sync.Once、sync.Map、time.Timer(部分)……都是零值可用。make 只留给真正需要分配底层结构的 slice/map/channel。


需要特别注意的”陷阱”(与零值相关)

1. 零值可用 ≠ 随便复制

var mu sync.Mutex
mu.Lock()
mu2 := mu // ❌ 复制了一把已加锁的锁!状态错乱
mu.Unlock() // mu 解锁了,但 mu2 还是"锁定"状态(实际上是无效的复制体)

Mutex 文档明确写:“A Mutex must not be copied after first use.” 复制后内部状态不一致,行为未定义。含 Mutex 的结构体也不应值传递(传指针),go vet 能检测出这个问题。

2. 零值可用 ≠ 不用管配对

零值只是一把”干净的锁”,Lock/Unlock 的配对、不可重入等规则依然生效。

3. 放到指针或堆上不会改变语义

var mu sync.Mutex 可能在栈上,也可能因为逃逸分析被分配到堆上——但语义不变,零值依然是”未锁定”。所以”栈 vs 堆”完全不影响这里的讨论。


总结

问题 答案
为什么 var mu sync.Mutex 就能用? 因为 Mutex 的内存全零(state=0, sema=0)正是”未锁定、无等待者”的有效初始状态,这是标准库有意设计成”零值可用”的。
为什么不用 make? make 只用于 slice/map/channel,因为它们的 nil 零值不能完整承担写入/发送职责。Mutex 没有这个问题。
为什么不用 new? new(T) 只是分配零值内存并返回指针,对 Mutex 没额外价值。而且 mu.Lock() 对可寻址变量会自动取地址,值变量就够用;作为结构体字段更是天然内嵌。
是否需要任何初始化? 不需要。零值即完整状态。只有 sync.Cond 这类需要绑定外部 Locker 的才必须用 NewCond。
唯一要小心什么? 首次使用后禁止复制;Lock/Unlock 严格配对;RWMutex 的读写方法各自配对。