Go语言,Mutex 实现原理

简介: Mutex 使用非常方便,但它的内部实现却复杂的很,今天我们来介绍下它的内部实现原理。

Mutex 使用非常方便,但它的内部实现却复杂的很,今天我们来介绍下它的内部实现原理。

Mutex 数据结构

在源码包 src/sync/mutex.go:Mutex 定义了互斥锁的数据结构:

// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
//
// A Mutex must not be copied after first use.
type Mutex struct {
 state int32
 sema  uint32
}
  • state : 表示互斥锁的状态,例如是否被锁定
  • sema : 表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。

state 是32位的整型变量,内部实现是把它分成了四份,用来记录 Mutex 的四种状态。Mutex 的内部布局:

image.png

  • Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。
  • Starving:表示该 Mutex 是否处理饥饿状态, 0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms。
  • Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
  • Locked: 表示该 Mutex 是否已被锁定,0:没有锁定 1:已被锁定。

协程之间抢锁实际上是抢给 Locked 赋值的权利,能够给 Locked 域置 1,就说明抢锁成功。抢不到的话就阻塞等待 Mutex.sema 信号量,一旦持有锁的协程解锁,等待的协程会依次被唤醒。

Mutex方法

  • Lock() : 加锁方法
  • Unlock(): 解锁方法
  1. 互斥锁,使同一时刻只能有一个协程执行某段程序,其他协程等待该协程执行完再依次执行。
  2. 互斥锁只有两个方法 Lock (加锁)和 Unlock(解锁),当一个协程对资源上锁后,只有等该协程解锁,其他协程才能再次上锁。
  3. Lock 和 Unlock 是成对出现,为了防止上锁后忘记释放锁,我们可以使用 defer 语句来释放锁。

加/解锁过程

简单加锁

假设当前只有一个协程在加锁,且没有其他协程的干扰,其过程如下图:

image.png

加锁会查看 Locked 标志位是否为 0,若为 0 则改为 1,表示加锁成功。

加锁被阻塞

假设加锁时,锁已经被其他协程占用了,其过程如下图:

image.png

当 B 协程对一个已被占用的锁再次加锁时,Waiter 计数器增加了1,此时 B 协程将被阻塞,直到 Locked 值变为0后才会被唤醒。

简单解锁

假设解锁时,没有其他协程阻塞,其过程如下图:

image.png

由于没有其他协程阻塞等待加锁,所以此时解锁时只需要把 Locked 位改为 0 即可,不需要释放信号量。

解锁并唤醒协程

假设解锁时,有1个或多个协程阻塞,其过程如下图:

image.png

A协程解锁分为两个步,一是把 Locked 位置0,二是查看到 Waiter>0,所以释放一个信号量,唤醒一个阻塞的协程,被唤醒的B协程把 Locked 位改为 1,于是 B协程获得锁。

自旋过程

  • 加锁时,如果当前 Locked 位为 1,则说明该锁当前是由其他协程持有,尝试加锁的协程并不会马上转入阻塞,而是会持续的探测 Locked 位是否变为 0,这个过程即为自旋过程。
  • 自旋的时间很短,但如果在自旋过程中发现锁已被释放,那么协程可以立即获取锁。此时即便有协程被唤醒也无法获取锁,只能再次阻塞。
  • 自旋的好处是,当加锁失败时不必立即转入阻塞,有一定机会获取到锁,这样可以避免协程的切换。

自旋的条件

加锁时程序会自动判断是否可以自旋,但无限制的自旋会给 CPU 带来巨大的压力,所以判断是否可以自旋就很重要了。

自旋必须满足以下所有条件:

  • 自旋的次数要足够小,通常为4,即自旋最多为4次
  • CPU 核数要大于1,否则自旋是没有意义的,因为此时不可能有其他协程释放锁
  • 协程调度机制中的 Process 数量要大于 1,比如使用 GOMAXPROCS() 将处理器设置为 1 就不能启用自旋
  • 协程调度机制中的可运行队列必须为空,否则会延迟协程调度

自旋优势及问题

自旋的优势是更充分的利用CPU,尽量避免协程切换。

如果自旋过程中获得锁,那么之前被阻塞的协程将无法获得锁,如果加锁的协程特别多,每次都通过自旋获得锁,那么之前被阻塞的进程将很难获得锁,从而进入饥饿状态

为了避免协程长时间无法获取锁,自1.8版本以来增加了一个状态,即 Mutex 的 Starving 状态。这个状态下不会自旋,一旦有协程释放锁,那么一定会唤醒一个协程并成功加锁。

Mutex模式

每个 Mutex 都有两个模式,Normal 和 Starving。

  1. Normal

默认情况下,Mutex 的模式为 normal。 在该模式下,协程如果加锁不成功不会立即转入阻塞排队,而是判断是否满足自旋的条件,如果满足则会启动自旋过程,尝试抢锁。

  1. starvation

自旋过程中能抢到锁,一定意味着同一时刻有协程释放了锁,释放锁时如果发现有阻塞等待的协程,还会释放一个信号量来唤醒一个等待协程,被唤醒的协程得到 CPU 后开始运行,此时发现锁已被抢占了,自己只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过 1ms 的话,会将 Mutex 标记为”饥饿”模式,然后 再阻塞。

处于饥饿模式下,不会启动自旋过程,也即一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将会成功获取锁,同时也会把等待计数减 1。

Woken 状态

Woken 状态用于加锁和解锁过程的通信,例如,同一时刻,两个协程一个在加锁,一个在解锁,在加锁的协程可能在自旋过程中,此时把 Woken 标记为 1,用于通知解锁协程不必释放信号量了。

重复解会 panic

Unlock 过程分为将 Locked 改为 0,然后判断 Waiter 的值:

  • 如果值 >0,则释放信号量。
  • 如果多次 Unlock(),那么可能每次都释放一个信号量,这样会唤醒多个协程,多个协程唤醒后会继续在 Lock()的逻辑里抢锁,势必会增加 Lock()实现的复杂度,也会引起不必要的协程切换。
相关文章
|
3天前
|
SQL 安全 Go
【Go语言专栏】Go语言中的安全审计与漏洞修复
【4月更文挑战第30天】本文介绍了Go语言中的安全审计和漏洞修复实践。安全审计包括代码审查、静态分析、运行时分析、渗透测试和专业服务,借助工具如`go vet`、`staticcheck`、`gosec`等。修复漏洞的方法涉及防止SQL注入、XSS攻击、CSRF、不安全反序列化等。遵循最小权限原则、输入验证等最佳实践,结合持续学习,可提升Go应用安全性。参考[Go安全工作组](https://github.com/golang/security)和[OWASP Top 10](https://owasp.org/www-project-top-ten/)深入学习。
|
1天前
|
存储 缓存 安全
Golang深入浅出之-Go语言中的并发安全容器:sync.Map与sync.Pool
Go语言中的`sync.Map`和`sync.Pool`是并发安全的容器。`sync.Map`提供并发安全的键值对存储,适合快速读取和少写入的情况。注意不要直接遍历Map,应使用`Range`方法。`sync.Pool`是对象池,用于缓存可重用对象,减少内存分配。使用时需注意对象生命周期管理和容量控制。在多goroutine环境下,这两个容器能提高性能和稳定性,但需根据场景谨慎使用,避免不当操作导致的问题。
14 4
|
1天前
|
安全 Go 开发者
Golang深入浅出之-Go语言中的CSP模型:深入理解并发哲学
【5月更文挑战第2天】Go语言的并发编程基于CSP模型,强调通过通信共享内存。核心概念是goroutines(轻量级线程)和channels(用于goroutines间安全数据传输)。常见问题包括数据竞争、死锁和goroutine管理。避免策略包括使用同步原语、复用channel和控制并发。示例展示了如何使用channel和`sync.WaitGroup`避免死锁。理解并发原则和正确应用CSP模型是编写高效安全并发程序的关键。
18 4
|
2天前
|
安全 Go 开发者
Golang深入浅出之-Go语言中的CSP模型:深入理解并发哲学
【5月更文挑战第1天】Go语言基于CSP理论,借助goroutines和channels实现独特的并发模型。Goroutine是轻量级线程,通过`go`关键字启动,而channels提供安全的通信机制。文章讨论了数据竞争、死锁和goroutine泄漏等问题及其避免方法,并提供了一个生产者消费者模型的代码示例。理解CSP和妥善处理并发问题对于编写高效、可靠的Go程序至关重要。
10 2
|
2天前
|
设计模式 Go 调度
Golang深入浅出之-Go语言中的并发模式:Pipeline、Worker Pool等
【5月更文挑战第1天】Go语言并发模拟能力强大,Pipeline和Worker Pool是常用设计模式。Pipeline通过多阶段处理实现高效并行,常见问题包括数据竞争和死锁,可借助通道和`select`避免。Worker Pool控制并发数,防止资源消耗,需注意任务分配不均和goroutine泄露,使用缓冲通道和`sync.WaitGroup`解决。理解和实践这些模式是提升Go并发性能的关键。
16 2
|
2天前
|
JSON 监控 安全
Golang深入浅出之-Go语言中的反射(reflect):原理与实战应用
【5月更文挑战第1天】Go语言的反射允许运行时检查和修改结构,主要通过`reflect`包的`Type`和`Value`实现。然而,滥用反射可能导致代码复杂和性能下降。要安全使用,应注意避免过度使用,始终进行类型检查,并尊重封装。反射的应用包括动态接口实现、JSON序列化和元编程。理解反射原理并谨慎使用是关键,应尽量保持代码静态类型。
12 2
|
2天前
|
Go
Golang深入浅出之-Go语言代码质量与规范:遵循Gofmt与Linting
【5月更文挑战第1天】本文讨论了如何使用`gofmt`和Lint工具提升Go代码质量。`gofmt`负责自动格式化代码,保持风格统一,而Lint工具如`golint`、`govet`、`staticcheck`则进行静态分析,检查潜在错误和未使用的变量。通过集成`gofmt`检查到CI/CD流程,避免格式冲突,并使用Lint工具发现并修复问题,如未处理的错误、不规范命名。遵循这些最佳实践,可提高代码可读性、团队协作效率和可维护性。
10 3
|
3天前
|
JSON 安全 Java
2024年的选择:为什么Go可能是理想的后端语言
【4月更文挑战第27天】Go语言在2024年成为后端开发的热门选择,其简洁设计、内置并发原语和强大工具链备受青睐。文章探讨了Go的设计哲学,如静态类型、垃圾回收和CSP并发模型,并介绍了使用Gin和Echo框架构建Web服务。Go的并发通过goroutines和channels实现,静态类型确保代码稳定性和安全性,快速编译速度利于迭代。Go广泛应用在云计算、微服务等领域,拥有丰富的生态系统和活跃社区,适合作为应对未来技术趋势的语言。
8 0
|
3天前
|
Go 开发者
Golang深入浅出之-Go语言项目构建工具:Makefile与go build
【4月更文挑战第27天】本文探讨了Go语言项目的构建方法,包括`go build`基本命令行工具和更灵活的`Makefile`自动化脚本。`go build`适合简单项目,能直接编译Go源码,但依赖管理可能混乱。通过设置`GOOS`和`GOARCH`可进行跨平台编译。`Makefile`适用于复杂构建流程,能定义多步骤任务,但编写较复杂。在选择构建方式时,应根据项目需求权衡,从`go build`起步,逐渐过渡到Makefile以实现更高效自动化。
14 2
|
3天前
|
存储 Go
Golang深入浅出之-Go语言依赖管理:GOPATH与Go Modules
【4月更文挑战第27天】Go语言依赖管理从`GOPATH`进化到Go Modules。`GOPATH`时代,项目结构混乱,可通过设置多个工作空间管理。Go Modules自Go 1.11起提供更现代的管理方式,通过`go.mod`文件控制依赖。常见问题包括忘记更新`go.mod`、处理本地依赖和模块私有化,可使用`go mod tidy`、`replace`语句和`go mod vendor`解决。理解并掌握Go Modules对现代Go开发至关重要。
12 2