1. 程式人生 > 其它 >Go語言中的併發安全和鎖

Go語言中的併發安全和鎖

如果沒有鎖

在我們的專案中,可能會存在多個goroutine同時操作一個資源(臨界區),這種情況會發生競態問題(資料競態)。
直接程式碼解釋:

// 多個goroutine併發操作全域性變數x
var x int64
var wg sync.WaitGroup

func add()  {
    for i :=0;i<1000;i++{
        x = x + 1
    }
    wg.Done()
}

func main()  {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

開啟兩個goroutine去累加變數x的值,這兩個goroutine在訪問和修改x變數的時候就會存在資料競爭,導致最後的結果與期待的不符。

互斥鎖

互斥鎖能夠保證同時只有一個goroutine可以訪問共享資源,是一種常用的控制共享資源訪問的方法。Go語言中使用sync包的Mutex型別來實現互斥鎖。
上述程式碼優化:

// 多個goroutine併發操作全域性變數x
var x int64
var wg sync.WaitGroup
var lock sync.Mutex // 互斥鎖

func add()  {
    for i :=0;i<1000;i++{
        lock.Lock()     // 加鎖
        x = x + 1
        lock.Unlock()   // 解鎖
    }
    wg.Done()
}
func main()  {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

使用互斥鎖能夠保證同一時間有且只有一個goroutine進入臨界區,其他的goroutine則在等待鎖;當互斥鎖釋放後,等待的goroutine才可以獲取鎖進入臨界區,多個goroutine同時等待一個鎖時,喚醒的策略是隨機的。
但是,這種方式還是有問題的,讀寫都會等待,大大降低了程式效率。

讀寫互斥鎖

在大多數場景下,是讀多寫少的,,當我們併發的去讀取一個資源不涉及資源修改的時候是沒有必要加鎖的。這種情況就可以使用讀寫互斥鎖,Go語言中使用sync包中的RWMutex型別。
讀寫鎖分為兩種:讀鎖和寫鎖。當一個goroutine獲取讀鎖之後,其他的goroutine如果是獲取讀鎖會繼續獲得鎖,如果是獲取寫鎖就會等待;當一個goroutine獲取寫鎖之後,其他的goroutine無論是獲取讀鎖還是寫鎖都會等待。
我們通過互斥鎖和讀寫鎖分別來檢驗下程式碼執行效率:
互斥鎖程式碼示例:

// 讀寫互斥鎖
var (
    x int64
    lock sync.Mutex
    wg  sync.WaitGroup
)
func read()  {
    lock.Lock()     // 讀加鎖
    time.Sleep(time.Millisecond)        // 讀操作耗時1ms
    lock.Unlock()   //解鎖
    wg.Done()
}

func write()  {
    lock.Lock()     // 寫加鎖
    x += 1
    time.Sleep(time.Millisecond *2) // 寫操作耗時2ms
    lock.Unlock()
    wg.Done()
}

func main()  {
    start := time.Now()
    for i:=0;i<100;i++{
        wg.Add(1)
        go write()
    }

    for i:=0;i<1000;i++{
        wg.Add(1)
        go read()
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

互斥鎖耗時大概1.6s,換成讀寫鎖如下:

// 讀寫互斥鎖
var (
    x int64
    lock sync.RWMutex
    wg  sync.WaitGroup
)
func read()  {
    lock.RLock()        // 讀加鎖
    time.Sleep(time.Millisecond)        // 讀操作耗時1ms
    lock.RUnlock()  //解鎖
    wg.Done()
}

func write()  {
    lock.RLock()        // 寫加鎖
    x += 1
    time.Sleep(time.Millisecond *2) // 寫操作耗時2ms
    lock.RUnlock()
    wg.Done()
}

func main()  {
    start := time.Now()
    for i:=0;i<100;i++{
        wg.Add(1)
        go write()
    }

    for i:=0;i<1000;i++{
        wg.Add(1)
        go read()
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

讀寫鎖耗時在4ms左右。
讀寫鎖非常適合讀多寫少的場景!

sync.WaitGroup

Go語言中可以使用sync.WaitGroup來實現併發任務的同步。
sync.WaitGroup有以下幾個方法:

方法名 功能
(wg * WaitGroup) Add(delta int) 計數器 + delta
(wg *WaitGroup) Done() 計數器-1
(wg *WaitGroup) Wait() 阻塞直到計數器變為0

sync.WaitGroup內部維護著一個計數器,計數器的值可以增加和減少。例如當我們啟動了N 個併發任務時,就將計數器值增加N。每個任務完成時通過呼叫Done()方法將計數器減1。通過呼叫Wait()來等待併發任務執行完,當計數器值為0時,表示所有併發任務已經完成。

sync.Map

Go語言中內建的map不是併發安全的,當併發操作map時,就會出現fatal error: concurrent map writes的錯誤。

var (
    wg sync.WaitGroup
    m  = make(map[int]int)
)

func get(key int) int {
    return m[key]
}

func set(key int, value int) {
    m[key] = value
}

func main() {
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int) {
            set(i, i+10)
            fmt.Println(i, get(i))
            wg.Done()
        }(i)
    }
    wg.Wait()
}

像這種場景下就需要為map加鎖來保證併發的安全性了,Go語言的sync包中提供了一個開箱即用的併發安全版map,sync.Map。
程式碼優化如下:

// sync.map 併發安全的map
var (
    wg sync.WaitGroup
    m  = sync.Map{}
)

func get(key int) interface{} {
    value, _ := m.Load(key)
    return value
}

func set(key int, value int) {
    m.Store(key, value+10)
}

func main() {
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int) {
            set(i, i+10)
            fmt.Println(i, get(i))
            wg.Done()
        }(i)
    }
    wg.Wait()
}

精簡一下:

var (
    wg sync.WaitGroup
    m  = sync.Map{}
)

func main() {
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int) {
            m.Store(i, i+10)
            value, _ := m.Load(i)
            fmt.Println(i, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
}