用原子指针+互斥锁实现仅写时加锁
目录
此博客介绍我写arena的一个经验。
通常在访问共享资源时,简单的加互斥锁就够用,在读多写少时使用读写锁优化性能,在写arena等场景,为了极致的性能,需要别的方法。
回顾一些知识,说明为什么互斥锁和读写锁不适合用在需要极致性能时。
为什么访问共享资源时需要加锁,因为如果不加锁,多个线程可能看到正在进行操作的中间状态,并基于此中间状态执行它自己的一些操作,导致错误结果。
一个简单的例子说明这一点:
如果对变量i进行加1,CPU的执行步骤是
- 读取i的值到寄存器
- 执行加1
- 结果写回i
如果两个线程直接对变量i加1,可能的执行步骤是
- 两个线程读取i的值(i=1)到寄存器
- 执行加1,得到2
- 结果写回i,结果i=2
这样导致i被加了两次1但是结果却不是正确的3。
对于这种情况,CPU提供了原子操作,避免了这样因为看到其他线程操作的中间状态,导致错误结果。
CPU提供的原子操作包括原子加,原子读,原子写,原子交换等,适用于很小的数据。在一些简单的场景,如记录网站访问量,这样简单的对一个整数作加法这种场景是足够的,但在更复杂的场景不够。
对于更复杂的场景,基于原子操作产生了互斥锁。互斥锁有加锁和解锁两个操作,一把锁加锁了必须解锁才能再加锁,这样避免在更复杂的场景看到其他线程操作的中间状态。
用伪代码表示,互斥锁通常这样使用
var lock Mutex
lock.Lock()//加锁
//复杂的操作
lock.Unlock()//解锁
因为互斥锁的特性(一把锁加锁了必须解锁才能再加锁),所以可以确保复杂的操作在锁的保护中,不会被看到它操作时的中间状态,从而避免了错误结果。
简单的互斥锁用go语言写扣除空行,完整实现只需要5行代码
package mutex
import "sync/atomic"
type Mutex int32
func (lock *Mutex) Lock() {for !atomic.CompareAndSwapInt32((*int32)(lock), 0, 1) {}}
func (lock *Mutex) Unlock() {for !atomic.CompareAndSwapInt32((*int32)(lock), 1, 0) {}}
很简单,就是一个整数,用原子操作,加锁就为0时改成1,解锁就为1时改成0。这样就可以实现了一个能用的互斥锁。
因为读操作与写操作不同,读操作不会改变什么,它没有中间状态,它只需要不会写操作同时进行,就可以多个线程同时进行读操作。在读多写少的场景,使用互斥锁让可以同时进行的读操作分开进行,会降低性能,所以产生了读写锁。
读写锁是这样的锁:
- 有读锁和写锁。
- 不能同时加读锁没解锁和加写锁没解锁,其中一种加锁要等待另一种加锁后解锁才能。
- 写锁就像互斥锁。
- 读锁可以加锁n次,但解锁也要n次,否则加写锁永远不会成功。
用伪代码表示,读写锁通常这样使用
var lock RWMutex
//写操作
lock.Lock()//加写锁
//执行写操作
lock.Unlock()//解写锁
//读操作
lock.RLock()//加读锁
//执行读操作
lock.RUnlock()//解读锁
这样因为读写锁的特性,所以写操作不会和读操作同时进行,多个读操作可以同时进行,比互斥锁提高了读多写少时的性能。
互斥锁和读写锁都基于原子操作实现,并且是使用原子加等相对性能更差的原子操作实现,性能随着CPU核数增加反而可能更差,所以在需要极致性能时,不适合。
仅写时加锁
在写arena时,存在读多写少的场景,并且读操作特别多。每次都加读锁不能达到极致的性能。
在原子操作中,原子读相对性能更好。并且数据在内存中有内存地址,它可以进行原子读。
所以可以将使用读写锁的代码,用原子指针+互斥锁(对内存地址进行原子操作的指针)改写成这样代码:
一个伪代码展示通用的模版:
使用读写时的代码
var lock RWMutex
var data []int //共享数据
//写操作
lock.Lock()//加写锁
//执行写操作,例如
data=make([]int,0)
lock.Unlock()//解写锁
//读操作
lock.RLock()//加读锁
//执行读操作,例如
v:=data[0]
lock.RUnlock()//解读锁
改写为用原子指针的代码:
var ptr atomic.Pointer[[]int]
var lock Mutex
var data []int //共享数据
ptr.Store(&data)
//写操作
lock.Lock()//加写锁
//执行写操作,例如创建一个新的[]int替换旧的
new=make([]int,0)
ptr.Store(&new)
lock.Unlock()//解写锁
//读操作,例如读[]int
v:=*ptr.Load()
这样仅写时加锁,进一步提高了读操作的性能。
注意采用这种办法,需要小心注意写操作的涉及的内存必须和读操作涉及的内存不相交,否则读操作和写操作可能同时对同一块内存进行,导致错误结果
这是一个用伪代码写的不正确的模版:
var ptr atomic.Pointer[[]int]
var lock Mutex
var data []int //共享数据
ptr.Store(&data)
//写操作
lock.Lock()//加写锁
//执行写操作,例如创建一个新的[]int替换旧的
v:=*ptr.Load()
v[0]=v[0]+1
lock.Unlock()//解写锁
//读操作,例如读[]int
v:=*ptr.Load()
i:=v[0]
不正确在读操作和写操作对同一块内存(v[0])可以同时进行。