深入理解Golang中的锁机制

简介

Go 语言(Golang)提供了丰富的同步原语来管理并发,其中包括 互斥锁(Mutex)、读写锁(RWMutex)和条件变量(Cond) 等。本文将深入探讨它们的底层实现、适用场景,并通过示例代码分析如何高效使用 Go 的锁机制。


1. 为什么需要锁?

在多线程(Goroutine)并发环境中,竞态条件(Race Condition) 可能会导致数据不一致、内存访问冲突等问题。Go 的锁机制提供了一种方式,确保在某一时刻只有一个 Goroutine 能够访问共享资源

典型竞态条件示例

go 复制代码
var counter int

func increment() {
    counter++
}

func main() {
    for i := 0; i < 1000; i++ {
        go increment()
    }
    time.Sleep(1 * time.Second)
    fmt.Println("Counter:", counter) // 结果可能 < 1000
}

counter++ 并非原子操作(实际是 读取 → 修改 → 写入),如果在多个 Goroutine 并发执行时,可能因调度器调度导致计数丢失,这个时候,我们需要把我们的临界区increment方法加上锁,保证同一时间有且只有一个increment方法被调用。


2. Go 中的锁类型

2.1 互斥锁(Mutex)

sync.Mutex 是最基础的锁,提供 Lock()Unlock() 方法,确保一次只有一个 Goroutine 能进入临界区,sync.Mutex 互斥锁使用Lock()进行加锁,使用Unlock()进行解锁。

标准用法
go 复制代码
var (
    counter int
    mu      sync.Mutex
)

func increment() {
    mu.Lock()         // 获取锁
    defer mu.Unlock() // 确保释放锁(即使发生 panic)
    counter++
}

func main() {
    for i := 0; i < 1000; i++ {
        go increment()
    }
    time.Sleep(1 * time.Second)
    fmt.Println("Counter:", counter) // 保证 1000
}
底层实现

互斥锁的数据结构,设计非常巧妙,32位的都赋予了特殊的含义。

  • state表示互斥锁的状态,比如是否被锁定等。
  • sema表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。
go 复制代码
// A Mutex must not be copied after first use.
// Mutex被使用后,不可以将其复制。(意思是不能复制值,可以做成引用复制)
// 复制容易导致非预期的死锁
// https://mozillazg.com/2019/04/notes-about-go-lock-mutex.html#hidcopy
type Mutex struct {
   state int32
   sema  uint32 // 信号量
}

state是32位的整型变量,内部实现时把该变量分成四份,用于记录Mutex的四种状态。

Waiter:前29位数字记录当前阻塞的goroutine数,并以此判断是否需要释放信号量 sema

Starving: 第30位数字表示是否处于饥饿状态

Woken:第31位数字表示是否有协程被唤醒,

Locked:第32位数字表示当前Mutex是否锁定

协程之间抢锁实际上是抢给Locked赋值的权利,能给Locked域置1,就说明抢锁成功。抢不到的话就阻塞等待

Mutex.sema信号量,一旦持有锁的协程解锁,等待的协程会依次被唤醒。想必大家都好奇饥饿状态是什么东西。

正常模式:当一个协程被唤醒后并不是直接拥有锁,该协程需要和新的获取锁的协程一起竞争锁的所有权。新到的协程有个优势,那就是它已经在CPU上运行了,而且新到的协程可能有很多,所以被唤醒的协程极有可能抢占不到锁。在这种情况下,被唤醒的协程会被放置于等待队列的队头。如果等待的协程超过1ms内没有获取到锁,将会把锁置为饥饿模式。

go 复制代码
// sync/mutex.go
func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return // 快速获取锁(无竞争)
    }
    m.lockSlow() // 进入竞争逻辑
}
// sync/mutex.go (Go 1.21+)
func (m *Mutex) lockSlow() {
    // ...
    awoke := false // 是否被唤醒
    for {
        if awoke {
            if m.state&mutexStarving == 0 {
                awoke = false // 非饥饿模式下重新竞争
            }
        }
        // 竞争锁的逻辑...
    }
}

饥饿模式:在饥饿模式下,解锁的协程会将锁的所有权直接交给等待队列中位于队头的协程。正好解锁的那一刻有新的协程到达,新到达的协程也不会尝试自旋获取锁。相反,他们会将自己置于等待队列的队尾。

go 复制代码
// sync/mutex.go
const (
    mutexStarving = 1 << 2 // 饥饿模式标记
)

func (m *Mutex) lockSlow() {
    // ...
    starvationThresholdNs := 1e6 // 1ms(纳秒)
    if !awoke && old&mutexStarving == 0 {
        if runtime.Nanotime()-waitStartTime > starvationThresholdNs {
            m.state |= mutexStarving // 进入饥饿模式
        }
    }
}
// sync/mutex.go
func (m *Mutex) lockSlow() {
    // ...
    if old&mutexStarving != 0 {
        // 饥饿模式下,唤醒的 Goroutine 直接获取锁
        delta := int32(mutexLocked - 1<<mutexWaiterShift)
        if !awoke {
            delta -= mutexWoken
        }
        atomic.AddInt32(&m.state, delta)
        break
    }
}

锁的调度离不开等待队列的管理,Golang中并没有显式的链表或者数组管理锁,而是通过 runtime 的 信号量(semaphore)机制,每调用一次 runtime_Semacquire(),当前 Goroutine 会被 挂起(park) 并放入 runtime.semaRoot 管理的等待队列,runtime_Semrelease() 会唤醒一个 Goroutine 从 semaRoot 队列中取出。

go 复制代码
// sync/mutex.go
func (m *Mutex) lockSlow() {
    // ...
    runtime_Semacquire(&m.sema, queueLifo, 1) // 进入等待队列
    // ...
}

🔹 如果锁竞争激烈,可能会出现 Goroutine 频繁切换,影响性能 ,此时可以考虑优化锁粒度或改用 RWMutex


2.2 读写锁(RWMutex)

sync.RWMutex 优化了"读多写少"的场景,允许多个 Goroutine 并发读,但写操作独占锁

适用场景
  • 读频繁的操作(如缓存)
  • 写操作较少,但需要隔离
  • 低并发读场景下,追求一致性,写比较多的场景
示例
go 复制代码
var (
    data    map[string]string
    rwMutex sync.RWMutex
)

func read(key string) string {
    rwMutex.RLock()       // 读锁
    defer rwMutex.RUnlock()
    return data[key]
}

func write(key, value string) {
    rwMutex.Lock()        // 写锁
    defer rwMutex.Unlock()
    data[key] = value
}

读写锁的数据结构

go 复制代码
type RWMutex struct {
 w           Mutex  // held if there are pending writers
 writerSem   uint32 // semaphore for writers to wait for completing readers
 readerSem   uint32 // semaphore for readers to wait for completing writers
 readerCount int32  // number of pending readers
 readerWait  int32  // number of departing readers
}
  • w:复用互斥锁提供的能力;
  • writerSem:写操作goroutine阻塞等待信号量,当阻塞写操作的读操作goroutine释放读锁时,通过该信号量通知阻塞的写操作的goroutine;
  • readerSem:读操作goroutine阻塞等待信号量,当写操作goroutine释放写锁时,通过该信号量通知阻塞的读操作的goroutine;
  • redaerCount:当前正在执行的读操作goroutine数量;
  • readerWait:当写操作被阻塞时等待的读操作goroutine个数;
底层优化
  • 读锁 :使用原子计数器记录并发读数量,无竞争时可快速返回。
    读锁的对应方法如下:
go 复制代码
func (rw *RWMutex) RLock() {
  // 原子操作readerCount 只要值不是负数就表示获取读锁成功
 if atomic.AddInt32(&rw.readerCount, 1) < 0 {
  // 有一个正在等待的写锁,为了避免饥饿后面进来的读锁进行阻塞等待
  runtime_SemacquireMutex(&rw.readerSem, false, 0)
 }
}

非阻塞加读锁

Go语言在1.18中引入了非阻塞加读锁的方法:

go 复制代码
func (rw *RWMutex) TryRLock() bool {
 for {
    // 读取readerCount值能知道当前是否有写锁在阻塞等待,如果值为负数,那么后面的读锁就会被阻塞住
  c := atomic.LoadInt32(&rw.readerCount)
  if c < 0 {
   if race.Enabled {
    race.Enable()
   }
   return false
  }
    // 尝试获取读锁,for循环不断尝试
  if atomic.CompareAndSwapInt32(&rw.readerCount, c, c+1) {
   if race.Enabled {
    race.Enable()
    race.Acquire(unsafe.Pointer(&rw.readerSem))
   }
   return true
  }
 }
}

使用原子操作更新readerCount,将readercount值加1,只要原子操作后值不为负数就表示加读锁成功,如果值为负数表示已经有写锁获取互斥锁成功,写锁goroutine正在等待或运行,所以为了避免饥饿后面进来的读锁要进行阻塞等待,调用runtime_SemacquireMutex阻塞等待

释放读锁代码主要分为两部分,第一部分:

go 复制代码
func (rw *RWMutex) RUnlock() {
  // 将readerCount的值减1,如果值等于等于0直接退出即可;否则进入rUnlockSlow处理
 if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
  // Outlined slow-path to allow the fast-path to be inlined
  rw.rUnlockSlow(r)
 }
}

我们都知道readerCount的值代表当前正在执行的读操作goroutine数量,执行递减操作后的值大于等于0表示当前没有异常场景或写锁阻塞等待,所以直接退出即可,否则需要处理这两个逻辑:

rUnlockSlow逻辑如下:

go 复制代码
func (rw *RWMutex) rUnlockSlow(r int32) {
  // r+1等于0表示没有加读锁就释放读锁,异常场景要抛出异常
  // r+1 == -rwmutexMaxReaders 也表示没有加读锁就是释放读锁
  // 因为写锁加锁成功后会将readerCout的值减去rwmutexMaxReaders
 if r+1 == 0 || r+1 == -rwmutexMaxReaders {
  race.Enable()
  throw("sync: RUnlock of unlocked RWMutex")
 }
 // 如果有写锁正在等待读锁时会更新readerWait的值,所以一步递减rw.readerWait值
  // 如果readerWait在原子操作后的值等于0了说明当前阻塞写锁的读锁都已经释放了,需要唤醒等待的写锁
 if atomic.AddInt32(&rw.readerWait, -1) == 0 {
  // The last reader unblocks the writer.
  runtime_Semrelease(&rw.writerSem, false, 1)
 }
}

解读一下这段代码:

r+1等于0说明当前goroutine没有加读锁就进行释放读锁操作,属于非法操作

r+1 == -rwmutexMaxReaders 说明写锁加锁成功了会将readerCount的减去rwmutexMaxReaders变成负数,如果此前没有加读锁,那么直接释放读锁就会造成这个等式成立,也属于没有加读锁就进行释放读锁操作,属于非法操作;

readerWait代表写操作被阻塞时读操作的goroutine数量,如果有写锁正在等待时就会更新readerWait的值,读锁释放锁时需要readerWait进行递减,如果递减后等于0说明当前阻塞写锁的读锁都已经释放了,需要唤醒等待的写锁。(看下文写锁的代码就呼应上了)

  • 写锁:必须等待所有读操作完成才能获取。
go 复制代码
const rwmutexMaxReaders = 1 << 30
func (rw *RWMutex) Lock() {
 // First, resolve competition with other writers.
  // 写锁也就是互斥锁,复用互斥锁的能力来解决与其他写锁的竞争
  // 如果写锁已经被获取了,其他goroutine在获取写锁时会进入自旋或者休眠
 rw.w.Lock()
 // 将readerCount设置为负值,告诉读锁现在有一个正在等待运行的写锁(获取互斥锁成功)
 r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
 // 获取互斥锁成功并不代表goroutine获取写锁成功,我们默认最大有2^30的读操作数目,减去这个最大数目
  // 后仍然不为0则表示前面还有读锁,需要等待读锁释放并更新写操作被阻塞时等待的读操作goroutine个数;
 if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
  runtime_SemacquireMutex(&rw.writerSem, false, 0)
 }
}

代码量不是很大,但是理解起来还有一点复杂,我尝试用文字来解析一下,主要分为两部分:

获取互斥锁,写锁也就是互斥锁,这里我们复用互斥锁mutex的加锁能力,当互斥锁加锁成功后,其他写锁goroutine再次尝试获取锁时就会进入自旋休眠等待;

判断获取写锁是否成功,这里有一个变量rwmutexMaxReaders = 1 << 30表示最大支持230个并发读,互斥锁加锁成功后,假设230个读操作都已经释放了读锁,通过原子操作将readerCount设置为负数在加上2^30,如果此时r仍然不为0说面还有读操作正在进行,则写锁需要等待,同时通过原子操作更新readerWait字段,也就是更新写操作被阻塞时等待的读操作goroutine个数;readerWait在上文的读锁释放锁时会进行判断,进行递减,当前readerWait递减到0时就会唤醒写锁。

go 复制代码
func (rw *RWMutex) TryLock() bool {
  // 先判断获取互斥锁是否成功,没有成功则直接返回false
 if !rw.w.TryLock() {
  if race.Enabled {
   race.Enable()
  }
  return false
 }
  // 互斥锁获取成功了,接下来就判断是否是否有读锁正在阻塞该写锁,如果没有直接更新readerCount为
  // 负数获取写锁成功;
 if !atomic.CompareAndSwapInt32(&rw.readerCount, 0, -rwmutexMaxReaders) {
  rw.w.Unlock()
  if race.Enabled {
   race.Enable()
  }
  return false
 }
 return true
}

释放写锁

go 复制代码
func (rw *RWMutex) Unlock() {
 // Announce to readers there is no active writer.
  // 将readerCount的恢复为正数,也就是解除对读锁的互斥
 r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
 if r >= rwmutexMaxReaders {
  race.Enable()
  throw("sync: Unlock of unlocked RWMutex")
 }
 // 如果后面还有读操作的goroutine则需要唤醒他们
 for i := 0; i < int(r); i++ {
  runtime_Semrelease(&rw.readerSem, false, 0)
 }
 // 释放互斥锁,写操作的goroutine和读操作的goroutine同时竞争
 rw.w.Unlock()
}

释放写锁的逻辑比较简单,释放写锁会将会面的读操作和写操作的goroutine都唤醒,然后他们在进行竞争;

🔹 如果在高并发读时出现频繁写操作,可能导致读 Goroutine 饿死 ,因此 RWMutex 适用于读远多于写的场景。


3. 小结

  • 优先使用 sync.Mutex 保护临界区,defer 确保解锁。
  • RWMutex 适用于"多读少写"场景,提升读并发性能。
  • 复杂条件同步可用 Cond,但需小心死锁。
  • 优化锁竞争(减小锁粒度、原子操作、避免锁嵌套)。

锁的正确使用能避免竞态条件,但过度使用可能导致性能下降,建议在必要时用 go test -race 检测数据竞争问题。

附录:用锁的时候需要注意些什么

  1. 不要复制已使用的Mutex:复制后的Mutex与原Mutex的状态不同步,可能导致程序行为异常

  2. 加锁后确保解锁 :推荐使用defer语句确保解锁,避免忘记解锁导致死锁

  3. 合理使用读写锁(RWMutex):读多写少的场景下,RWMutex能提供更好的性能

  4. 避免锁嵌套:容易导致死锁,如果必须嵌套要确保加锁顺序一致

  5. 锁不应保护过多内容:锁定范围过大可能导致性能问题

性能优化建议

  • 优先考虑channel作为同步机制
  • 减少锁的持有时间
  • 尽量使用细粒度锁
  • 使用sync.Map替代传统Map+锁的模式
相关推荐
草莓熊Lotso1 小时前
Python 流程控制完全指南:条件语句 + 循环语句 + 实战案例(零基础入门)
android·开发语言·人工智能·经验分享·笔记·后端·python
码luffyliu1 小时前
Go 中的深浅拷贝:从城市缓存场景讲透指针与内存操作
后端·go·指针·浅拷贝·深拷贝
雾岛听蓝1 小时前
C++ 模板初阶
开发语言·c++
小杰帅气2 小时前
智能指针喵喵喵
开发语言·c++·算法
老华带你飞2 小时前
个人网盘管理|基于springboot + vue个人网盘管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端
代码or搬砖2 小时前
悲观锁讲解
开发语言·数据库
hudawei9962 小时前
对比kotlin和flutter中的异步编程
开发语言·flutter·kotlin·异步·
南棱笑笑生2 小时前
20251219给飞凌OK3588-C开发板适配Rockchip原厂的Buildroot【linux-5.10】后解决启动不了报atf-2的问题
linux·c语言·开发语言·rockchip
deephub2 小时前
ONNX Runtime Python 推理性能优化:8 个低延迟工程实践
开发语言·人工智能·python·神经网络·性能优化·onnx