30 | 原子操作(下)
我們接著上一篇文章的內容繼續聊,上一篇我們提到了,sync/atomic包中的函式可以做的原子操作有:加法(add)、比較並交換(compare and swap,簡稱 CAS)、載入(load)、儲存(store)和交換(swap)。並且以此衍生出了兩個問題。
今天我們繼續來看第三個衍生問題: 比較並交換操作與交換操作相比有什麼不同?優勢在哪裡?
回答是:比較並交換操作即 CAS 操作,是有條件的交換操作,只有在條件滿足的情況下才會進行值的交換。
所謂的交換指的是,把新值賦給變數,並返回變數的舊值。
在進行 CAS 操作的時候,函式會先判斷被操作變數的當前值,是否與我們預期的舊值相等。如果相等,它就把新值賦給該變數,並返回true以表明交換操作已進行;否則就忽略交換操作,並返回false。
可以看到,CAS 操作並不是單一的操作,而是一種操作組合。這與其他的原子操作都不同。正因為如此,它的用途要更廣泛一些。例如,我們將它與for語句聯用就可以實現一種簡易的自旋鎖(spinlock)。
for {
if atomic.CompareAndSwapInt32(&num2, 10, 0) {
fmt.Println("The second number has gone to zero.")
break
}
time.Sleep(time.Millisecond * 500)
}
在for語句中的 CAS 操作可以不停地檢查某個需要滿足的條件,一旦條件滿足就退出for迴圈。這就相當於,只要條件未被滿足,當前的流程就會被一直“阻塞”在這裡。
這在效果上與互斥鎖有些類似。不過,它們的適用場景是不同的。我們在使用互斥鎖的時候,總是假設共享資源的狀態會被其他的 goroutine 頻繁地改變。
而for語句加 CAS 操作的假設往往是:共享資源狀態的改變並不頻繁,或者,它的狀態總會變成期望的那樣。這是一種更加樂觀,或者說更加寬鬆的做法。
package main
import (
"fmt"
"sync/atomic"
"time"
)
func main() {
// 第三個衍生問題的示例。
forAndCAS1()
fmt.Println()
forAndCAS2()
}
// forAndCAS1 用於展示簡易的自旋鎖。
func forAndCAS1() {
sign := make(chan struct{}, 2)
num := int32(0)
fmt.Printf("The number: %d\n", num)
go func() { // 定時增加num的值。
defer func() {
sign <- struct{}{}
}()
for {
time.Sleep(time.Millisecond * 500)
newNum := atomic.AddInt32(&num, 2)
fmt.Printf("The number: %d\n", newNum)
if newNum == 10 {
break
}
}
}()
go func() { // 定時檢查num的值,如果等於10就將其歸零。
defer func() {
sign <- struct{}{}
}()
for {
if atomic.CompareAndSwapInt32(&num, 10, 0) {
fmt.Println("The number has gone to zero.")
break
}
time.Sleep(time.Millisecond * 500)
}
}()
<-sign
<-sign
}
// forAndCAS2 用於展示一種簡易的(且更加寬鬆的)互斥鎖的模擬。
func forAndCAS2() {
sign := make(chan struct{}, 2)
num := int32(0)
fmt.Printf("The number: %d\n", num)
max := int32(20)
go func(id int, max int32) { // 定時增加num的值。
defer func() {
sign <- struct{}{}
}()
for i := 0; ; i++ {
currNum := atomic.LoadInt32(&num)
if currNum >= max {
break
}
newNum := currNum + 2
time.Sleep(time.Millisecond * 200)
if atomic.CompareAndSwapInt32(&num, currNum, newNum) {
fmt.Printf("The number: %d [%d-%d]\n", newNum, id, i)
} else {
fmt.Printf("The CAS operation failed. [%d-%d]\n", id, i)
}
}
}(1, max)
go func(id int, max int32) { // 定時增加num的值。
defer func() {
sign <- struct{}{}
}()
for j := 0; ; j++ {
currNum := atomic.LoadInt32(&num)
if currNum >= max {
break
}
newNum := currNum + 2
time.Sleep(time.Millisecond * 200)
if atomic.CompareAndSwapInt32(&num, currNum, newNum) {
fmt.Printf("The number: %d [%d-%d]\n", newNum, id, j)
} else {
fmt.Printf("The CAS operation failed. [%d-%d]\n", id, j)
}
}
}(2, max)
<-sign
<-sign
}
第四個衍生問題:假設我已經保證了對一個變數的寫操作都是原子操作,比如:加或減、儲存、交換等等,那我對它進行讀操作的時候,還有必要使用原子操作嗎?
回答是:很有必要。其中的道理你可以對照一下讀寫鎖。為什麼在讀寫鎖保護下的寫操作和讀操作之間是互斥的?這是為了防止讀操作讀到沒有被修改完的值,對嗎?
如果寫操作還沒有進行完,讀操作就來讀了,那麼就只能讀到僅修改了一部分的值。這顯然破壞了值的完整性,讀出來的值也是完全錯誤的。
所以,一旦你決定了要對一個共享資源進行保護,那就要做到完全的保護。不完全的保護基本上與不保護沒有什麼區別。
好了,上面的主問題以及相關的衍生問題涉及了原子操作函式的用法、原理、對比和一些最佳實踐,希望你已經理解了。
由於這裡的原子操作函式只支援非常有限的資料型別,所以在很多應用場景下,互斥鎖往往是更加適合的。
不過,一旦我們確定了在某個場景下可以使用原子操作函式,比如:只涉及併發地讀寫單一的整數型別值,或者多個互不相關的整數型別值,那就不要再考慮互斥鎖了。
這主要是因為原子操作函式的執行速度要比互斥鎖快得多。而且,它們使用起來更加簡單,不會涉及臨界區的選擇,以及死鎖等問題。當然了,在使用 CAS 操作的時候,我們還是要多加註意的,因為它可以被用來模仿鎖,並有可能“阻塞”流程。
知識擴充套件
問題:怎樣用好sync/atomic.Value?
為了擴大原子操作的適用範圍,Go 語言在 1.4 版本釋出的時候向sync/atomic包中新增了一個新的型別Value。此型別的值相當於一個容器,可以被用來“原子地”儲存和載入任意的值。
atomic.Value型別是開箱即用的,我們宣告一個該型別的變數(以下簡稱原子變數)之後就可以直接使用了。這個型別使用起來很簡單,它只有兩個指標方法:Store和Load。不過,雖然簡單,但還是有一些值得注意的地方的。
首先一點,一旦atomic.Value型別的值(以下簡稱原子值)被真正使用,它就不應該再被複制了。什麼叫做“真正使用”呢?
我們只要用它來儲存值了,就相當於開始真正使用了。atomic.Value型別屬於結構體型別,而結構體型別屬於值型別。
所以,複製該型別的值會產生一個完全分離的新值。這個新值相當於被複制的那個值的一個快照。之後,不論後者儲存的值怎樣改變,都不會影響到前者,反之亦然。
另外,關於用原子值來儲存值,有兩條強制性的使用規則。第一條規則,不能用原子值儲存nil。
也就是說,我們不能把nil作為引數值傳入原子值的Store方法,否則就會引發一個 panic。
這裡要注意,如果有一個介面型別的變數,它的動態值是nil,但動態型別卻不是nil,那麼它的值就不等於nil。我在前面講介面的時候和你說明過這個問題。正因為如此,這樣一個變數的值是可以被存入原子值的。
第二條規則,我們向原子值儲存的第一個值,決定了它今後能且只能儲存哪一個型別的值。
例如,我第一次向一個原子值儲存了一個string型別的值,那我在後面就只能用該原子值來儲存字串了。如果我又想用它儲存結構體,那麼在呼叫它的Store方法的時候就會引發一個 panic。這個 panic 會告訴我,這次儲存的值的型別與之前的不一致。
你可能會想:我先儲存一個介面型別的值,然後再儲存這個介面的某個實現型別的值,這樣是不是可以呢?
很可惜,這樣是不可以的,同樣會引發一個 panic。因為原子值內部是依據被儲存值的實際型別來做判斷的。所以,即使是實現了同一個介面的不同型別,它們的值也不能被先後儲存到同一個原子值中。
遺憾的是,我們無法通過某個方法獲知一個原子值是否已經被真正使用,並且,也沒有辦法通過常規的途徑得到一個原子值可以儲存值的實際型別。這使得我們誤用原子值的可能性大大增加,尤其是在多個地方使用同一個原子值的時候。
下面,我給你幾條具體的使用建議。
1、不要把內部使用的原子值暴露給外界。比如,宣告一個全域性的原子變數並不是一個正確的做法。這個變數的訪問許可權最起碼也應該是包級私有的。
2、如果不得不讓包外,或模組外的程式碼使用你的原子值,那麼可以宣告一個包級私有的原子變數,然後再通過一個或多個公開的函式,讓外界間接地使用到它。注意,這種情況下不要把原子值傳遞到外界,不論是傳遞原子值本身還是它的指標值。
3、如果通過某個函式可以向內部的原子值儲存值的話,那麼就應該在這個函式中先判斷被儲存值型別的合法性。若不合法,則應該直接返回對應的錯誤值,從而避免 panic 的發生。
4、如果可能的話,我們可以把原子值封裝到一個資料型別中,比如一個結構體型別。這樣,我們既可以通過該型別的方法更加安全地儲存值,又可以在該型別中包含可儲存值的合法型別資訊。
除了上述使用建議之外,我還要再特別強調一點:儘量不要向原子值中儲存引用型別的值。因為這很容易造成安全漏洞。請看下面的程式碼:
var box6 atomic.Value
v6 := []int{1, 2, 3}
box6.Store(v6)
v6[1] = 4 // 注意,此處的操作不是併發安全的!
我把一個[]int型別的切片值v6, 存入了原子值box6。注意,切片型別屬於引用型別。所以,我在外面改動這個切片值,就等於修改了box6中儲存的那個值。這相當於繞過了原子值而進行了非併發安全的操作。那麼,應該怎樣修補這個漏洞呢?可以這樣做:
store := func(v []int) {
replica := make([]int, len(v))
copy(replica, v)
box6.Store(replica)
}
store(v6)
v6[2] = 5 // 此處的操作是安全的。
我先為切片值v6建立了一個完全的副本。這個副本涉及的資料已經與原值毫不相干了。然後,我再把這個副本存入box6。如此一來,無論我再對v6的值做怎樣的修改,都不會破壞box6提供的安全保護。
以上,就是我要告訴你的關於atomic.Value的注意事項和使用建議。你可以在 demo64.go 檔案中看到相應的示例。
package main
import (
"errors"
"fmt"
"io"
"os"
"reflect"
"sync/atomic"
)
func main() {
// 示例1。
var box atomic.Value
fmt.Println("Copy box to box2.")
box2 := box // 原子值在真正使用前可以被複制。
v1 := [...]int{1, 2, 3}
fmt.Printf("Store %v to box.\n", v1)
box.Store(v1)
fmt.Printf("The value load from box is %v.\n", box.Load())
fmt.Printf("The value load from box2 is %v.\n", box2.Load())
fmt.Println()
// 示例2。
v2 := "123"
fmt.Printf("Store %q to box2.\n", v2)
box2.Store(v2) // 這裡並不會引發panic。
fmt.Printf("The value load from box is %v.\n", box.Load())
fmt.Printf("The value load from box2 is %q.\n", box2.Load())
fmt.Println()
// 示例3。
fmt.Println("Copy box to box3.")
box3 := box // 原子值在真正使用後不應該被複制!
fmt.Printf("The value load from box3 is %v.\n", box3.Load())
v3 := 123
fmt.Printf("Store %d to box3.\n", v3)
//box3.Store(v3) // 這裡會引發一個panic,報告儲存值的型別不一致。
_ = box3
fmt.Println()
// 示例4。
var box4 atomic.Value
v4 := errors.New("something wrong")
fmt.Printf("Store an error with message %q to box4.\n", v4)
box4.Store(v4)
v41 := io.EOF
fmt.Println("Store a value of the same type to box4.")
box4.Store(v41)
v42, ok := interface{}(&os.PathError{}).(error)
if ok {
fmt.Printf("Store a value of type %T that implements error interface to box4.\n", v42)
//box4.Store(v42) // 這裡會引發一個panic,報告儲存值的型別不一致。
}
fmt.Println()
// 示例5。
box5, err := NewAtomicValue(v4)
if err != nil {
fmt.Printf("error: %s\n", err)
}
fmt.Printf("The legal type in box5 is %s.\n", box5.TypeOfValue())
fmt.Println("Store a value of the same type to box5.")
err = box5.Store(v41)
if err != nil {
fmt.Printf("error: %s\n", err)
}
fmt.Printf("Store a value of type %T that implements error interface to box5.\n", v42)
err = box5.Store(v42)
if err != nil {
fmt.Printf("error: %s\n", err)
}
fmt.Println()
// 示例6。
var box6 atomic.Value
v6 := []int{1, 2, 3}
fmt.Printf("Store %v to box6.\n", v6)
box6.Store(v6)
v6[1] = 4 // 注意,此處的操作不是併發安全的!
fmt.Printf("The value load from box6 is %v.\n", box6.Load())
// 正確的做法如下。
v6 = []int{1, 2, 3}
store := func(v []int) {
replica := make([]int, len(v))
copy(replica, v)
box6.Store(replica)
}
fmt.Printf("Store %v to box6.\n", v6)
store(v6)
v6[2] = 5 // 此處的操作是安全的。
fmt.Printf("The value load from box6 is %v.\n", box6.Load())
}
type atomicValue struct {
v atomic.Value
t reflect.Type
}
func NewAtomicValue(example interface{}) (*atomicValue, error) {
if example == nil {
return nil, errors.New("atomic value: nil example")
}
return &atomicValue{
t: reflect.TypeOf(example),
}, nil
}
func (av *atomicValue) Store(v interface{}) error {
if v == nil {
return errors.New("atomic value: nil value")
}
t := reflect.TypeOf(v)
if t != av.t {
return fmt.Errorf("atomic value: wrong type: %s", t)
}
av.v.Store(v)
return nil
}
func (av *atomicValue) Load() interface{} {
return av.v.Load()
}
func (av *atomicValue) TypeOfValue() reflect.Type {
return av.t
}
總結
我們把這兩篇文章一起總結一下。相對於原子操作函式,原子值型別的優勢很明顯,但它的使用規則也更多一些。首先,在首次真正使用後,原子值就不應該再被複制了。
其次,原子值的Store方法對其引數值(也就是被儲存值)有兩個強制的約束。一個約束是,引數值不能為nil。另一個約束是,引數值的型別不能與首個被儲存值的型別不同。也就是說,一旦一個原子值儲存了某個型別的值,那它以後就只能儲存這個型別的值了。
基於上面這幾個注意事項,我提出了幾條使用建議,包括:不要對外暴露原子變數、不要傳遞原子值及其指標值、儘量不要在原子值中儲存引用型別的值,等等。與之相關的一些解決方案我也一併提出了。希望你能夠受用。
原子操作明顯比互斥鎖要更加輕便,但是限制也同樣明顯。所以,我們在進行二選一的時候通常不會太困難。但是原子值與互斥鎖之間的選擇有時候就需要仔細的考量了。不過,如果你能牢記我今天講的這些內容的話,應該會有很大的助力。
思考題
今天的思考題只有一個,那就是:如果要對原子值和互斥鎖進行二選一,你認為最重要的三個決策條件應該是什麼?
筆記原始碼
https://github.com/MingsonZheng/go-core-demo
本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。