golang锁和原子操作
锁是什么?
锁是用于解决隔离性的一种机制
某个协程(线程)在访问某个资源时先锁住,防止其他协程的访问,等访问完毕解锁后其他协程再来加锁进行访问
锁是用来做什么的?
控制各个协程的同步,防止资源竞争导致错乱的问题
在高并发的场景下,如果选对了合适的锁,则会大大提高系统的性能,否则性能会降低。
GO中的锁有哪些?
- 互斥锁
- 读写锁
我们在编码中会存在多个goroutine协程同时操作一个资源(临界区),这种情况会发生竟态问题(数据竞争)
比如生活中 厕所需要排队上,饮水机接水需要一个一个接
体现在代码里是下面这样
package main
import (
"fmt"
"sync"
)
var num int64
var wg sync.WaitGroup
func add() {
for i :=0; i < 1000000; i++ {
num = num + 1
}
wg.Done()
}
func main() {
wg.add(2)
go add()
go add()
wg.Wait()
fmt.Println(num)
}
接下来我们运行代码,我们启动了两个协程执行了两次add函数。所以希望输出的结果是2000000
,可是实际结果却是1119197
哈?我不信。
再多试几次
每次结果还都不一样~
出现这个问题的原因就是上述提到的资源竞争
两个goroutine 协程在访问和修改num变量,会存在两个协程同时对num+1,最终num总共只加了1,而不是2。
例如现在num=10,两个协程同时执行num=num+1 。从num取出来的值都是10,于是等于执行了两次num=11
这就导致最后的结果与预期结果的不符。我们希望得到的结果是2000000
,所以需要num=num+1同一时刻只执行一次
用锁可以解决这个问题,在操作num之前先确定是否拿到锁,只有拿到锁了才能进行对临界区资源的修改
互斥锁
来思考一下我们想要的加锁后上面代码的运行过程
协程1开始第一次循环
协程2开始第一次循环
协程1给num加互斥锁
协程2给num加互斥锁(因为协程1已经锁定了num,所以协程2开始阻塞)
协程1执行num=num+1 num = 1
协程1释放互斥锁
协程2被唤醒 并加锁成功
协程1进入第二次循环
协程2执行num=num+1 num = 2
协程1给num加互斥锁(因为协程2已经锁定了num,所以协程1开始阻塞)
协程2释放互斥锁
协程1被唤醒并加锁成功
...
num只有一个,只要被锁定,其他人不管是读还是写都无法访问。只有等待解锁后去抢占锁
互斥锁一般被使用在 写大于读的场景
互斥锁是一种常用的控制共享资源访问的方法,它能保证同时只有一个goroutine协程可以访问共享资源
接下来我们使用 sync包
的Mutex类型
来实现互斥锁
package main
import (
"fmt"
"sync"
)
var num int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
for i :=0; i < 1000000; i++ {
lock.Lock()
num = num + 1
lock.Unlock()
}
wg.Done()
}
func main() {
wg.add(2)
go add()
go add()
wg.Wait()
fmt.Println(num)
}
执行上述代码,我们能看到,输出的结果与我们预期的一致 2000000
实际上协程1和协程2并不是按照我们想象中的顺序去执行的,因为num = num + 1 执行需要的时间太短了。大多数情况下都是协程1和协程2同时去抢占锁,至于谁能抢到?是完全随机的
读写锁
为什么有了互斥锁,还要读写锁?
很明显互斥锁并不能满足所有的应用场景
互斥锁是完全互斥的,不管协程是读临界区资源还是写临界区资源,都必须要拿到锁。否则就无法操作
但是我们再实际的应用场景下,大多数情况下是读多写少
比如1000个用户同时打开APP需要通过接口获取开屏广告图,这类资源短时间内基本不会发生改变,如果我们也要加锁才能获取数据,接口的响应时间会变的很长。我们不希望发生这种情况
那么,我们的需求是希望大家可以一起读,但是读的过程中不可以改变资源的值。否则大家拿到的结果就不一样了。来思考一下读写混合的场景下 G1、G3只读 G2、G4只写是怎样的执行过程吧
G1加读锁 成功
G2加写锁(资源已被读锁定,自旋)
G3加读锁 成功
G4加写锁(资源已被读锁定,自旋)
G1解读锁
G2解读锁
G3加写锁成功(G3持续尝试加写锁,现在没有读锁加锁成功)
G1加读锁(资源已被读锁定,自旋)
G3解写锁
G4加写锁成功(G4持续尝试加写锁,现在没有读锁加锁成功)
G4解写锁
G1加读锁 成功
当一个goroutine 协程获取读锁之后,其他的 goroutine 协程如果是获取读锁会继续获得锁
可如果是获取写锁就必须等待
当一个 goroutine 协程获取写锁之后,其他的goroutine 协程无论是获取读锁还是写锁都会等待
让我们使用sync包
的RWMutex类型
来实现读写锁
package main
import (
"fmt"
"sync"
"time"
)
var (
num int64
wg sync.WaitGroup
rwlock sync.RWMutex
)
func write() {
rwlock.Lock()
num = num + 1
// 模拟真实写数据消耗的时间
time.Sleep(10 * time.Millisecond)
rwlock.Unlock()
wg.Done()
}
func read() {
rwlock.RLock()
// 模拟真实读取数据消耗的时间
time.Sleep(time.Millisecond)
rwlock.RUnlock()
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))
}
执行上面的代码得到的输出是 53.941061ms
如果我们将上述代码修改成加互斥锁,运行后的输出是1.7750029s
是不是结果相差很大呢,对于不同的场景应用不同的锁,对于我们的程序性能影响也是很大,当然上述结果,若读协程,和写协程的个数差距越大,结果就会越悬殊
总结一下读写锁的特征
- 写锁是排他性的,一个读写锁同时只能有一个写或者多个读
- 不能同时既有读又有写
- 如果资源未被读写锁锁定,那么写者可以即刻获得写锁。否则它必须原地自旋,直到资源被所有者释放
- 如果资源未被写者锁定,那么读者可以立刻获得读锁,否则读者必须原地自旋,直到写者释放写锁
上面提到了自旋,我们来简单解释一下什么是自旋
自旋也叫自旋锁,是专门为了防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,即在标志寄存器中关闭/打开中断标志位,不需要自旋锁)
简单来说,在并发过程中,若其中一个协程拿不到锁,他会不停的去尝试拿锁,而不是阻塞睡眠
自旋锁和互斥锁的区别
- 互斥锁:当拿不到锁的时候,会阻塞等待,会睡眠,等待锁释放后被唤醒
- 自旋锁:当拿不到锁的时候,会在原地不停的看能不能拿到锁,所以叫自旋锁,不会阻塞,不会睡眠
如何选择锁?
- 如果写多读少,那么选择互斥锁
- 如果读多写少,那么选择读写锁
那如果读和写都很多,并且对性能要求更极致的场景怎么办?不管是睡眠还是自旋都会有损耗,我们来了解一下原子操作
啥是原子操作
"原子操作(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 < 2000000; 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))
}
无锁情况下耗时745.292115ms
,但是不是并发安全的,num结果为 1999848
互斥锁情况下耗时846.407091ms
并发安全,比无锁要慢
原子操作情况下耗时806.684619ms
并发安全,比无锁慢,但是比加锁要快
原子操作
加或减
用于加或减的原子操作函数名称都是以Add
开头,后面跟具体的数据类型名 比如 AddInt32
AddInt64
原子操作函数的第一个参数都是指针,是因为原子操作需要知道该变量的内存地址,然后以特殊的CPU指令操作,对于不能取得内存地址的变量是无法进行原子操作的。
原子操作的第二个参数类型会自动转换为与第一个参数相同的类型,原子操作会自动将的操作后的值赋值给变量,无需我们自己手动赋值。
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var num int64 = 99
atomic.AddInt64(&num, 1)
fmt.Println(num)
atomic.AddInt64(&num, -1)
fmt.Println(num)
}
执行上面代码得到的结果是
100
99
对于 AddUint32
和 AddUint64
的第二个参数为 uint32 与 uint64,因此无法直接传递一个负的数值进行减法操作,Go语言提供了另一种方法来迂回实现:使用二进制补码的特性
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var num uint32 = 99
n := 9
atomic.AddUint32(&num, -uint32(n))
fmt.Println(num)
atomic.AddUint32(&num, ^uint32(n-1))
fmt.Println(num)
}
执行上面的代码输出结果为
90
81
比较并交换(Compare And Swap)
简称CAS,在标准库代码包sync/atomic
中以CompareAndSwap
为前缀的若干函数就是CAS操作函数,比如
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
第一个参数的值是这个变量的指针,第二个参数是这个变量的旧值,第三个参数指的是这个变量的新值。
运行过程:调用CompareAndSwapInt32 后,会先判断这个指针上的值是否跟旧值相等,若相等,就用新值覆盖掉这个值,若相等,那么后面的操作就会被忽略掉。返回一个 swapped 布尔值,表示是否已经进行了值替换操作。
与锁有不同之处:锁总是假设会有并发操作修改被操作的值,而CAS总是假设值没有被修改,因此CAS比起锁要更低的性能损耗,锁被称为悲观锁,而CAS被称为乐观锁。
CAS的使用示例
package main
import (
"fmt"
"sync/atomic"
)
var value int32
func AddValue(delta int32) {
// 类似于自旋行为,在确保交换成功后再结束循环,否则不停尝试
for ;!atomic.CompareAndSwapInt32(&value, value, (value+delta)); {}
}
func main() {
AddValue(10)
fmt.Println(value)
AddValue(10)
fmt.Println(value)
}
----输出
10
20
载入与存储
对一个值进行读或写时,并不代表这个值是最新的值,也有可能是在在读或写的过程中进行了并发的写操作导致原值改变。为了解决这问题,Go语言的标准库代码包sync/atomic
提供了原子的读取(Load
为前缀的函数)或写入(Store
为前缀的函数)某个值
package main
import (
"fmt"
"sync/atomic"
)
var value int32
func AddValue(delta int32) {
// 类似于自旋行为,在确保交换成功后再结束循环,否则不停尝试
for ;!atomic.CompareAndSwapInt32(&value, atomic.LoadInt32(&value), (atomic.LoadInt32(&value)+delta)); {}
}
func main() {
AddValue(10)
fmt.Println(value)
AddValue(10)
fmt.Println(value)
}
原子写入总会成功,因为它不需要关心原值是什么,而CAS中必须关注旧值,因此原子写入并不能代替CAS,原子写入包含两个参数,以func StoreInt32(addr *int32, val int32)
为例,addr 是被操作值的指针,val是要写入 addr 的新值
交换
这类操作都以”Swap“开头的函数,称为”原子交换操作“,功能与之前说的CAS操作与原子写入操作有相似之处。
以 func SwapInt32(addr *int32, new int32) (old int32)
为例,addr 是int32类型的指针,new 是新值。原子交换操作不需要关心原值,而是直接设置新值,但是会返回被操作值的旧值。
原子值
Go语言的标准库代码包sync/atomic
中有一个叫做Value
的原子值,它是一个结构体类型,用于存储需要原子读写的值,结构体如下
type Value struct {
v interface{}
}
结构体内是一个v interface{}
,也就是说 该Value
原子值可以保存任何类型的需要原子读写的值。
使用方式如下:
var Atomicvalue atomic.Value
该类型有两个公开的指针方法
//原子的读取原子值实例中存储的值,返回一个 interface{} 类型的值,且不接受任何参数。
//若未曾通过store方法存储值之前,会返回nil
func (v *Value) Load() (x interface{})
//原子的在原子实例中存储一个值,接收一个 interface{} 类型(不能为nil)的参数,且不会返回任何值
func (v *Value) Store(x interface{})
一旦原子值实例存储了某个类型的值,那么之后Store存储的值就必须是与该类型一致,否则就会引发panic。
严格来讲,atomic.Value
类型的变量一旦被声明,就不应该被复制到其他地方。比如:作为源值赋值给其他变量,作为参数传递给函数,作为结果值从函数返回,作为元素值通过通道传递,这些都会造成值的复制。
但是atomic.Value
类型的指针类型变量就不会存在这个问题,原因是对结构体的复制不但会生成该值的副本,还会生成其中字段的副本,这样那么并发引发的值变化都与原值没关系了。
看下面这两种情况对比的示例
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var Atomicvalue atomic.Value
Atomicvalue.Store([]int{1,2,3,4,5})
fmt.Println("main before testA: ",Atomicvalue)
testA(Atomicvalue)
fmt.Println("main after testA: ",Atomicvalue)
// 复位
Atomicvalue.Store([]int{1,2,3,4,5})
fmt.Println("\n")
fmt.Println("main before testB: ",Atomicvalue)
testB(&Atomicvalue)
fmt.Println("main after testB: ",Atomicvalue)
}
func testA(Atomicvalue atomic.Value) {
Atomicvalue.Store([]int{6,7,8,9,10})
fmt.Println("testA: ",Atomicvalue)
}
func testB(Atomicvalue *atomic.Value) {
Atomicvalue.Store([]int{6,7,8,9,10})
fmt.Println("testB: ",Atomicvalue)
}
执行后输出结果:
main before testA: {[1 2 3 4 5]}
testA: {[6 7 8 9 10]}
main after testA: {[1 2 3 4 5]}
main before testB: {[1 2 3 4 5]}
testB: &{[6 7 8 9 10]}
main after testB: {[6 7 8 9 10]}