
文章目錄
- 1.什么是并發安全
- 2.struct 并發賦值安全嗎
- 3.如何保證并發賦值的安全性
- 4.哪些型別并發賦值是安全的
- 4.1 基本型別的并發賦值
- 4.1.1 位元組型、布爾型、整型、浮點型、字符型(安全)
- 4.1.2 復數型(不安全)
- 4.1.3 字串(不安全)
- 4.2 復合資料型別的并發賦值
- 4.2.1 指標(安全)
- 4.2.2 函式(安全)
- 4.2.2 陣列、切片、字典、通道、介面(不安全)
- 陣列
- 切片
- 字典
- 通道
- 介面
- 5.小結
- 參考文獻
我們知道 Golang 中變數的賦值不是并發安全的,實際情況果真如此嗎?
1.什么是并發安全
并發安全就是程式在并發情況下執行的結果是正確的,
比如對一個變數簡單的自增操作count++,在非并發下很好理解,而在并發情況下卻容易出現預期之外的結果,這樣的代碼就是非并發安全的,
因為count++其實是分成兩步執行的,當分成了兩步執行,那么其他協程就可以趁著這個時間間隙作怪,
如一下 a b 兩個協程同時 count++:
count:= 1
a > 讀取count : 1
b > 讀取count : 1
a > 計算count+1 : 2
b > 計算count+1 : 2
a > 賦值count : 2
b > 賦值count : 2
這就會發生明明 a b 協程計算了兩次,可結果還是 2,
2.struct 并發賦值安全嗎
對一個簡單變數的自增都會出現偏差,那么賦值一個更為復雜的結構體會不會有問題呢?
例如以下代碼,在多協程的情況下,并發使用兩個不同的值對結構體變數進行賦值,如果結構體成員出現例外情況, 那么說明并發出現了問題,
type Test struct {
X int
Y int
}
func main() {
var g Test
for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
g = Test{1,2}
}()
// 協程 2
wg.Add(1)
go func(){
defer wg.Done()
g = Test{3,4}
}()
wg.Wait()
// 賦值例外判斷
if !((g.X == 1 && g.Y == 2) || (g.X == 3 && g.Y == 4)) {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
}
運行一次或多次,將出現賦值例外,
concurrent assignment error, i=48714 g={X:1 Y:4}
結構體中有多個欄位,協程 1 賦值了欄位 X,協程 2 賦值了欄位 Y,此時整個結構體既不是協程 1 想要的結果,也不是協程 2 想要的結果,可見 struct 賦值時,并不是原子操作,各個欄位的賦值是獨立的,在并發操作的情況下可能會出現例外,
3.如何保證并發賦值的安全性
Golang 早已想到該問題,并為我們提供一個開箱即用的型別 atomic.Value 來保證賦值的并發安全,
// A Value provides an atomic load and store of a consistently typed value.
// The zero value for a Value returns nil from Load.
// Once Store has been called, a Value must not be copied.
//
// A Value must not be copied after first use.
type Value struct {
v interface{}
}
讓我們借助 atomic.Value 來完成對 struct 的安全并發賦值,
func main() {
var v atomic.Value
for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
v.Store(Test{1,2})
}()
// 協程 2
wg.Add(1)
go func(){
defer wg.Done()
v.Store(Test{3,4})
}()
wg.Wait()
// 賦值例外判斷
g := v.Load().(Test)
if (g.X == 1 && g.Y == 2) || (g.X == 3 && g.Y == 4) {
} else {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
}
上面執行將不會出現并發賦值例外的情況,
4.哪些型別并發賦值是安全的
我們已經知道了 struct 因為存在多個欄位,賦值時各個欄位時獨立完成,所以并發不安全,那么對于 Golang 中其他的資料型別,并發賦值是安全的嗎?
Golang 中資料型別可以分類兩大類:基本資料型別和復合資料型別,
基本資料型別有:位元組型,布爾型、整型、浮點型、字符型、復數型、字串,
復合資料型別包括:指標、陣列、切片、結構體、字典、通道、函式、介面,
復合資料類又可細分為如下三類:
(1)非參考型別:陣列、結構體;
(2)參考型別:指標、切片、字典、通道、函式;
(3)介面,
下面一一列舉哪些資料型別是并發不安全的,
4.1 基本型別的并發賦值
4.1.1 位元組型、布爾型、整型、浮點型、字符型(安全)
由于位元組型、布爾型、整型、浮點型、字符型的位寬不會超過 64 位,在 64 位的指令集架構中可以由一潭訓器指令完成,不存在被細分為更小的操作單位,所以這些型別的并發賦值是安全的,
下面以浮點型為例進行測驗,
func main() {
var g float64
for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
g = 1.1
}()
// 協程 2
wg.Add(1)
go func(){
defer wg.Done()
g = 2.2
}()
wg.Wait()
// 賦值例外判斷
if g != 1.1) && g != 2.2 {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
}
上面個的測驗代碼對一個 float64 型別的變數進行并發賦值是沒有問題的,其他型別讀者可自行驗證,
4.1.2 復數型(不安全)
按照上面的分析,因為復數型分為實部和虛部,兩者的賦值是分開進行的,所以復數型別并發賦值是不安全的,
func main() {
var g complex64
for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
g = complex(1,2)
}()
// 協程 2
wg.Add(1)
go func(){
defer wg.Done()
g = complex(3,4)
}()
wg.Wait()
// 賦值例外判斷
if g != complex(1,2) && g != complex(3,4) {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
}
運行輸出:
concurrent assignment error, i=131512 g=(1+4i)
注意:如果復數并發賦值時,有相同的虛部或實部,那么兩個欄位賦值就退化成一個欄位,這種情況下時并發安全的,讀者可自行驗證,
4.1.3 字串(不安全)
字串在 Go 中是一個只讀位元組切片,
字串有兩個重要特點:
(1)string 可以為空(長度為 0),但不會是 nil;
(2)string物件不可以修改,
在原始碼包src/runtime/string.go我們可以找到 string 的底層資料結構:
type stringStruct struct {
str unsafe.Pointer
len int
}
其資料結構很簡單:
str 為字串的首地址;
len 為字串的長度(單位位元組);
string 資料結構跟切片有些類似,只不過切片還有一個表示容量的成員,事實上 string 和位元組切片間經常強制互轉,
因為 string 底層結構是個 struct,前面已經討論過 struct 并發賦值是不安全的,所以 string 的并發賦值同樣是不安全,我們來驗證一下,
func main() {
var s string
for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
s = "ab"
}()
// 協程 2
wg.Add(1)
go func() {
defer wg.Done()
s = "abc"
}()
wg.Wait()
// 賦值例外判斷
if s != "ab" && s != "abc" {
fmt.Printf("concurrent assignment error, i=%v s=%v", i, s)
break
}
}
}
運行輸出:
concurrent assignment error, i=509383 s=abi
并發賦值不出意料地出現了例外情況,推測正確,
從這里我們可以得到一個基本結論:只要底層結構是 struct 的型別,那么并發賦值都是不安全的,
注意不安全不代表一定發生錯誤,就是說不安全不代表任何并發賦值的情況下都會發生錯誤,比如上面測驗代碼回圈次數少的情況下,很難出現出現例外情況,
不過我這里想說的不是次數的問題,因為次數多少是個概率的問題,我這里說的是和所要賦的值有關,只要不同的值滿足一定特點,不管多少次并發,都是安全的,
為什么可以這么說呢,我們還是要回看 string 的底層資料結構,因為是兩個欄位,位元組指標 str 和字串長度 len,我們只要保證并發賦值情況下,兩個欄位的賦值正確就行,前面也說了,因為 struct 多個欄位的賦值是獨立,所以如果兩個欄位中只要有一個欄位是不同的,那么并發賦值就變成了一個欄位的并發賦值,這樣就不會出現問題,
比如我們并發賦值兩個等長度但內容不同的字串,就不會有問題,驗證如下:
func main() {
var s string
for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
s = "123"
}()
// 協程 2
wg.Add(1)
go func() {
defer wg.Done()
s = "abc"
}()
wg.Wait()
// 賦值例外判斷
if s != "123" && s != "abc" {
fmt.Printf("concurrent assignment error, i=%v s=%v", i, s)
break
}
}
}
上面的代碼,因為字串 123 和 abc 是等長的,所以并發賦值不管回圈多少次都是絕對的安全,因為 struct 賦值蛻變成了一個數值型指標的賦值,
4.2 復合資料型別的并發賦值
4.2.1 指標(安全)
指標是保存另一個變數的記憶體地址的變數,指標的零值為 nil,
因為是記憶體地址,所以位寬為 32位(x86平臺)或 64位(x64平臺),賦值操作由一個機器指令即可完成,不能被中斷,所以也不會出現并發賦值不安全的情況,
這在上面討論 string 等長不同值并發賦值時,已經驗證沒有問題,
4.2.2 函式(安全)
Go 函式可以像值一樣傳遞,
Go 函式定義形式如下:
func some_func_name(arguments) return_values
定義函式型別時去掉函式名:
type TypeName func(arguments) return_values
其中 TypeName 是自定義的型別名稱,
下面是一個函式型別的使用示例:
package main
import "fmt"
func main() {
// 定義函式型別的變數
add := func(x, y int) int {
return x + y
}
fmt.Println(add(1, 2))
}
// 函式作為形參
func doOperation(fn func(int, int) int, x, y int) int {
return fn(x, y)
}
函式型別的變數賦值時,實際上賦的是函式地址,一潭訓器指令便可以完成,所以并發賦值是安全的,
我們使用unsafe.Sizeof()可以查看函式型別的寬度(位元組),
type Add func(int, int) int
var add Add
fmt.Println(unsafe.Sizeof(add)) // 8
下面驗證一下函式變數并發賦值的安全性,
type Add func(int, int) int
func main() {
var g Add
var i int
for ; i < 10000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
g = func(x, y int) int {
return x + y
}
}()
// 協程 2
wg.Add(1)
go func() {
defer wg.Done()
g = func(x, y int) int {
return x - y
}
}()
wg.Wait()
// 賦值例外判斷
if !(g(1, 1) == 2 || g(1, 1) == 0) {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
if i == 10000000 {
fmt.Println("no error")
}
}
運行輸出:
no error
4.2.2 陣列、切片、字典、通道、介面(不安全)
陣列、切片、字典、通道、介面,這些復合型別,除了陣列,其他底層資料結構都是 struct,所以并發都不是安全的,當然陣列并發賦值也是不安全的,
下面的講解不會對所有型別一一驗證,不過相關的底層資料我們應該著重了解一下,
陣列
array 是相同型別值的集合,陣列的長度是其型別的一部分,
陣列賦值和傳參都會拷貝整個陣列的資料,所以陣列不是參考型別,
陣列的底層資料結構就是其本身,是一個相同型別不同值的順序排列,所以如果陣列位寬不大于 64 位且是 2 的整數次冪(8,16,32,64),那么其并發賦值其實也是安全的,只不過這個大部分情況并非如此,所以其并發賦值是不安全的,
下面以位元組陣列為例,看下位寬不大于 64 位的并發賦值安全的情況,
func main() {
var g [4]byte
var i int
for ; i < 10000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
g = [...]byte{1, 2, 3, 4}
}()
// 協程 2
wg.Add(1)
go func() {
defer wg.Done()
g = [...]byte{3, 4, 5, 6}
}()
wg.Wait()
// 賦值例外判斷
if !(g == [...]byte{1, 2, 3, 4} || g == [...]byte{3, 4, 5, 6}) {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
if i == 10000000 {
fmt.Println("no error")
}
}
運行輸出:
no error
可以看到,位寬為 32 位的陣列 [4]byte,雖然有四個元素,但是賦值時由一潭訓器指令完成,所以也是原子操作,
如果你把位元組陣列的長度換成下面這樣子,即使沒有超過 64 位,也需要多條指令完成賦值,因為 CPU 中并沒有這樣位寬的暫存器,需要拆分為多條指令來完成,
[3]byte
[5]byte
[7]byte
切片
slice 也是相同型別值的集合,只不過切片是動態調整大小的,內部是對陣列的參考,相當于動態陣列,如上所述,陣列的大小是固定的,因此切片為陣列提供了更靈活的介面,
切片是一種參考型別,它內部由三個欄位表示:
- 陣列地址
- 陣列長度
- 容量大小
在原始碼包src/runtime/slice.go我們可以找到切片的底層資料結構:
type slice struct {
array unsafe.Pointer
len int
cap int
}
因為其是一個 struct,所以并發賦值是不安全的,這里不再以代碼驗證,
字典
map 是經常被使用的內置 key-value 型容器,是一個同型別元素的無序組,元素通過另一型別唯一鍵進行索引,
map 的底層結構也是一個 struct,定義于src/runtime/map.go:
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
map 并發讀寫會引發 panic,一般使用讀寫鎖 sync.RWMutex 來保證安全,
通道
channel 在 goroutine 之間提供同步和通信,您可以將其視為 goroutines 通過其發送值和接收值的管道,運算子<-用于發送或接收資料,箭頭方向指定資料流的方向,
ch <- val // Sending a value present in var variable to channel
val := <-cha // Receive a value from the channel and assign it to val variable
因為 channel 通常用法是初始化后作為共享變數在 goroutine 之間提供同步和通信,很少會發生賦值,就是把一個 channel 賦給另一個 channel,所以這里就不過多討論其并發賦值的安全性,如果真的有這種情況,那么只要知道其底層資料結構是個 struct,并發賦值時不安全的即可,
關于 channel 的底層資料介面可在 Go 原始碼src\runtime\chan.go,
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
關于 channel 的用法和實作原理,感興趣的同學可自行查閱資料探究,這里不再贅述,
介面
介面是 Go 中的一個型別,它是方法的集合,實作介面的所有方法的任何型別都屬于該介面型別,介面的零值為 nil,
定義一個介面型別的變數后,如果具體型別實作了介面的所有方法,我們可以將任何具體型別的值賦給這個變數,
實際上 Go 中的介面有個特殊情況,就是空介面,其不包含任何方法,因此,默認情況下,所有具體型別都實作空介面,
如果撰寫的函式接受空介面,則可以向該函式傳遞任何型別,
package main
import "fmt"
func main() {
test("thisisstring")
test("10")
test(true)
}
func test(a interface{}) {
fmt.Printf("(%v, %T)\n", a, a)
}
運行輸出:
(thisisstring, string)
(10, string)
(true, bool)
因為存在兩種型別的介面,包含方法的非空介面和不包含任何方法的空介面,所以在底層實作上使用runtime.iface表示非空介面,使用runtime.eface表示空介面 interface{},
在 Go 原始碼中 runtime 包下,我們可以找到 runtime.iface 和 runtime.eface 的定義,
type iface struct { // 16 位元組
tab *itab
data unsafe.Pointer
}
這個結構體中有指向原始資料的指標 data 和 runtime.itab,
runtime.itab 結構體是介面型別的核心組成部分,每一個 runtime.itab 都占 32 位元組,我們可以將其看成介面型別和具體型別的組合,它們分別用 inter 和 _type 兩個欄位表示:
type itab struct { // 32 位元組
inter *interfacetype
_type *_type
hash uint32
_ [4]byte
fun [1]uintptr
}
除了 inter 和 _type 兩個用于表示型別的欄位之外,上述結構體中的另外兩個欄位也有自己的作用:
hash 是對 _type.hash 的拷貝,當我們想將 interface 型別轉換成具體型別時,可以使用該欄位快速判斷目標型別和具體型別 runtime._type 是否一致;
fun 是一個動態大小的陣列,它是一個用于動態派發的虛函式表,存盤了一組函式指標,雖然該變數被宣告成大小固定的陣列,但是在使用時會通過原始指標獲取其中的資料,所以 fun 陣列中保存的元素數量是不確定的,
type eface struct { // 16 位元組
_type *_type
data unsafe.Pointer
}
由于 interface{} 型別不包含任何方法,所以它的結構也相對來說比較簡單,只包含指向底層資料和型別的兩個指標,從上述結構我們也能推斷出 Go 語言的任意型別都可以轉換成 interface{},
其中runtime._type是 Go 語言型別的運行時表示,下面是運行時包中的結構體,其中包含了很多型別的元資訊,例如:型別的大小、哈希、對齊以及種類等,
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldAlign uint8
kind uint8
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte
str nameOff
ptrToThis typeOff
}
size 欄位存盤了型別占用的記憶體空間,為記憶體空間的分配提供資訊;
hash 欄位能夠幫助我們快速確定型別是否相等;
equal 欄位用于判斷當前型別的多個物件是否相等,該欄位是為了減少 Go 語言二進制包大小從 typeAlg 結構體中遷移過來的,
我們只需要對 runtime._type 結構體中的欄位有一個大體的概念,不需要詳細理解所有欄位的作用和意義,
根據上面對介面底層結構的分析,我們可以得出如下結論:
介面底層資料結構包含兩個欄位,相互賦值時如果是相同具體型別不同值并發賦給一個介面,那么只有一個欄位 data 的值是不同的,此時退化成指標的并發賦值,所以是安全的,但如果是不同具體型別的值并發賦給一個介面,那么并引發 panic,
不同具體型別并發賦值介面非安全驗證如下:
func main() {
var g interface{}
var i int
for ; i < 10000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
g = "a"
}()
// 協程 2
wg.Add(1)
go func() {
defer wg.Done()
g = 1
}()
wg.Wait()
// 賦值例外判斷
v1, _ := g.(string)
v2, _ := g.(int)
if !(v1 == "a" || v2 == 1) {
fmt.Printf("concurrent assignment error, i=%v g=%v ", i, g)
break
}
}
if i == 10000000 {
fmt.Println("no error")
}
}
運行輸出:
unexpected fault address 0x1fffffa8
fatal error: fault
[signal 0xc0000005 code=0x0 addr=0x1fffffa8 pc=0x5f5585]
...
把上面的示例代碼中協議 1 中的字串換成一個 int 值,那么并發是安全的,
func main() {
var g interface{}
var i int
for ; i < 10000000; i++ {
var wg sync.WaitGroup
// 協程 1
wg.Add(1)
go func() {
defer wg.Done()
g = 0
}()
// 協程 2
wg.Add(1)
go func() {
defer wg.Done()
g = 1
}()
wg.Wait()
// 賦值例外判斷
v1, _ := g.(int)
v2, _ := g.(int)
if !(v1 == 0 || v2 == 1) {
fmt.Printf("concurrent assignment error, i=%v g=%v ", i, g)
break
}
}
if i == 10000000 {
fmt.Println("no error")
}
}
運行輸出:
no error
5.小結
Go 多協程并發的場景無處不在,并發對同一變數的賦值也是經常遇到,本文嘗試探討了 Go 中所有型別并發賦值的安全性,
(1)由一潭訓器指令完成賦值的型別并發賦值是安全的,這些型別有:位元組型,布爾型、整型、浮點型、字符型、指標、函式,
(2)陣列由一個或多個元素組成,大部分情況并發不安全,注意:當位寬不大于 64 位且是 2 的整數次冪(8,16,32,64),那么其并發賦值是安全的,
(3)struct 或底層是 struct 的型別并發賦值大部分情況并發不安全,這些型別有:復數、字串、 陣列、切片、字典、通道、介面,注意:當 struct 賦值時退化為單個欄位由一個機器指令完成賦值時,并發賦值又是安全的,這種情況有:
(a)實部或虛部相同的復數的并發賦值;
(b)等長字串的并發賦值;
(c)同長度同容量切片的并發賦值;
(d)同一種具體型別不同值并發賦給介面,
注意: 以上結論基于x86-64指令集架構,其他平臺未作實際驗證,
參考文獻
[1] 簡書.Golang并發操作變數需要注意的問題
[2] All data types in Golang with examples
[3] The Go Blog.Go Slices: usage and internals
[4] CSDN.Golang map 三板斧第三式:實作原理
[5] CSDN.Golang channel 快速入門
[6] Go 語言設計與實作.4.2介面
轉載請註明出處,本文鏈接:https://www.uj5u.com/qukuanlian/272550.html
標籤:區塊鏈
