golang lockfree

本文介绍了Go语言中的锁无关编程概念,强调了Lock-free算法的重要性,指出即使不使用锁,程序也可能不是lockfree的。文章详细解析了基于CAS(Compare and Swap)的原子操作,并通过示例解释了atomic.Value的写入(Store)和读取(Load)操作。同时,文章通过一个sync.Pool的例子说明了其在并发编程中的应用,以及使用限制和注意事项。

一个“锁无关”的程序能够确保执行它的所有线程中至少有一个能够继续往下执行。

就是说你的各个线程不会互相阻塞,那么你的程序才能成为lock free的。像我们平常用的互斥锁,当有线程获得锁,其他线程就被阻塞掉了,这里的问题就是如果获得锁的线程挂掉了,而且锁也没有释放,那么整个程序其实就被block在那了,而如果程序是lock free的那么即使有线程挂掉,也不影响整个程序继续向下进行,也就是系统在整体上而言是一直前进的。

那么,不用锁就是lock free的吗,一开始就提到了,不用锁也可能不是lock free的,如果两个线程同时执行,可能同时进入while循环,然后x两次改变值之后,依然是0,那么两个线程就会一直互相在这里阻塞掉了,所以这里虽然没有锁,依然不是lock free的。

 

Lock-free 算法的基础是 CAS (Compareand-Swap) 原子操作。当某个地址的原始值等于某个比较值时,把值改成新值,无论有否修改,返回这个地址的原始值。目前的cpu 支持最多64位的CAS。并且指针 p 必须对齐。

 注:原子操作指一个cpu时钟周期内就可以完成的操作,不会被其他线程干扰。

一般的 CAS 使用方式是:

假设有指针 p, 它指向一个 32 位或者64位数,

复制p 的内容(*p)到比较量 cmp (原子操作)

基于这个比较量计算一个新值 xchg (非原子操作)

调用 CAS 比较当前 *p 和 cmp, 如果相等把 *p 替换成 xchg (原子操作)

如果成功退出,否则回到第一步重新进行

第3步的 CAS 操作保证了写入的同时 p 没有被其他线程更改。如果*p已经被其他线程更改,那么第2步计算新值所使用的值(cmp)已经过期了,因此这个整个过程失败,重新来过。多线程环境下,由于 3 是一个原子操作,那么起码有一个线程(最快执行到3)的CAS 操作可以成功,这样整体上看,就保证了所有的线程上在“前进”,而不需要使用效率低下的锁来协调线程,更不会导致死锁之类的麻烦。

ABA 问题
当 A 线程执行2的时候,被B 线程更改了 *p为x, 而 C 线程又把它改回了原始值,这时回到A 线程,A线程无法监测到原始值已经被更改过了,CAS 操作会成功(实际上应该失败)。ABA 大部分情况下会造成一些问题,因为 p 的内容一般不可能是独立的,其他内容已经更改,而A线程认为它没有更改就会带来不可预知的结果。

ABA 问题的解决一般是扩展 *p 的位数(比如从32扩展到64),使用多余的位来保存一个版本号,每次更改都修改版本号,从而保证这个线程能正确的监测到值的更改。

atomic.value

使用姿势

atomic.Value类型对外暴露的方法就两个:

  • v.Store(c) - 写操作,将原始的变量c存放到一个atomic.Value类型的v里。c = v.Load() - 读操作,从线程安全的v中读取上一步存放的内容。 

简洁的接口使得它的使用也很简单,只需将需要作并发保护的变量读取和赋值操作用Load()Store()代替就行了。

数据结构

atomic.Value被设计用来存储任意类型的数据,所以它内部的字段是一个interface{}类型,非常的简单粗暴。

type Value struct {
  v interface{}
}

除了Value外,这个文件里还定义了一个ifaceWords类型,这其实是一个空interface (interface{})的内部表示格式(参见runtime/runtime2.go中eface的定义)。它的作用是将interface{}类型分解,得到其中的两个字段。

写入(Store)操作

在介绍写入之前,我们先来看一下 Go 语言内部的unsafe.Pointer类型。

出于安全考虑,Go 语言并不支持直接操作内存,但它的标准库中又提供一种不安全(不保证向后兼容性) 的指针类型unsafe.Pointer,让程序可以灵活的操作内存。

unsafe.Pointer的特别之处在于,它可以绕过 Go 语言类型系统的检查,与任意的指针类型互相转换。也就是说,如果两种类型具有相同的内存结构(layout),我们可以将unsafe.Pointer当做桥梁,让这两种类型的指针相互转换,从而实现同一份内存拥有两种不同的解读方式。

比如说,[]bytestring其实内部的存储结构都是一样的,但 Go 语言的类型系统禁止他俩互换。如果借助unsafe.Pointer,我们就可以实现在零拷贝的情况下,将[]byte数组直接转换成string类型。 

bytes := []byte{104, 101, 108, 108, 111}
 
p := unsafe.Pointer(&bytes) //强制转换成unsafe.Pointer,编译器不会报错
str := *(*string)(p) //然后强制转换成string类型的指针,再将这个指针的值当做string类型取出来
fmt.Println(str) //输出 "hello"

知道了unsafe.Pointer的作用,我们可以直接来看代码了:

func (v *Value) Store(x interface{}) {
  if x == nil {
    panic("sync/atomic: store of nil value into Value")
  }
  vp := (*ifaceWords)(unsafe.Pointer(v))  // Old value
  xp := (*ifaceWords)(unsafe.Pointer(&x)) // New value
  for {
    typ := LoadPointer(&vp.typ)
    if typ == nil {
      // Attempt to start first store.
      // Disable preemption so that other goroutines can use
      // active spin wait to wait for completion; and so that
      // GC does not see the fake type accidentally.
      runtime_procPin()
      if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(^uintptr(0))) {
        runtime_procUnpin()
        continue
      }
      // Complete first store.
      StorePointer(&vp.data, xp.data)
      StorePointer(&vp.typ, xp.typ)
      runtime_procUnpin()
      return
    }
    if uintptr(typ) == ^uintptr(0) {
      // First store in progress. Wait.
      // Since we disable preemption around the first store,
      // we can wait with active spinning.
      continue
    }
    // First store completed. Check type and overwrite data.
    if typ != xp.typ {
      panic("sync/atomic: store of inconsistently typed value into Value")
    }
    StorePointer(&vp.data, xp.data)
    return
  }
}

大概的逻辑:

  • 第5~6行 - 通过unsafe.Pointer现有的要写入的值分别转成ifaceWords类型,这样我们下一步就可以得到这两个interface{}的原始类型(typ)和真正的值(data)。

  • 从第7行开始就是一个无限 for 循环。配合CompareAndSwap食用,可以达到乐观锁的功效。

  • 第8行,我们可以通过LoadPointer这个原子操作拿到当前Value中存储的类型。下面根据这个类型的不同,分3种情况处理。第一次写入(第9~24行) - 一个 Value实例被初始化后,它的typ字段会被设置为指针的零值 nil,所以第9行先判断如果

  • typ是 nil 那就证明这个Value还未被写入过数据。那之后就是一段初始写入的操作:

  • runtime_procPin()这是runtime中的一段函数,具体的功能我不是特别清楚,也没有找到相关的文档。这里猜测一下,一方面它禁止了调度器对当前 goroutine 的抢占(preemption),使得它在执行当前逻辑的时候不被打断,以便可以尽快地完成工作,因为别人一直在等待它。另一方面,在禁止抢占期间,GC 线程也无法被启用,这样可以防止 GC 线程看到一个莫名其妙的指向^uintptr(0)的类型(这是赋值过程中的中间状态)。

  • 使用CAS操作,先尝试将typ设置为^uintptr(0)这个中间状态。如果失败,则证明已经有别的线程抢先完成了赋值操作,那它就解除抢占锁,然后重新回到 for 循环第一步。

  • 如果设置成功,那证明当前线程抢到了这个"乐观锁”,它可以安全的把v设为传入的新值了(19~23行)。注意,这里是先写data字段,然后再写typ字段。因为我们是以typ字段的值作为写入完成与否的判断依据的。

atomic.Value Store 流程

  • 第一次写入还未完成(第25~30行)- 如果看到typ字段还是^uintptr(0)这个中间类型,证明刚刚的第一次写入还没有完成,所以它会继续循环,“忙等"到第一次写入完成。

  • 第一次写入已完成(第31行及之后) - 首先检查上一次写入的类型与这一次要写入的类型是否一致,如果不一致则抛出异常。反之,则直接把这一次要写入的值写入到data字段。

  • 这个逻辑的主要思想就是,为了完成多个字段的原子性写入,我们可以抓住其中的一个字段,以它的状态来标志整个原子写入的状态。这个想法我在 TiDB 的事务实现中看到过类似的,他们那边叫Percolator模型,主要思想也是先选出一个primaryRow,然后所有的操作也是以primaryRow的成功与否作为标志。嗯,果然是太阳底下没有新东西。

    如果没有耐心看代码,没关系,这儿还有个简化版的流程图:

    读取(Load)操作

    先上代码:

    func (v *Value) Load() (x interface{}) {
      vp := (*ifaceWords)(unsafe.Pointer(v))
      typ := LoadPointer(&vp.typ)
      if typ == nil || uintptr(typ) == ^uintptr(0) {
        // First store not yet completed.
        return nil
      }
      data := LoadPointer(&vp.data)
      xp := (*ifaceWords)(unsafe.Pointer(&x))
      xp.typ = typ
      xp.data = data
      return
    }

    读取相对就简单很多了,它有两个分支:

    1.如果当前的typ是 nil 或者^uintptr(0),那就证明第一次写入还没有开始,或者还没完成,那就直接返回 nil (不对外暴露中间状态)。 
    2. 否则,根据当前看到的typdata构造出一个新的interface{}返回出去

    sync.pool

直接上例子

package main
 
import (
    "fmt"
    "sync"
    "sync/atomic"
)
 
// 用来统计实例真正创建的次数
var numCalcsCreated int32
 
// 创建实例的函数
func createBuffer() interface{} {
    // 这里要注意下,非常重要的一点。这里必须使用原子加,不然有并发问题;
    atomic.AddInt32(&numCalcsCreated, 1)
    buffer := make([]byte, 1024)
    return &buffer
}
 
func main() {
    // 创建实例
    bufferPool := &sync.Pool{
        New: createBuffer,
    }
 
    // 多 goroutine 并发测试
    numWorkers := 1024 * 1024
    var wg sync.WaitGroup
    wg.Add(numWorkers)
 
    for i := 0; i < numWorkers; i++ {
        go func() {
            defer wg.Done()
            // 申请一个 buffer 实例
            buffer := bufferPool.Get()
            _ = buffer.(*[]byte)
            // 释放一个 buffer 实例
            defer bufferPool.Put(buffer)
        }()
    }
    wg.Wait()
    fmt.Printf("%d buffer objects were created.\n", numCalcsCreated)
}

sync.Pool 不适合用于像 socket 长连接或数据库连接池?
因为,我们不能对 sync.Pool 中保存的元素做任何假设,以下事情是都可以发生的:

Pool 池里的元素随时可能释放掉,释放策略完全由 runtime 内部管理;
Get 获取到的元素对象可能是刚创建的,也可能是之前创建好 cache 住的。使用者无法区分;
Pool 池里面的元素个数你无法知道;
所以,只有的你的场景满足以上的假定,才能正确的使用 Pool 。sync.Pool 本质用途是
 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

anssummer

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值