go语言中的锁底层分析(一)


作者简介:C/C++ 、Golang 领域耕耘者,创作者
个人主页:作者主页
专栏地址: 从原理解析go语言
刷题专栏:leetcode专栏
如果感觉博主的文章还不错的话,还请关注➕ 、点赞👍 、收藏🧡三连支持一下博主哦~~~

🧡 0. 前言

go 中的锁的底层理解是比较复杂的,但在go很多数据结构中都使用到了锁。如果深入了解并知道go 底层的锁是如何实现,不同的锁有啥区别,对于后面在项目中也很有帮助。

这一章节主要讲解一下四种锁

  1. sync.Mutex : 互斥锁
  2. sync.RWMutex : 读写锁
  3. sync.WaitGroup : 等待锁
  4. sync.Once : 初始化

💛 1. 锁的基础是什么

1.1 atomic 操作(原子操作)

1.1.1 不使用atomic的场景

多协程累加经典例子

package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

func addP(p *int32) {
	*p++
}

func main() {
	c := int32(0)

	// 并发实现对c的累加
	for i := 0; i < 1000; i++ {
		go addP(&c)
	}

	time.Sleep(time.Second * 1)
	fmt.Println(c)
}

下面是随机运行的两个结果,可见此代码是充满随机性的,原因就是因为addP函数不是原子操作,在项目中我们显然不是要的这个结果
在这里插入图片描述
在这里插入图片描述

1.1.2 使用atomic场景,保护并发变量

由于上面的例子不是我们想要的,现在我们来使用atomic 修改一下

package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

func addP(p *int32) {
	//*p++   // 修改成下面一行
	atomic.AddInt32(p, 1)
}

func main() {
	c := int32(0)

	// 并发实现对c的累加
	for i := 0; i < 1000; i++ {
		go addP(&c)
	}

	time.Sleep(time.Second * 1)
	fmt.Println(c)
}

运行结果: 可以看到每次运行结果都是符合预定结果的
在这里插入图片描述

1.1.3 两个线程操作未保护的变量

这里是两个线程操作未保护变量的例子,在上面的代码还中有1000个并发执行,所以说,像这样的代码充满了随机性
在这里插入图片描述
使用atomic 修改后的代码,查看汇编的过程,会发现里面加了一个锁
在这里插入图片描述

  1. 我们发现汇编中加法前,有一个锁,这个锁是不同于我们使用的锁
  2. 这是一种硬件锁(由硬件实现的锁,不可更改,单纯实现原子操作)
1.1.4 原子操作机制
  1. 原子操作是一种硬件层面加锁的机制 (最最底层的锁)
  2. 保证操作一个变量的时候,其他协程、线程无法访问
  3. 只能用于简单变量的简单操作 (这里只是锁int类型,如果锁一个结构体,业务中的逻辑还是不行的)
1.1.5 atomic 其他功能

在上面我们使用了atomic 的累加功能,其实atomic还有一些功能,比如说下面的比较交换(CAS)、装载功能(load)等

CAS
在这里插入图片描述
他的功能就是将原先的值比较是不是old 值,如果是,换成100。 是将非原子操作换成原子操作

	atomic.CompareAndSwapInt32(&c, 10, 100)
	
	// 等于上面的一行代码
	if c == 10 {
		c = 100
	}

load 功能
加载超过系统字长的值,可能需要多次,防止读取高位时,低位被改,就需要使用这个

使用atomic 中的这个功能,就能不受其他协程操作影响了

atomic.LoadInt64()

atomic 主要是将多个指令变成原子操作,防止其他协程操作的影响,在项目中,十分重要。

1.2 sema 锁

1.2.1 sema 锁的认识
  1. 也叫做信号量锁/信号锁
  2. 核心是一个uint32值,含义是同时可并发的数量
  3. 每一个sema 锁都对应一个SemaRoot 结构体
  4. SemaRoot 中有一个平衡二叉树用于协程排队
1.2.2 sema 锁底层示意图

在这里插入图片描述
在这里插入图片描述
下面是获取锁的函数
在这里插入图片描述

1.2.3 sema 操作(uint32 > 0)

获取锁: uint32 减1, 获取成功
释放锁: uint32 加1, 释放成功

在这里插入图片描述

1.2.4 sema 操作(uint32 == 0)

获取锁: 协程休眠,进入堆树(就是上面提到的平衡二叉树)等待
释放锁: 从堆树中取出一个协程, 唤醒
sema 锁退化成一个专用休眠队列

在这里插入图片描述

1.3 小结

  1. 原子操作是一种硬件层面加锁的机制
  2. 数据类型和操作类型有限制(只能简单的加锁类型)
  3. sema 锁是runtime 的常用工具
  4. sema 经常被用作休眠队列

💚 2. 互斥锁解决了什么问题以及如何工作的

2.1 sync.Mutex

  1. sync.Mutex是Go的互斥锁
  2. 在Go中用于并发保护最常见方案

2.2 使用场景分析

下面是模拟三个人给小伙person 升职加薪

package main
import (
"fmt"
"sync"
"time"
) 
type Person struct {
	mtx sync.Mutex
	salary int
	level int
} 
func (p *Person) promote() {
	p.mtx.Lock()
	p.salary += 1000
	p.level += 1
	fmt.Println(p.salary)
	fmt.Println(p.level)
	p.mtx.Unlock()
} 
func main() {
	p := Person{level: 1, salary: 10000}
	// 模拟三个人给小伙p 升职加薪
	go p.promote()
	go p.promote()
	go p.promote()
	time.Sleep(time.Second)
} 

/**不加锁
13000
4
11000
12000
4
4

加锁
11000
2
12000
3
13000
4
*/

2.3 mutex 数据结构及图标展示

在这里插入图片描述
在这里插入图片描述

2.4 正常模式的加锁

2.4.1 加锁的步骤
  1. 尝试CAS 直接加锁
  2. 若无法直接获取,进行多次尝试自旋尝试
  3. 多次尝试失败,进入sema 队列休眠
2.4.2 状态分析
  1. 初始状态,两个协程在竞争同一把锁时
    在这里插入图片描述
  2. 两个协程会竞争一个锁,得不到锁资源的就会一直自旋(等一下在去尝试获取锁)如果多次没有获取锁,就会休眠, 转过来就获取 sema == 0 就是休眠, 跟前面的章节对应上了
    在这里插入图片描述
  3. 如果锁一直被占用, 另外两个协程竞争不到锁,就会到右边的等待队列中, waiterShift 字段就
    为2, 有几个协程在等待就等于几
    在这里插入图片描述

2.5 正常模式的解锁

2.5.1 加锁的步骤
  1. 尝试CAS 直接解锁
  2. 若发现有协程在sema 中休眠,唤醒一个协程
2.5.2 状态分析
  1. 如果现有的协程解锁后,内部会先检查有没有等待的协程,然后唤醒协程
    在这里插入图片描述
  2. 已经指向唤醒协程

在这里插入图片描述
3. 这里也不是直接放出来后就能抢占到锁,会出现上面的情况
在这里插入图片描述
4. 然后这里就会跟上面的情况一样了
在这里插入图片描述

2.6 小结

  1. mutex 正常模式: 自旋加锁 + sema 休眠等待
  2. mutex正常模式下,可能有锁饥饿的问题

💙 3. 发生锁饥饿了怎么办

在上节说了,在大量竞争的情况下,有锁饥饿的情况

3.1 锁饥饿的状态是什么

先看两幅图
在这里插入图片描述
在这里插入图片描述
上面两个截图,表示两种状态

  1. 在正常情况下,现在在已经占领锁的协程在释放锁的时候会在等待(休眠)队列中去唤醒一个协程,让调度器重新的调度协程
  2. 那把这个协程唤醒之后,这个协程就要给这个锁加锁(由0置为1),如果这个锁存在严重的竞争情况,在这个协程放出来之前就有其他协程在竞争这把锁,所以说他虽然在等待队列里面等了很久,它被放出来也要和新来的协程竞争,它胜的概率也不大,失败之后又会被放到等待队列中去,时间长了就会出现锁饥饿情况
  3. 出现锁饥饿的情况,就会出现跟这个相关的业务就会出现一直卡住的情况,显然不是我们想要的
    所以在源码中出现了一个字段state ,描述饥饿模式

3.2 mutex 饥饿模式

3.2.1 理解
  1. 指当前协程等到锁的时间超过了1ms, 切换到饥饿模式
  2. 饥饿模式中,不自旋,新来的协程直接sema 休眠 (就是没有跑一些空语句,在尝试,跑一些空语句,在尝试的过程)
  3. 饥饿模式中,被唤醒的协程直接获取锁
  4. 没有协程在队列中继续等待时,回到正常模式
3.2.2 步骤
  1. 这个G 是刚刚结束的协程,正在去等待队列中唤醒新的协程
    在这里插入图片描述
  2. 饥饿模式下: 其他新来的协程(空白的协程)直接进入休眠队列,被唤醒的协程直接获取锁
    在这里插入图片描述
  3. 这就叫饥饿模式
    就是等待队列中协程已经空了, 就回到了正常模式

在这里插入图片描述

3.3 饥饿模式的意义

  1. 减少了自旋 的竞争(也就是说在高竞争的情况下,就不要竞争了,保证性能)
  2. 保证公平性,协程已经等了很长时间了。

3.4 互斥锁使用经验

  1. 减少锁的使用时间(在业务函数中只用锁住关键的代码)
  2. 善用defer 确保锁的释放

3.5 小结

  1. 锁竞争严重时,互斥锁进入饥饿模式
  2. 饥饿模式没有自旋等待,有利于公平

推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习

如果此篇博客对你有帮助的话,可以动一动你的小手~~~
👍 点赞,你的认可是我创作的动力!
🧡 收藏,你的青睐是我努力的方向!
✏️ 评论,你的意见是我进步的财富!

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值