淺談 Golang 鎖的應用: sync包
Go 語言 sync 包中的鎖都在什么場景下用?怎么用?本文對 sync 包內的鎖做了梳理。
今天談一下鎖,以及 Go 里面 Sync 包里面自帶的各種鎖,說到鎖這個概念,在日常生活中,鎖是為了保護一些東西,比如門鎖、密碼箱鎖,可以理解對資源的保護。在編程里面,鎖也是為了保護資源,比如說對文件加鎖,同一時間只也許一個用戶修改,這種鎖一般叫作文件鎖。
實際開發中,鎖又可分為互斥鎖(排它鎖)、讀寫鎖、共享鎖、自旋鎖,甚至還有悲觀鎖、樂觀鎖這種說法。在 Mysql 數據庫里面鎖的應用更多,比如行鎖、表鎖、間隙鎖,有點眼花繚亂。拋開這些概念,在編程領域,鎖的本質是為了解決并發情況下對數據資源的訪問問題,如果我們不加鎖,并發讀寫一塊數據必然會產生問題,如果直接加個互斥鎖問題是解決了,但是會嚴重影響讀寫性能,所以后面又產生了更復雜的鎖機制,在數據安全性和性能之間找到最佳平衡點。
正常來說,只有在并發編程下才會需要鎖,比如說多個線程(在 Go 里面則是協程)同時讀寫一個文件,下面我以一個文件為例,來解釋這幾種鎖的概念:
如果我們使用互斥鎖,那么同一時間只能由一線程去操作(讀或寫),這就是像是咱們去上廁所,一個坑位同一時間只能蹲一個人,這就是廁所門鎖的作用。
如果我們使用讀寫鎖,意味著可以同時有多個線程讀取這個文件,但是寫的時候不能讀,并且只能由一個線程去寫。這個鎖實際上是互斥鎖的改進版,很多時候我們之所以給文件加鎖是為了避免你在寫的過程中有人讀到了臟數據。
如果我們使用共享鎖,根據我查到資料,這種叫法大多數是源自 MySQL 事務里面的鎖概念,它意味著只能讀數據,并不能修改數據。
如果我們使用自旋鎖,則意味著當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那么該線程將循環等待,然后不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖才會退出循環。
這些鎖的機制在 Go 里面有什么應用呢,下面大家一起看看 Go 標準庫里面 sync 包提供的一些非常強大的基于鎖的實現。
1. 文件鎖
文件鎖和 sync 包沒關系,這里面只是順便說一下,舉個例子,磁盤上面有一個文件,必須保證同一時間只能由一個人打開,這里的同一時間是指操作系統層面的,并不是指應用層面,文件鎖依賴于操作系統實現。
在 C 或 PHP 里面,文件鎖會使用一個 flock 的函數去實現,其實 Go 里面也類似:
需要說明一下,Flock 函數第一個參數是文件描述符,第二個參數是鎖的類型,分為 LOCK_EX(排它鎖)、LOCK_SH(讀共享鎖)、LOCK_NB(遭遇鎖的表現,遇到排它鎖的時候默認會被阻塞,NB 即非阻塞,直接返回 Error)、LOCK_UN(解鎖)。
如果這時候你打開另外一個終端再次運行這個程序你會發現報錯信息如下:
文件鎖保證了一個文件在操作系統層面的數據讀寫安全,不過實際應用中并不常見,畢竟大部分時候我們都是使用數據庫去做數據存儲,極少使用文件。
2.sync.Mutex
下面我所說的這些鎖都是應用級別的鎖,位于 Go 標準庫 sync 包里面,各有各的應用場景。
這是一個標準的互斥鎖,平時用的也比較多,用法也非常簡單,lock 用于加鎖,unlock 用于解鎖,配合 defer 使用,完美。
為了更好的展示鎖的應用,這個舉一個沒有實際意義的例子,給一個 int 變量做加法,用 2 個協程并發的去做加法。
我們想要得到的正常結果是 20000,然而實際上并不是,其結果是不固定的,很可能少于 20000,大家多運行幾次便可得知。
假設你多加一行 runtime.GOMAXPROCS(1),你會發現結果一直是正確的,這是為什么呢?
用一個比較理論的說法,這是因為產生了數據競爭(data race)問題,在 Go 里面我們可以在 go run 后面加上?-race?來檢測數據競爭,結果會告訴你在哪一行產生的,非常實用。
解決這個問題,有多種解法,我們當然可以換個寫法,比如說用 chan 管道去做加法(chan 底層也用了鎖),實際上在 Go 里面更推薦去使用 chan 解決數據同步問題,而不是直接用鎖機制。
在上面的這個例子里面我們需要在 add 方法里面寫,每次操作之前 lock,然后 unlock:
3.sync.RWMutex
讀寫鎖是互斥鎖的升級版,它最大的優點就是支持多讀,但是讀和寫、以及寫與寫之間還是互斥的,所以比較適合讀多寫少的場景。
它的實現里面有 5 個方式:
其中 Lock() 和 Unlock() 用于申請和釋放寫鎖,RLock() 和 RUnlock() 用于申請和釋放讀鎖,RLocker() 用于返回一個實現了 Lock() 和 Unlock() 方法的 Locker 接口。
實話說,平時這個用的真不多,主要是使用起來比較復雜,雖然在讀性能上面比?Mutex?要好一點。
4.sync.Map
這個類型印象中是后來加的,最早很多人使用互斥鎖來并發的操作 map,現在也還有人這么寫:
也就是一個 map 配一把鎖的寫法,可能是這種寫法比較多,于是乎官方就在標準庫里面實現了一個?sync.Map, 是一個自帶鎖的 map,使用起來方便很多,省心。
需要注意的一點是這個 map 的 key 和 value 都是 interface{}類型,所以可以隨意放入任何類型的數據,在使用的時候就需要做好斷言處理。
5.sync.Once
執行結果只打印了一個 one,所以 sync.Once 的功能就是保證只執行一次,也算是一種鎖,通常可以用于只能執行一次的初始化操作,比如說單例模式里面的懶漢模式可以用到。
6.sync.Cond
這個一般稱之為條件鎖,就是當滿足某些條件下才起作用的鎖,啥個意思呢?舉個例子,當我們執行某個操作需要先獲取鎖,但是這個鎖必須是由某個條件觸發的,其中包含三種方式:
-
等待通知:wait, 阻塞當前線程,直到收到該條件變量發來的通知 -
單發通知:signal, 讓該條件變量向至少一個正在等待它的通知的線程發送通知,表示共享數據的狀態已經改變 -
廣播通知:broadcast, 讓條件變量給正在等待它的通知的所有線程都發送通知
下面看一個簡單的例子:
開始我們使用 for 循環啟動 10 個協程,每個協程都在等待鎖,然后使用 signal 發送一個通知。
如果你多次運行,你會發現打印的結果也是隨機從 0 到 9,說明各個協程之間是競爭的,鎖是起到作用的。如果把 singal 替換成 broadcast,則會打印所有結果。
講實話,我暫時也沒有發現有哪些應用場景,感覺這個應該適合需要非常精細的協程控制場景,大家先了解一下吧。
7.sync.WaitGroup
這個大多數人都用過,一般用來控制協程執行順序,大家都知道如果我們直接用 go 啟動一個協程,比如下面這個寫法:
如果沒有后面的 sleep 操作,協程就得不到執行,因為整個函數結束了,主進程都結束了協程哪有時間執行,所以有時候為了方便可以直接簡單粗暴的睡眠幾秒,但是實際應用中不可行。這時候就可以使用 waitGroup 解決這個問題,舉個例子:
8.sync.Pool
這是一個池子,但是卻是一個不怎么可靠的池子,sync.Pool 初衷是用來保存和復用臨時對象,以減少內存分配,降低 CG 壓力。
說它不可靠是指放進 Pool 中的對象,會在說不準什么時候被 GC 回收掉,所以如果事先 Put 進去 100 個對象,下次 Get 的時候發現 Pool 是空也是有可能的。
從輸出結果可以看到,Pool 就像是一個池子,我們放進去什么東西,但不一定可以取出來(如果中間有 GC 的話就會被清空),如果池子空了,就會使用之前定義的 New 方法返回的結果。
為什么這個池子會放到 sync 包里面呢?那是因為它有一個重要的特性就是協程安全的,所以其底層自然也用到鎖機制。
至于其應用場景,知名的 Web 框架 Gin 里面就有用到,在處理用戶的每條請求時都會為當前請求創建一個上下文環境 Context,用于存儲請求信息及相應信息等。Context 滿足長生命周期的特點,且用戶請求也是屬于并發環境,所以對于線程安全的 Pool 非常適合用來維護 Context 的臨時對象池。
轉自:wangbjun.site/2020/coding/golang/locker.html
文章轉載:Go開發大全
(版權歸原作者所有,侵刪)