[TOC]
GO 的锁和原子操作分享
上次咱们说到协程,咱们再来回顾一下:
- 协程相似线程,是一种更为轻量级的调度单位
- 线程是零碎级实现的,常见的调度办法是工夫片轮转法
- 协程是应用软件级实现,原理与线程相似
- 协程的调度基于 GPM 模型 实现
要是对协程的应用感兴趣的话,能够看看这篇文章简略理解一下瞅一眼就会应用 GO 的并发编程分享
明天咱们来聊聊 GO 外面的锁
锁是什么?
锁 是用于解决隔离性的一种机制
某个协程(线程)在拜访某个资源时先锁住,避免其它协程的拜访,等拜访结束解锁后其余协程再来加锁进行拜访
在咱们生存中,咱们应该不会生疏,锁是这样的
本意是指置于可启闭的器物上,以钥匙或暗码开启,引申义是用锁锁住、关闭
生存中用到的锁
上锁根本是为了避免外人进来、避免本人财物被盗
编程语言中的锁
锁的品种更是多种多样,每种锁的加锁开销以及利用场景也不尽相同
锁是用来做什么的?
用来管制各个协程的同步,避免资源竞争导致错乱问题
在高并发的场景下,如果选对了适合的锁,则会大大提高零碎的性能,否则性能会升高。
那么晓得各种锁的开销,以及利用场景很有必要
GO 中的锁有哪些?
- 互斥锁
- 读写锁
咱们在编码中会存在多个 goroutine 协程 同时操作一个资源(临界区),这种状况会产生竞态问题(数据竞态)
举一个生存中的例子
生存中最显著的例子就是,大家抢着上厕所,资源无限,只能一个一个的用
举一个编码中的例子
package main
import (
"fmt"
"sync"
)
// 全局变量
var num int64
var wg sync.WaitGroup
func add() {
for i := 0; i < 10000000; i++ {num = num + 1}
// 协程退出,记录 -1
wg.Done()}
func main() {
// 启动 2 个协程,记录 2
wg.Add(2)
go add()
go add()
// 期待子协程退出
wg.Wait()
fmt.Println(num)
}
依照上述代码,咱们的输入后果应该是 20000000
,每一个协程计算 10000000 次,可是理论后果却是
10378923
每一次计算的后果还不一样,呈现这个问题的起因就是上述提到的资源竞争
两个 goroutine 协程在拜访和批改 num 变量,会存在 2 个协程同时对 num+1,最终 num 总共只加了 1,而不是 2
这就导致最初的后果与期待的不符,那么咱们如何解决呢?
咱们当然是用锁管制同步了,保障各自协程在操作临界区资源的时候,先的确是否拿到锁,只有拿到锁了能力进行对临界区资源的批改
先来看看互斥锁
互斥锁
互斥锁的简略了解就像上述咱们讲到上厕所的案例一样,同一时间点,只能有一个人在应用其他人只能排队期待
在编程中,引入了对象互斥锁的概念,来保障共享数据操作的完整性
每个对象都对应于一个可称为 互斥锁
的标记,这个标记用来保障在任一时刻,只能有一个协程拜访该对象。
利用场景
写大于读操作的
它代表的资源就是一个,不论是读者还是写者,只有谁领有了它,那么其他人就只有期待解锁后
咱们来应用互斥锁解决上述的问题
互斥锁 – 解决问题
互斥锁是一种罕用的管制共享资源拜访的办法,它可能保障同时只有一个 goroutine 协程能够访问共享资源
Go 中应用到如下 1 个知识点来解决
- sync 包 的 Mutex 类型 来实现互斥锁
package main
import (
"fmt"
"sync"
)
// 全局变量
var num int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
for i := 0; i < 10000000; i++ {
// 拜访资源前 加锁
lock.Lock()
num = num + 1
// 拜访资源后 解锁
lock.Unlock()}
// 协程退出,记录 -1
wg.Done()}
func main() {
// 启动 2 个协程,记录 2
wg.Add(2)
go add()
go add()
// 期待子协程退出
wg.Wait()
fmt.Println(num)
}
执行上述代码,咱们能看到,输入的后果与咱们预期的统一
20000000
应用互斥锁可能保障同一时间有且只有一个 goroutine 协程进入临界区,其余的 goroutine 则在期待锁
当互斥锁开释后,期待的 goroutine 协程才能够获取锁进入临界区
如何晓得哪一个协程是先被唤醒呢?
可是,多个 goroutine 协程同时期待一个锁时,如何晓得哪一个协程是先被唤醒呢?
互斥锁这里的唤醒的策略是随机的,并不知道到底是先唤醒谁
读写锁
为什么有了互斥锁,还要读写锁呢?
很显著就是互斥锁不能满足所有的利用场景,就催生出了读写锁,咱们细细道来
互斥锁是齐全互斥的,不论协程是读临界区资源还是写临界区资源,都必须要拿到锁,否则就无奈操作(这个限度太死了对吗?)
可是在咱们理论的利用场景下是 读多写少
若咱们并发的去读取一个资源,且不对资源做任何批改的时候如果也要加锁能力读取数据,是不是就很没有必要呢
这种场景下读写锁就发挥作用了,他就绝对灵便了,也很好的解决了读多写少的场景问题
读写锁的品种
- 读锁
- 写锁
当一个 goroutine 协程获取读锁之后,其余的 goroutine 协程如果是获取读锁会持续取得锁
可如果是获取写锁就必须期待
当一个 goroutine 协程获取写锁之后,其余的 goroutine 协程无论是获取读锁还是写锁都会期待
咱们先来写一个读写锁的 DEMO
Go 中应用到如下 1 个知识点来解决
- sync 包 的 RWMutex 类型 来实现读写锁
package main
import (
"fmt"
"sync"
"time"
)
var (
num int64
wg sync.WaitGroup
//lock sync.Mutex
rwlock sync.RWMutex
)
func write() {
// 加互斥锁
// lock.Lock()
// 加写锁
rwlock.Lock()
num = num + 1
// 模仿实在写数据耗费的工夫
time.Sleep(10 * time.Millisecond)
// 解写锁
rwlock.Unlock()
// 解互斥锁
// lock.Unlock()
// 退出协程前 记录 -1
wg.Done()}
func read() {
// 加互斥锁
// lock.Lock()
// 加读锁
rwlock.RLock()
// 模仿实在读取数据耗费的工夫
time.Sleep(time.Millisecond)
// 解读锁
rwlock.RUnlock()
// 解互斥锁
// lock.Unlock()
// 退出协程前 记录 -1
wg.Done()}
func main() {
// 用于计算工夫 耗费
start := time.Now()
// 开 5 个协程用作 写
for i := 0; i < 5; i++ {wg.Add(1)
go write()}
// 开 500 个协程,用作读
for i := 0; i < 1000; i++ {wg.Add(1)
go read()}
// 期待子协程退出
wg.Wait()
end := time.Now()
// 打印程序耗费的工夫
fmt.Println(end.Sub(start))
}
咱们开 5 个协程用于写,开 1000 个协程用于读,应用读写锁加锁,后果耗时 54.4871ms
如下
54.4871ms
如果咱们将上述代码批改成加 互斥锁,运行之后的后果是 1.7750029s
如下
1.7750029s
是不是后果相差很大呢,对于不同的场景利用不同的锁,对于咱们的程序性能影响也是很大,当然上述后果,若读协程,和写协程的个数差距越大,后果就会越迥异
咱们总结一下这一小块的逻辑:
- 写者是排他性的,一个读写锁同时只能有一个写者或多个读者
- 不能同时既有读者又有写者
- 如果读写锁以后没有读者,也没有写者,那么写者能够立即取得读写锁,否则它必须自旋在那里,直到没有任何写者或读者。
- 如果读写锁没有写者,那么读者能够立刻取得该读写锁,否则读者必须自旋在那里,直到写者开释该读写锁。
上述提了自旋锁,咱们来简略解释一下,什么是自旋锁
自旋锁是专为避免多处理器并发而引入的一种锁,它在内核中大量利用于中断解决等局部(对于单处理器来说,避免中断解决中的并发可简略采纳敞开中断的形式,即在标记寄存器中敞开 / 关上中断标记位,不须要自旋锁)。
简略来说,在并发过程中,若其中一个协程拿不到锁,他会不停的去尝试拿锁,不停的去看能不能拿,而不是阻塞睡眠
自旋锁和互斥锁的区别
- 互斥锁
当拿不到锁的时候,会阻塞期待,会睡眠,期待锁开释后被唤醒
- 自旋锁
当拿不到锁的时候,会在原地不停的看能不能拿到锁,所以叫做自旋,他不会阻塞,不会睡眠
如何抉择锁?
对于 C/C++ 而言
- 若加锁后的业务操作耗费,大于互斥锁阻塞后切换上下文的耗费,那么就抉择互斥锁
- 若加锁后的业务操作耗费,小于互斥锁阻塞后切换上下文的耗费,那么抉择自旋锁
对于 GO 而言
- 若写的频次大大的多余读的频次,那么抉择互斥锁
- 若读的频次大大的多余写的频次,那么抉择读写锁
咱们都是对本身要求比拟高的同学,那么有没有比锁还好用的货色呢?
天然是有的,咱们来看看原子操作
啥是原子操作
“ 原子操作 (atomic operation) 是不须要 synchronized”,这是多线程编程的陈词滥调了。所谓原子操作是指不会被线程调度机制打断的操作
这种操作一旦开始,就始终运行到完结,两头不会有任何 context switch(切换到另一个线程)。
原子操作的个性:
- 原子操作是不可分割的,在执行结束之前不会被任何其它工作或事件中断
上述咱们的加锁案例,咱们编码中的加锁操作会波及内核态的上下文切换会比拟耗时、代价比拟高
针对 根本的数据类型 咱们还能够应用原子操作来保障 并发平安
因为原子操作是 Go 语言提供的办法它在 用户态 就能够实现,因而性能比加锁操作更好
不必咱们本人写汇编,这里 GO 也提供了原子操作的包,供咱们一起来应用 sync/atomic
咱们对上述的案例做一个延长
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var num int64
var l sync.Mutex
var wg sync.WaitGroup
// 一般版加函数
func add() {
num = num + 1
wg.Done()}
// 互斥锁版加函数
func mutexAdd() {l.Lock()
num = num + 1
l.Unlock()
wg.Done()}
// 原子操作版加函数
func atomicAdd() {atomic.AddInt64(&num, 1)
wg.Done()}
func main() {
// 目标是 记录程序耗费工夫
start := time.Now()
for i := 0; i < 20000; i++ {wg.Add(1)
// go add() // 无锁的 add 函数 不是并发平安的
// go mutexAdd() // 互斥锁的 add 函数 是并发平安的,因为拿不到互斥锁会阻塞,所以加锁性能开销大
go atomicAdd() // 原子操作的 add 函数 是并发平安,性能优于加锁的}
// 期待子协程 退出
wg.Wait()
end := time.Now()
fmt.Println(num)
// 打印程序耗费工夫
fmt.Println(end.Sub(start))
}
咱们应用上述 demo 代码,模仿了 3 种状况下,程序的耗时以及计算结果 比照
- 不加锁
无锁的 add 函数 不是并发平安的
19495
11.9474ms
- 加互斥锁
互斥锁的 add 函数 是并发平安的,因为拿不到互斥锁会阻塞,所以加锁性能开销大
20000
14.9586ms
- 应用原子操作
原子操作的 add 函数 是并发平安,性能优于加锁的
20000
9.9726ms
总结
- 分享了锁是什么,用来做什么
- 分享了互斥锁,读写锁,以及其区别和利用场景
- 分享了原子操作
- 大家感兴趣能够去看看锁的实现,外面也是有应用原子操作
欢送点赞,关注,珍藏
敌人们,你的反对和激励,是我保持分享,提高质量的能源
好了,本次就到这里,下一次 GO 通道和 sync 包的分享
技术是凋谢的,咱们的心态,更应是凋谢的。拥抱变动,背阴而生,致力向前行。
我是 小魔童哪吒,欢送点赞关注珍藏,下次见~