Go锁 详解

go 复制代码
- Go 函数并发编程中,锁是一种同步机制,用于协调对共享资源的访问,防止数据竞争
- Go 中提供了多种类型的锁,每种锁都有不同的特性和适用场景

类型

  • 互斥锁(mutex)

    • 基础锁,只能同时允许一个 goroutine 获取资源(悲观锁)
    • 保证了对共享资源的独占访问
    • 适用于对数据进行频繁写操作的场景
  • 读写锁(RWMutex)

    • 更高级的锁,它允许多个goroutine同时读取受保护的数据,但只允许一个goroutine同时写入(悲观锁)
    • 可以提高程序的性能,因为读取操作通常比写入操作要快
    • 适用于对数据进行频繁读操作的场景

互斥锁

  • 底层结构
go 复制代码
// sync 包下的mutex就是互斥锁
type Mutex struct {
 state int32
 sema  uint32
}
- state:表示当前互斥锁的状态,复合型字段
- sema:信号量变量,用来控制等待goroutine的阻塞休眠和唤醒

state的不同位分别表示了不同的状态,使用最小的内存来表示更多的意义

go 复制代码
// 其中低三位由低到高分别表示mutexLocked、mutexWoken 和 mutexStarving
// 剩下的位则用来表示当前共有多少个goroutine在等待锁:
const (
   mutexLocked = 1 << iota // 表示互斥锁的锁定状态
   mutexWoken // 表示从正常模式被从唤醒
   mutexStarving // 当前的互斥锁进入饥饿状态
   mutexWaiterShift = iota // 当前互斥锁上等待者的数量
)
go 复制代码
提供了三个公开方法:
Lock():获得锁,Unlock():释放锁,在Go1.18新提供了TryLock()方法可以非阻塞式的取锁操作
  • 加锁

  • 释放锁

  • 正常模式(默认)

    • 采用公平的先进先出策略
    • 当一个goroutine尝试获取锁时,如果锁处于加锁状态,该goroutine会被放入等待队列中,等待锁的释放。当锁被解锁后,等待队列中的goroutine会按照先后顺序获取锁
    • 当一个协程被唤醒后并不是直接拥有锁,该协程需要和刚刚到达的协程一起竞争锁的所有权
    • 当等待的 goroutine 1ms内没有获取到锁,将会把锁置为饥饿模式
  • 饥饿模式

    • 非公平的模式
    • 互斥锁会直接交给等待队列最前面的goroutine,新的 goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待
    • 当等待队列中的协程获取到锁,它会查看以下俩个条件,有任意一个满足,将会将锁改为普通模式
      1. 自己是否是等待队列中最后一个协程
      2. 自己等待的时间是否小于1ms
  • 自旋

    • 定义:
      • 加锁时,如果发现该锁当前由其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续的探测锁是否被释放
      • 自旋时间很短,但如果在自旋过程中发现锁已被释放,那么协程可以立即获取锁
    • 优势:
      • 为了更加高效,减少损耗,更充分的利用CPU,尽量避免协程切换
      • 当前申请加锁的协程拥有CPU,如果经过短时间的自旋可以获得锁,当前协程可以继续运行,不必进入阻塞状态
    • 条件:
      • 自旋次数要足够小,通常为4,即自旋最多4次
      • CPU核数要大于1,否则自旋没有意义,因为此时不可能有其他协程释放锁
      • 调度机制中的Process数量要大于1,否则自旋没有意义
      • 调度机制中的可运行队列必须为空,否则会延迟协程调度,需要把CPU让给更需要的进程
    • 问题:
      • 自旋有个特性,无视正在排队等待加锁的进程,在自旋过程中,获取到锁便可加锁,类似于插队
      • 极端情况下,很多进程正排队等待加锁,此时有进程刚到,开始自旋加锁,如果成功,该进程便插队成功加锁。如果此时不断有进程自旋加锁,则在排队的进程将长时间无法获取到锁
      • 解决:锁添加饥饿状态,该状态下不允许自旋
  • 结论

    • 一般认为普通模式会有更好的性能,因为即使有等待的协程,新的协程可以连续获取到锁
    • 饥饿模式能够防止等待协程长时间获取不到锁。

读写锁

go 复制代码
读写锁包含了两种锁:读锁、写锁,因此设计中两种锁的权重可能有下列三种场景:
- 读优先:读任务占有锁时,后续的读任务可以立即获得锁;这种设计可以提高并发性能(后来的读任务不需要等待),但如果读任务太多,会造成写任务一直处于等待中,造成写饥饿现象
- 写优先:指如果有写任务在等待锁,会阻塞后来的读任务获取锁。保证了写任务不会被持续的读进程阻塞,但如果写任务过多,又会导致读任务一直被阻塞,造成读任务饿死。
- 读写权重一致:读写锁的优先级一样,即普通的Mutex
Golang的读写实现中,采用了读优先、写优先交替策略:
  - 在读任务执行过程中,对于接收到的写任务、读任务,采取写优先策略,阻塞接收到的读任务,让写任务在读过程结束后优先执行
  - 在写任务执行过程中,对于接收到的写任务、读任务,采取读优先策略,阻塞接收到的写任务,让读任务在写过程结束后优先执行
  - 使用交替机制,确保不会因为读写任何一方任务过多,造成另一方的任务无法执行
  • 底层结构
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个数
  • 获取读锁:

    • 获取读锁时,先将读计数器 readerCount 增1,表示增加一个读任务
    • 当readerCount值为负时,表示前面存在等待处理写任务或有写任务正在处理,此时阻塞新接收到的读任务,等待信号量通知
  • 释放读锁:

    • 释放读锁时,先将读计数器 readerCount 减一,表示完成一个读任务
    • 如果 readerCount 为负,则存在需要优先处理的写任务,进入慢路径
    • 首先检测读计数器的临界区,防止RUnlock调用出错(上锁一次、解锁多次)
    • 因为此时存在写任务,readerWait已被写任务赋值,将该值减一,表示写任务执行前要处理的读任务完成一个
    • 如果readerWait为0,则表示写任务执行之前的所有读任务都已完成,释放写信号量,执行等待处理的写任务
  • 获取写锁:

    • 获取写锁时,先抢占互斥锁;因为当存在多个写任务时,同一时间仅会处理一个
    • 反转readerCount的值为负,同时计算收到写任务时的读任务数量
    • 当读任务数量>0时,表示存在正在处理的读任务,将该值累加给readerWait,表示执行接收到的写任务时需要执行多少任务
    • 当readWait > 0,表示有任务要执行,因为通过信号量将写任务阻塞
  • 释放写锁:

    • 释放写锁时,先将readerCount反转为正值表示写任务执行完成,并计算读任务的数量;在释放写锁期间如果有新到的并发读任务,因为readerCount>=0,可以立即获取读锁执行
    • 释放r次读信号量,将在写任务期间被阻塞的读任务唤醒执行
    • 释放Mutex互斥锁
  • 总结
    • 读写锁提供四种操作:读上锁,读解锁,写上锁,写解锁;加锁规则是读读共享,写写互斥,读写互斥,写读互斥
    • 读写锁中的读锁是一定要存在的,其目的是也是为了规避原子性问题,只有写锁没有读锁的情况下会导致我们读取到中间值
    • Go语言的读写锁在设计上也避免了写锁饥饿
相关推荐
zfj3212 小时前
学技术学英文:代码中的锁:悲观锁和乐观锁
数据库·乐观锁··悲观锁·竞态条件
hkNaruto7 小时前
【P2P】【Go】采用go语言实现udp hole punching 打洞 传输速度测试 ping测试
golang·udp·p2p
入 梦皆星河7 小时前
go中常用的处理json的库
golang
海绵波波1079 小时前
Gin-vue-admin(2):项目初始化
vue.js·golang·gin
每天写点bug9 小时前
【go每日一题】:并发任务调度器
开发语言·后端·golang
一个不秃头的 程序员9 小时前
代码加入SFTP Go ---(小白篇5)
开发语言·后端·golang
基哥的奋斗历程10 小时前
初识Go语言
开发语言·后端·golang
ZVAyIVqt0UFji16 小时前
go-zero负载均衡实现原理
运维·开发语言·后端·golang·负载均衡
唐墨12320 小时前
golang自定义MarshalJSON、UnmarshalJSON 原理和技巧
开发语言·后端·golang
老大白菜21 小时前
FastAPI vs Go 性能对比分析
开发语言·golang·fastapi