原子操作和锁
本文先探究并发问题,再探究锁和原子操作解决问题的方式,最后进行对比。
并发问题
首先,我们看一下程序
num++
该程序表面看上去一步就可以运行完成,但是实际上,在计算机中是分三步运行的,如下图
该程序分为三个步骤
①读取当前值:首先,程序需要读取变量 i 的当前值。过程为,将从内存中加载 'i’的值到CPU寄存器中
② 增加值:读取当前值,将存在寄存器中的值加1,而非在 i 的内存地址操作
③ 写回新值:将新的值写回到变量 i 所在的内存地址
假设 i 的初始值为0,调用两个协程运行 i++,理想情况下,i会变成2
运行过程中,会有六步操作,操作的不同顺序也影响着最后的结果
情况1
两个协程依次运行,结果得到的是 2,完美运行
情况2
当两个协程运行的顺序按上图运行,得到的结果是1,结果明显错误。
这就是并发过程中引起的错误。当多个goroutine在没有相互同步的情况下,访问某个共享的资源,同时对该资源进行读写时,就会处于相互竞争的状态,这就是并发中的资源竞争。
针对以上问题,有两种解决方案,一种是锁,另一种是原子操作
锁
生活中的例子
想象一个场景,只有一间厕所,但有两个人都想上厕所,显然,厕所一个时刻只运行一个人使用。
第一个人使用前先将门锁上,以防外面的人进来,结束后,再把门锁打开,然后第二个人在锁门,上厕所,开锁。
程序改进
针对以上num++程序,我们可以类比操作。
第一个协程操作前上锁,然后进行num++操作,运行结束后,解锁。
接着第二个程序才能获取锁,再运行num++,运行结束后解锁。
go代码如下
var mu sync.Mutex
func mutexAdd(){
mu.lock()
num++
mu.unlock()
}
资源竞争
当多个协程进行资源竞争的时候,在一个协程获取到锁的时候,其余的协程进入阻塞态,等待资源释放。当该协程运行结束后,调度系统将阻塞队列其中的一个协程拿出来去获取锁,这其中涉及到切换上下文操作,需要消耗一定资源时间。
原子操作
原子操作,即不会被打断的操作。
原子操作是不可分割的,在执行完毕之前不会被其他任务或事物中断。
如上图,i++可以分为三个操作,这三个操作均为原子操作。原子操作必须执行完毕后,才能执行下一个操作。
有没有一种可能,把这三个原子操作合成为一个原子操作?
可以的,在go的标准库atomic中提供了一系列原子操作,其中有atomic.AddInt64(&num,1)
,可以看作将num++中的三步合并成了一步原子操作。
当num++变成一步原子操作后,便不会出现上述提出的并发问题。因为原子操作是必须一步完成的,其中的过程不能和其他程序交错进行。
go代码如下
func atomicAdd(){
atomic.AddInt64(&num,1)
}
运行对比
单个协程
单个协程在原子操作和加锁操作下的对比
经过对比,可以发现加锁操作步骤多,耗损资源多,运行效率没有原子操作高
多个协程
假设有两个协程同时运行,协程G1先运行,协程G2等待。以下分别是原子操作和加锁操作的区别
原子操作,当协程G1运行结束后,G2操作
- 在g1运行的时候,g2循环等待
- g1运行结束,g2开始执行程序
- 结束
加锁操作,当协程G1运行结束后,G2操作
- 在g1运行的时候,g2获取锁失败,进入阻塞队列
- g1解锁后,调度系统调度协程g2,g2获取锁,进入临界区,切换上下文环境
- 执行程序
- 程序执行结束后,解锁,退出临界区
优势分析
原子操作优势
原子操作适用于对共享变量执行非常简单的操作,如递增、递减、设置标志位等。它们的优势在于性能高,在硬件级别上执行,无需上下文切换或内核调度
原子操作劣势
原子操作无法处理复杂的操作序列,也不能实现多个共享变量之间的复合操作。它们通常不能替代锁,特别是在需要执行多个步骤或操作复杂数据结构时。
锁操作优势
锁适用于需要对多个共享变量执行复杂操作的场景,允许实现复杂的并发算法,并确保一致性。
锁操作劣势
而锁操作伴随着上下文切换和内核调度,这会导致一些性能开销。如果不正常使用,还容易导致死锁和竞态条件
针对以上自增操作,显然,原子操作更占优势。