淺談 Golang 鎖的應用: sync包

2021-12-11 Go開發大全

【導讀】Go 語言 sync 包中的鎖都在什麼場景下用?怎麼用?本文對 sync 包內的鎖做了梳理。

今天談一下鎖,以及 Go 裡面 Sync 包裡面自帶的各種鎖,說到鎖這個概念,在日常生活中,鎖是為了保護一些東西,比如門鎖、密碼箱鎖,可以理解對資源的保護。在編程裡面,鎖也是為了保護資源,比如說對文件加鎖,同一時間只也許一個用戶修改,這種鎖一般叫作文件鎖。

實際開發中,鎖又可分為互斥鎖(排它鎖)、讀寫鎖、共享鎖、自旋鎖,甚至還有悲觀鎖、樂觀鎖這種說法。在 Mysql 資料庫裡面鎖的應用更多,比如行鎖、表鎖、間隙鎖,有點眼花繚亂。拋開這些概念,在編程領域,鎖的本質是為了解決並發情況下對數據資源的訪問問題,如果我們不加鎖,並發讀寫一塊數據必然會產生問題,如果直接加個互斥鎖問題是解決了,但是會嚴重影響讀寫性能,所以後面又產生了更複雜的鎖機制,在數據安全性和性能之間找到最佳平衡點。

正常來說,只有在並發編程下才會需要鎖,比如說多個線程(在 Go 裡面則是協程)同時讀寫一個文件,下面我以一個文件為例,來解釋這幾種鎖的概念:

如果我們使用互斥鎖,那麼同一時間只能由一線程去操作(讀或寫),這就是像是咱們去上廁所,一個坑位同一時間只能蹲一個人,這就是廁所門鎖的作用。

如果我們使用讀寫鎖,意味著可以同時有多個線程讀取這個文件,但是寫的時候不能讀,並且只能由一個線程去寫。這個鎖實際上是互斥鎖的改進版,很多時候我們之所以給文件加鎖是為了避免你在寫的過程中有人讀到了髒數據。

如果我們使用共享鎖,根據我查到資料,這種叫法大多數是源自 MySQL 事務裡面的鎖概念,它意味著只能讀數據,並不能修改數據。

如果我們使用自旋鎖,則意味著當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那麼該線程將循環等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖才會退出循環。

這些鎖的機制在 Go 裡面有什麼應用呢,下面大家一起看看 Go 標準庫裡面 sync 包提供的一些非常強大的基於鎖的實現。

1. 文件鎖

文件鎖和 sync 包沒關係,這裡面只是順便說一下,舉個例子,磁碟上面有一個文件,必須保證同一時間只能由一個人打開,這裡的同一時間是指作業系統層面的,並不是指應用層面,文件鎖依賴於作業系統實現。

在 C 或 PHP 裡面,文件鎖會使用一個 flock 的函數去實現,其實 Go 裡面也類似:

func main() {
    var f = "/var/logs/app.log"
    file, err := os.OpenFile(f, os.O_RDWR, os.ModeExclusive)
    if err != nil {
        panic(err)
    }
    defer file.Close()

    // 調用系統調用加鎖
    err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
    if err != nil {
        panic(err)
    }
    defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
    // 讀取文件內容
    all, err := ioutil.ReadAll(file)
    if err != nil {
        panic(err)
    }

    fmt.Printf("%s", all)
    time.Sleep(time.Second * 10) //模擬耗時操作
}

需要說明一下,Flock 函數第一個參數是文件描述符,第二個參數是鎖的類型,分為 LOCK_EX(排它鎖)、LOCK_SH(讀共享鎖)、LOCK_NB(遭遇鎖的表現,遇到排它鎖的時候默認會被阻塞,NB 即非阻塞,直接返回 Error)、LOCK_UN(解鎖)。

如果這時候你打開另外一個終端再次運行這個程序你會發現報錯信息如下:

panic: resource temporarily unavailable

文件鎖保證了一個文件在作業系統層面的數據讀寫安全,不過實際應用中並不常見,畢竟大部分時候我們都是使用資料庫去做數據存儲,極少使用文件。

2.sync.Mutex

下面我所說的這些鎖都是應用級別的鎖,位於 Go 標準庫 sync 包裡面,各有各的應用場景。

這是一個標準的互斥鎖,平時用的也比較多,用法也非常簡單,lock 用於加鎖,unlock 用於解鎖,配合 defer 使用,完美。

為了更好的展示鎖的應用,這個舉一個沒有實際意義的例子,給一個 int 變量做加法,用 2 個協程並發的去做加法。

var i int

func main() {
    go add(&i)

    time.Sleep(time.Second * 3)

    println(i)
}

func add(i *int) {
    for j := 0; j < 10000; j++ {
        *i = *i + 1
    }
}

我們想要得到的正常結果是 20000,然而實際上並不是,其結果是不固定的,很可能少於 20000,大家多運行幾次便可得知。

假設你多加一行 runtime.GOMAXPROCS(1),你會發現結果一直是正確的,這是為什麼呢?

用一個比較理論的說法,這是因為產生了數據競爭(data race)問題,在 Go 裡面我們可以在 go run 後面加上 -race 來檢測數據競爭,結果會告訴你在哪一行產生的,非常實用。

go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00000056ccb8 by goroutine 7:
  main.add()
      main.go:23 +0x43
Previous write at 0x00000056ccb8 by goroutine 6:
  main.add()
       main.go:23 +0x59
Goroutine 7 (running) created at:
  main.main()
       main.go:14 +0x76
Goroutine 6 (running) created at:
  main.main()
       main.go:13 +0x52
==================
20000
Found 1 data race(s)
exit status 66

解決這個問題,有多種解法,我們當然可以換個寫法,比如說用 chan 管道去做加法(chan 底層也用了鎖),實際上在 Go 裡面更推薦去使用 chan 解決數據同步問題,而不是直接用鎖機制。

在上面的這個例子裡面我們需要在 add 方法裡面寫,每次操作之前 lock,然後 unlock:

func add(i *int) {
    for j := 0; j < 10000; j++ {
        s.Lock()
        *i = *i + 1
        s.Unlock()
    }
}

3.sync.RWMutex

讀寫鎖是互斥鎖的升級版,它最大的優點就是支持多讀,但是讀和寫、以及寫與寫之間還是互斥的,所以比較適合讀多寫少的場景。

它的實現裡面有 5 個方式:

func (rw *RWMutex) Lock()
func (rw *RWMutex) RLock()
func (rw *RWMutex) RLocker() Locker
func (rw *RWMutex) RUnlock()
func (rw *RWMutex) Unlock()

其中 Lock() 和 Unlock() 用於申請和釋放寫鎖,RLock() 和 RUnlock() 用於申請和釋放讀鎖,RLocker() 用於返回一個實現了 Lock() 和 Unlock() 方法的 Locker 接口。

實話說,平時這個用的真不多,主要是使用起來比較複雜,雖然在讀性能上面比 Mutex 要好一點。

4.sync.Map

這個類型印象中是後來加的,最早很多人使用互斥鎖來並發的操作 map,現在也還有人這麼寫:

type User struct {
    m map[string]string
    l sync.Mutex
}

也就是一個 map 配一把鎖的寫法,可能是這種寫法比較多,於是乎官方就在標準庫裡面實現了一個 sync.Map, 是一個自帶鎖的 map,使用起來方便很多,省心。

var m sync.Map

func main() {
    m.Store("1", 1)
    m.Store("2", 1)
    m.Store("3", 1)
    m.Store(4, "5") // 注意類型

    load, ok := m.Load("1")
    if ok {
        fmt.Printf("%v\n", load)
    }

    load, ok = m.Load(4)
    if ok {
        fmt.Printf("%v\n", load)
    }
}

需要注意的一點是這個 map 的 key 和 value 都是 interface{}類型,所以可以隨意放入任何類型的數據,在使用的時候就需要做好斷言處理。

5.sync.Once
package main

import "sync"

var once sync.Once

func main() {
    doOnce()
}

func doOnce() {
    once.Do(func() {
        println("one")
    })
}

執行結果只列印了一個 one,所以 sync.Once 的功能就是保證只執行一次,也算是一種鎖,通常可以用於只能執行一次的初始化操作,比如說單例模式裡面的懶漢模式可以用到。

6.sync.Cond

這個一般稱之為條件鎖,就是當滿足某些條件下才起作用的鎖,啥個意思呢?舉個例子,當我們執行某個操作需要先獲取鎖,但是這個鎖必須是由某個條件觸發的,其中包含三種方式:

等待通知:wait, 阻塞當前線程,直到收到該條件變量發來的通知單發通知:signal, 讓該條件變量向至少一個正在等待它的通知的線程發送通知,表示共享數據的狀態已經改變廣播通知:broadcast, 讓條件變量給正在等待它的通知的所有線程都發送通知

下面看一個簡單的例子:

package main
import (
    "sync"
    "time"
)

var cond = sync.NewCond(&sync.Mutex{})

func main() {
    for i := 0; i < 10; i++ {
        go func(i int) {
            cond.L.Lock()
            cond.Wait() // 等待通知,阻塞當前 goroutine
            println(i)
            cond.L.Unlock()
        }(i)
    }

    // 確保所有協程啟動完畢
    time.Sleep(time.Second * 1)

    cond.Signal()

    // 確保結果有時間輸出
    time.Sleep(time.Second * 1)
}

開始我們使用 for 循環啟動 10 個協程,每個協程都在等待鎖,然後使用 signal 發送一個通知。

如果你多次運行,你會發現列印的結果也是隨機從 0 到 9,說明各個協程之間是競爭的,鎖是起到作用的。如果把 singal 替換成 broadcast,則會列印所有結果。

講實話,我暫時也沒有發現有哪些應用場景,感覺這個應該適合需要非常精細的協程控制場景,大家先了解一下吧。

7.sync.WaitGroup

這個大多數人都用過,一般用來控制協程執行順序,大家都知道如果我們直接用 go 啟動一個協程,比如下面這個寫法:

go func() {
    println("1")
}()

time.Sleep(time.Second * 1) // 睡眠 1s

如果沒有後面的 sleep 操作,協程就得不到執行,因為整個函數結束了,主進程都結束了協程哪有時間執行,所以有時候為了方便可以直接簡單粗暴的睡眠幾秒,但是實際應用中不可行。這時候就可以使用 waitGroup 解決這個問題,舉個例子:

package main

import "sync"

var wg sync.WaitGroup

func main() {
    for i := 0; i < 10; i++ {
        wg.Add(1) // 計數+1
        go func() {
            println("1")
            wg.Done() // 計數-1,相當於 wg.add(-1)
        }()
    }
    wg.Wait() // 阻塞帶等待所有協程執行完畢
}

8.sync.Pool

這是一個池子,但是卻是一個不怎麼可靠的池子,sync.Pool 初衷是用來保存和復用臨時對象,以減少內存分配,降低 CG 壓力。

說它不可靠是指放進 Pool 中的對象,會在說不準什麼時候被 GC 回收掉,所以如果事先 Put 進去 100 個對象,下次 Get 的時候發現 Pool 是空也是有可能的。

package main

import (
    "fmt"
    "sync"
)

type User struct {
    name string
}

var pool = sync.Pool{
    New: func() interface{} {
        return User{
            name: "default name",
        }
    },
}

func main() {
    pool.Put(User{name: "name1"})
    pool.Put(User{name: "name2"})

    fmt.Printf("%v\n", pool.Get()) // {name1}
    fmt.Printf("%v\n", pool.Get()) // {name2}
    fmt.Printf("%v\n", pool.Get()) // {default name} 池子已空,會返回 New 的結果
}

從輸出結果可以看到,Pool 就像是一個池子,我們放進去什麼東西,但不一定可以取出來(如果中間有 GC 的話就會被清空),如果池子空了,就會使用之前定義的 New 方法返回的結果。

為什麼這個池子會放到 sync 包裡面呢?那是因為它有一個重要的特性就是協程安全的,所以其底層自然也用到鎖機制。

至於其應用場景,知名的 Web 框架 Gin 裡面就有用到,在處理用戶的每條請求時都會為當前請求創建一個上下文環境 Context,用於存儲請求信息及相應信息等。Context 滿足長生命周期的特點,且用戶請求也是屬於並發環境,所以對於線程安全的 Pool 非常適合用來維護 Context 的臨時對象池。

相關焦點

  • Golang語言標準庫 sync 包的 Once 怎麼使用?
    ,sync 包有一個 Once 類型,官方文檔介紹 Once 是一個只執行一次操作的對象。o.doSlow(f) }}func (o *Once) doSlow(f func()) {  // 二次檢查時,持有互斥鎖,保證只有一個 goroutine 執行。
  • Golang中sync.Map的實現原理
    需要並發讀寫時,一般的做法是加鎖,但這樣性能並不高,Go語言在 1.9 版本中提供了一種效率較高的並發安全的 sync.Map,今天,我們就來講講 sync.Map的用法以及原理#  使用方法```gofunc main() { var m sync.Map //插入 m.Store("1","a")
  • Golang並發:再也不愁選channel還是選鎖
    如果自己心裡還沒有清晰的答案,那就讀下這篇文章,你會了解到:前戲前面很多篇的文章都在圍繞channel介紹,而只有前一篇sync的文章介紹到了Mutex,不是我偏心,而是channel在Golang是first class級別的,設計在語言特性中的,而Mutex只是一個包中的。這就註定了一個是主角,一個是配角。
  • 構建微服務的十大 Golang 框架和庫
    現在已經有很多開源庫 golang 支持構建應用程式,這些庫設計簡單,代碼乾淨,性能良好,本文為大家精心挑選了十個實用的框架和庫。
  • golang下文件鎖的使用
    前言題目是golang下文件鎖的使用,但本文的目的其實是通過golang下的文件鎖的使用方法,來一窺文件鎖背後的機制。
  • 它來了,關於Golang並發編程的超詳細教程!
    make(chan string) go func() { var counter int64 = 0 for { id <- fmt.Sprintf("%x", counter) counter += 1 } }() return id}func newUniqueIdServerMain() { id := newUniqueIdService
  • golang channel 使用總結
    本文介紹了使用 golang channel 的諸多特性和技巧,已經熟悉了 go 語言特性的小夥伴也可以看看,很有啟發。這種方式的優點是通過提供原子的通信原語,避免了競態情形 (race condition) 下複雜的鎖機制。channel 可以看成一個 FIFO 隊列,對 FIFO 隊列的讀寫都是原子的操作,不需要加鎖。
  • 在 Node.js 中引入 Golang ,會讓它更快嗎?
    測試項嘗試僅使用 Node.js 解決 CPU 密集型任務創建單獨使用 的Golang 編寫的服務,並通過發送請求或消息隊列的方式將其連接到應用裡面使用 Golang 構建 wasm 文件以運行 Node.js 中的某些方法速度與金錢我是老式義大利西部片的粉絲,尤其是《The Good, the Bad and the Ugly》。
  • 初學者入門 Golang 的學習型項目
    同時又是 Go 在第二領域開始發力的一年,我所說的第二領域就是區塊鏈,大家都知道比特幣在過去一年可以說迅速的火爆全球,而它的底層技術區塊鏈技術也是開始獲得大家的認知,這個可以說是區塊鏈 1.0 版本應用,而區塊鏈 2.0 版本應用就是智能合約以太坊開始獲得所有人的認知,基本上目前大多數做區塊鏈的公司都是基於以太坊進行二次開發,而以太坊的底層正是 Go 開發的,同時 IBM 也開源了他們的
  • golang 編程風格最佳實踐
    /doc/effective_go.htmluber golang 代碼規範 https://github.com/uber-go/guideuber golang 代碼規範中文 https://github.com/xxjwxc/uber_go_guide_cn代碼目錄規範
  • Go:有了 sync 為什麼還有 atomic?
    這是將競態條件調整為使用互斥鎖的相同測試用例:func Test_NoRaceCondition(t *testing.T) { var s = make([]int, 0) m := sync.Mutex{} wg := sync.WaitGroup
  • 原子操作和互斥鎖的區別
    所以 我們要使用sync/atomic代碼包中為我們提供的以Load為前綴的函數,來避免這樣的糟糕事情發生。競爭條件是由於異步的訪問共享資源,並試圖同時讀寫該資源而導致的,使用互斥鎖和通道的思路都是在線程獲得到訪問權後阻塞其他線程對共享內存的訪問,而使用原子操作解決數據競爭問題則是利用了其不可被打斷的特性。
  • golang的空結構體struct{}介紹
    簡介空結構體:不含任何欄位的struct類型我們稱之為空結構體,即struct{}由於空結構體不包含欄位,也就是沒有任何數據,因此所佔內存大小空間為0var s struct{}fmt.Println(unsafe.Sizeof
  • 【Golang】Context基礎篇
    一個接口golang的context包定義了Context類型,根據官方文檔的說法,該類型被設計用來在API邊界之間以及過程之間傳遞截止時間
  • Golang:重新認識你的Go應用
    於是乎,翻翻資料和代碼,重新認識我們所寫的應用。1 按圖索驥,梳理啟動流程package mainimport "fmt"func main() { fmt.Println("Hello World")}示例應用,執行命令-go build -gcflags "-N -l" -o hello .
  • Golang 語言的標準庫 log 包怎麼使用?
    () 函數之前,先釋放互斥鎖,獲取到信息後再加上互斥鎖來保證安全。我們查看 log 包的源碼。type Logger struct { mu     sync.Mutex // ensures atomic writes; protects the following fields prefix string     // prefix on each line to identify the logger (but
  • 關於Golang的4個小秘密
    = append(out, &item) }()}for _, v := range in { wg.Add(1) go func(value int) { defer wg.Done() out = append(out, &value) }(v)}‍golang
  • golang每日一題(fmt.Sprintf)
    Sprintf方法輸入參數是代表格式的字符串和接口類型,返回string類型的數據,首先先在newPrinter()中初始化一個*pp類型的printer,其中關鍵的是存放列印字符串的buffer和各種判斷arg的參數:// pp is used to store a printer's state and is reused with sync.Pool
  • golang 調試分析的高階技巧
    debug 的一些技巧應用,以及相關工具的實用用法,再也不用怕 golang 怎麼調試。golang 作為一門現代化語音,出生的時候就自帶完整的 debug 手段:golang tools 是直接集成在語言工具裡,支持內存分析,cpu分析,阻塞鎖分析等;delve,gdb 作為最常用的 debug 工具,讓你能夠更深入的進入程序調試;delve 當前是最友好的 golang 調試程序,ide 調試其實也是調用 dlv 而已,比如 goland;單元測試的設計深入到語言設計級別