秋来冬风的博客

用原子指针+互斥锁实现仅写时加锁

目录

此博客介绍我写arena的一个经验。

通常在访问共享资源时,简单的加互斥锁就够用,在读多写少时使用读写锁优化性能,在写arena等场景,为了极致的性能,需要别的方法。

回顾一些知识,说明为什么互斥锁和读写锁不适合用在需要极致性能时。

为什么访问共享资源时需要加锁,因为如果不加锁,多个线程可能看到正在进行操作的中间状态,并基于此中间状态执行它自己的一些操作,导致错误结果。

一个简单的例子说明这一点:

如果对变量i进行加1,CPU的执行步骤是

  1. 读取i的值到寄存器
  2. 执行加1
  3. 结果写回i

如果两个线程直接对变量i加1,可能的执行步骤是

  1. 两个线程读取i的值(i=1)到寄存器
  2. 执行加1,得到2
  3. 结果写回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。这样就可以实现了一个能用的互斥锁。

因为读操作与写操作不同,读操作不会改变什么,它没有中间状态,它只需要不会写操作同时进行,就可以多个线程同时进行读操作。在读多写少的场景,使用互斥锁让可以同时进行的读操作分开进行,会降低性能,所以产生了读写锁。

读写锁是这样的锁:

  1. 有读锁和写锁。
  2. 不能同时加读锁没解锁和加写锁没解锁,其中一种加锁要等待另一种加锁后解锁才能。
  3. 写锁就像互斥锁。
  4. 读锁可以加锁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])可以同时进行。

Tags: