使用 go 理解 Lock-Free

chrisho發表於2020-02-22

Lock-Free(無鎖程式設計)

鎖是程式設計中常用的技術, 通常應用於共享記憶體, 多個執行緒向同一資源操作往往會發生很多問題, 為了防止這些問題只能用到鎖解決. 雖然鎖可以解決, 但是在高併發的場景下, 可能會造成效能瓶頸. 無鎖程式設計目前大多數都是基於atomic實現, atomic能夠保證資料的正確性, sync.Mutex也有 Lock-Free 的影子.

無鎖程式設計是什麼?

<<The Art of Multiprocessor Programming>>書中的定義:
"如果一個方法是無鎖的,它保證執行緒無限次呼叫這個方法都能夠在有限步內完成。"

成為無鎖的條件:

  1. 是多執行緒.
  2. 多個執行緒訪問共享記憶體.
  3. 不會令其它執行緒造成阻塞.

go 中如果有一個方法裡操作棧資料, 如果沒有鎖肯定會導致競爭發生, 加上鎖又不會是無鎖. 無鎖程式設計是一個既複雜又具有挑戰性的活, 究竟如何寫一個無鎖程式碼?

實現 Lock-Free

type Config struct {
    sync.RWMutex
    endpoint string
}

func BenchmarkPMutexSet(b *testing.B) {
    config := Config{}
    b.ReportAllocs()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            config.Lock()
            config.endpoint = "api.example.com"
            config.Unlock()
        }
    })
}

func BenchmarkPMutexGet(b *testing.B) {
    config := Config{endpoint: "api.example.com"}
    b.ReportAllocs()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            config.RLock()
            _ = config.endpoint
            config.RUnlock()
        }
    })
}

func BenchmarkPAtomicSet(b *testing.B) {
    var config atomic.Value
    c := Config{endpoint: "api.example.com"}
    b.ReportAllocs()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            config.Store(c)
        }
    })
}

func BenchmarkPAtomicGet(b *testing.B) {
    var config atomic.Value
    config.Store(Config{endpoint: "api.example.com"})
    b.ReportAllocs()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            _ = config.Load().(Config)
        }
    })
}

看看結果

BenchmarkPMutexSet-8            19403011                61.6 ns/op             0 B/op          0 allocs/op
BenchmarkPMutexGet-8            35671380                32.7 ns/op             0 B/op          0 allocs/op
BenchmarkPAtomicSet-8           32477751                37.0 ns/op            48 B/op          1 allocs/op
BenchmarkPAtomicGet-8           1000000000               0.247 ns/op           0 B/op          0 allocs/op

比較結果相當明確, 確實是快. 上面只是一個最簡單的實現, 看看 Lock-Free Stack.

實現 Lock-Free Stack

先看一下鎖實現的棧

var mu sync.Mutex

type LStack struct {
    Next *LStack
    Item int
}

func (head *LStack) Push(i int) {
    mu.Lock()
    defer mu.Unlock()

    new := &LStack{Item: i}
    new.Next = head.Next
    head.Next = new
}

func (head *LStack) Pop() int {
    mu.Lock()
    defer mu.Unlock()

    old := head.Next
    if old == nil {
        return 0
    }

    new := head.Next
    head.Next = new

    return old.Item
}

LStack實現PushPop方法, 兩個方法都加上鎖, 防止競爭.

下面是 Lock-Free Stack

type LFStack struct {
    Next unsafe.Pointer
    Item int
}

var lfhead unsafe.Pointer // 記錄棧頭資訊

func (head *LFStack) Push(i int) *LFStack { // 強制逃逸
    new := &LFStack{Item: i}
    newptr := unsafe.Pointer(new)
    for {
        old := atomic.LoadPointer(&lfhead)
        new.Next = old

        if atomic.CompareAndSwapPointer(&lfhead, old, newptr) {
            break
        }
    }
    return new
}

func (head *LFStack) Pop() int {
    for {
        time.Sleep(time.Nanosecond) // 可以讓CPU緩一緩
        old := atomic.LoadPointer(&lfhead)
        if old == nil {
            return 0
        }

        if lfhead == old {
            new := (*LFStack)(old).Next
            if atomic.CompareAndSwapPointer(&lfhead, old, new) {
                return 1
            }
        }
    }
}

LFStack也實現了PushPop方法, 雖然沒有加鎖, 也可以保證返回資料的正確性. 對比鎖實現的方法來看, 是邏輯要複雜得多. 由於迴圈使 CPU 壓力增大, 可以用time.Sleep暫停一下.

runtime/lfstack.go

最近在研究 gc 時發現 go 原始碼有用到 Lock-Free Stack, 在runtime/lfstack.go

type lfstack uint64

func (head *lfstack) push(node *lfnode) {
    node.pushcnt++
    new := lfstackPack(node, node.pushcnt)
    if node1 := lfstackUnpack(new); node1 != node {
        print("runtime: lfstack.push invalid packing: node=", node, " cnt=", hex(node.pushcnt), " packed=", hex(new), " -> node=", node1, "\n")
        throw("lfstack.push")
    }
    for {
        old := atomic.Load64((*uint64)(head))
        node.next = old
        if atomic.Cas64((*uint64)(head), old, new) {
            break
        }
    }
}

func (head *lfstack) pop() unsafe.Pointer {
    for {
        old := atomic.Load64((*uint64)(head))
        if old == 0 {
            return nil
        }
        node := lfstackUnpack(old)
        next := atomic.Load64(&node.next)
        if atomic.Cas64((*uint64)(head), old, next) {
            return unsafe.Pointer(node)
        }
    }
}

func (head *lfstack) empty() bool {
    return atomic.Load64((*uint64)(head)) == 0
}

func lfnodeValidate(node *lfnode) {
    if lfstackUnpack(lfstackPack(node, ^uintptr(0))) != node {
        printlock()
        println("runtime: bad lfnode address", hex(uintptr(unsafe.Pointer(node))))
        throw("bad lfnode address")
    }
}

lfstack主要是用於對 gc 時儲存灰色物件, 有興趣的可以看看.

小結

Lock-Free 的實現還有很多種, Lock-Free Stack 只是其中之一. 在日常的程式設計中, 基本上用sync.Mutex可以滿足需求, 不要強制專案使用 Lock-Free, 可以選擇在負載高的方法考慮使用, 由於實現複雜有可能效能也不及鎖. 在 benchmark 測試LFStackLStack發現, 前者的效能不及後者, 所以不是無鎖都好用. 如果大家有興趣可以研究一下無鎖佇列.

更多原創文章乾貨分享,請關注公眾號
  • 使用 go 理解 Lock-Free
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章