《快學 Go 語言》第 13 課 —— 併發與安全

碼洞發表於2018-12-14

上一節我們提到併發程式設計不同的協程共享資料的方式除了通道之外還有就是共享變數。雖然 Go 語言官方推薦使用通道的方式來共享資料,但是通過變數來共享才是基礎,因為通道在底層也是通過共享變數的方式來實現的。通道的內部資料結構包含一個陣列,對通道的讀寫就是對內部陣列的讀寫。

在併發環境下共享讀寫變數必須要使用鎖來控制資料結構的安全,Go 語言內建了 sync 包,裡面包含了我們平時需要經常使用的互斥鎖物件 sync.Mutex。Go 語言內建的字典不是執行緒安全的,所以下面我們嘗試使用互斥鎖物件來保護字典,讓它變成執行緒安全的字典。

執行緒不安全的字典

Go 語言內建了資料結構「競態檢查」工具來幫我們檢查程式中是否存線上程不安全的程式碼。當我們在執行程式碼時,開啟 -run 開關,程式就會在內建的通用資料結構中進行埋點檢查。競態檢查工具在 Go 1.1 版本中引入,該功能幫助 Go 語言「元團隊」找出了 Go 語言標準庫中幾十個存線上程安全隱患的 bug,這是一個非常了不起的功能。同時這也說明了即使是猿界的神仙,寫出來的程式碼也避免不了有 bug。下面我們來嘗試一下

package main

import "fmt"

func write(d map[string]int) {
	d["fruit"] = 2
}

func read(d map[string]int) {
	fmt.Println(d["fruit"])
}

func main() {
	d := map[string]int{}
	go read(d)
	write(d)
}
複製程式碼

上面的程式碼明視訊記憶體在安全隱患,執行下面的競態檢查指令觀察輸出結果

$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c420090180 by goroutine 6:
  runtime.mapaccess1_faststr()     
  /usr/local/Cellar/go/1.10.3/libexec/src/runtime/hashmap_fast.go:172 +0x0
  main.read()
      ~/go/src/github.com/pyloque/practice/main.go:10 +0x5d

Previous write at 0x00c420090180 by main goroutine:
  runtime.mapassign_faststr()
/usr/local/Cellar/go/1.10.3/libexec/src/runtime/hashmap_fast.go:694 +0x0
  main.main()
      ~/go/src/github.com/pyloque/practice/main.go:6 +0x88

Goroutine 6 (running) created at:
  main.main()
      ~/go/src/github.com/pyloque/practice/main.go:15 +0x59
==================
==================
WARNING: DATA RACE
Read at 0x00c4200927d8 by goroutine 6:
  main.read()
      ~/go/src/github.com/pyloque/practice/main.go:10 +0x70

Previous write at 0x00c4200927d8 by main goroutine:
  main.main()
      ~/go/src/github.com/pyloque/practice/main.go:6 +0x9b

Goroutine 6 (running) created at:
  main.main()
      ~/go/src/github.com/pyloque/practice/main.go:15 +0x59
==================
2
Found 2 data race(s)
複製程式碼

競態檢查工具是基於執行時程式碼檢查,而不是通過程式碼靜態分析來完成的。這意味著那些沒有機會執行到的程式碼邏輯中如果存在安全隱患,它是檢查不出來的。

執行緒安全的字典

讓字典變的執行緒安全,就需要對字典的所有讀寫操作都使用互斥鎖保護起來。

package main

import "fmt"
import "sync"

type SafeDict struct {
	data  map[string]int
	mutex *sync.Mutex
}

func NewSafeDict(data map[string]int) *SafeDict {
	return &SafeDict{
		data:  data,
		mutex: &sync.Mutex{},
	}
}

func (d *SafeDict) Len() int {
	d.mutex.Lock()
	defer d.mutex.Unlock()
	return len(d.data)
}

func (d *SafeDict) Put(key string, value int) (int, bool) {
	d.mutex.Lock()
	defer d.mutex.Unlock()
	old_value, ok := d.data[key]
	d.data[key] = value
	return old_value, ok
}

func (d *SafeDict) Get(key string) (int, bool) {
	d.mutex.Lock()
	defer d.mutex.Unlock()
	old_value, ok := d.data[key]
	return old_value, ok
}

func (d *SafeDict) Delete(key string) (int, bool) {
	d.mutex.Lock()
	defer d.mutex.Unlock()
	old_value, ok := d.data[key]
	if ok {
		delete(d.data, key)
	}
	return old_value, ok
}

func write(d *SafeDict) {
	d.Put("banana", 5)
}

func read(d *SafeDict) {
	fmt.Println(d.Get("banana"))
}

func main() {
	d := NewSafeDict(map[string]int{
		"apple": 2,
		"pear"3,
	})
	go read(d)
	write(d)
}
複製程式碼

嘗試使用競態檢查工具執行上面的程式碼,會發現沒有了剛才一連串的警告輸出,說明 Get 和 Put 方法已經做到了協程安全,但是還不能說明 Delete() 方法是否安全,因為它根本沒有機會得到執行。

在上面的程式碼中我們再次看到了 defer 語句的應用場景 —— 釋放鎖。defer 語句總是要推遲到函式尾部執行,所以如果函式邏輯執行時間比較長,這會導致鎖持有的時間較長,這時使用 defer 語句來釋放鎖未必是一個好注意。

避免鎖複製

上面的程式碼中還有一個需要特別注意的地方是 sync.Mutex 是一個結構體物件,這個物件在使用的過程中要避免被複制 —— 淺拷貝。複製會導致鎖被「分裂」了,也就起不到保護的作用。所以在平時的使用中要儘量使用它的指標型別。讀者可以嘗試將上面的型別換成非指標型別,然後執行一下競態檢查工具,會看到警告資訊再次佈滿整個螢幕。鎖複製存在於結構體變數的賦值、函式引數傳遞、方法引數傳遞中,都需要注意。

使用匿名鎖欄位

在結構體章節,我們知道外部結構體可以自動繼承匿名內部結構體的所有方法。如果將上面的 SafeDict 結構體進行改造,將鎖欄位匿名,就可以稍微簡化一下程式碼。

package main

import "fmt"
import "sync"

type SafeDict struct {
	data  map[string]int
	*sync.Mutex
}

func NewSafeDict(data map[string]int) *SafeDict {
	return &SafeDict{data, &sync.Mutex{}}
}

func (d *SafeDict) Len() int {
	d.Lock()
	defer d.Unlock()
	return len(d.data)
}

func (d *SafeDict) Put(key string, value int) (int, bool) {
	d.Lock()
	defer d.Unlock()
	old_value, ok := d.data[key]
	d.data[key] = value
	return old_value, ok
}

func (d *SafeDict) Get(key string) (int, bool) {
	d.Lock()
	defer d.Unlock()
	old_value, ok := d.data[key]
	return old_value, ok
}

func (d *SafeDict) Delete(key string) (int, bool) {
	d.Lock()
	defer d.Unlock()
	old_value, ok := d.data[key]
	if ok {
		delete(d.data, key)
	}
	return old_value, ok
}

func write(d *SafeDict) {
	d.Put("banana", 5)
}

func read(d *SafeDict) {
	fmt.Println(d.Get("banana"))
}

func main() {
	d := NewSafeDict(map[string]int{
		"apple": 2,
		"pear"3,
	})
	go read(d)
	write(d)
}
複製程式碼

使用讀寫鎖

日常應用中,大多數併發資料結構都是讀多寫少的,對於讀多寫少的場合,可以將互斥鎖換成讀寫鎖,可以有效提升效能。sync 包也提供了讀寫鎖物件 RWMutex,不同於互斥鎖只有兩個常用方法 Lock() 和 Unlock(),讀寫鎖提供了四個常用方法,分別是寫加鎖 Lock()、寫釋放鎖 Unlock()、讀加鎖 RLock() 和讀釋放鎖 RUnlock()。寫鎖是排他鎖,加寫鎖時會阻塞其它協程再加讀鎖和寫鎖,讀鎖是共享鎖,加讀鎖還可以允許其它協程再加讀鎖,但是會阻塞加寫鎖。

讀寫鎖在寫併發高的情況下效能退化為普通的互斥鎖。下面我們將程式碼中 SafeDict 的互斥鎖改造成讀寫鎖。

package main

import "fmt"
import "sync"

type SafeDict struct {
	data  map[string]int
	*sync.RWMutex
}

func NewSafeDict(data map[string]int) *SafeDict {
	return &SafeDict{data, &sync.RWMutex{}}
}

func (d *SafeDict) Len() int {
	d.RLock()
	defer d.RUnlock()
	return len(d.data)
}

func (d *SafeDict) Put(key string, value int) (int, bool) {
	d.Lock()
	defer d.Unlock()
	old_value, ok := d.data[key]
	d.data[key] = value
	return old_value, ok
}

func (d *SafeDict) Get(key string) (int, bool) {
	d.RLock()
	defer d.RUnlock()
	old_value, ok := d.data[key]
	return old_value, ok
}

func (d *SafeDict) Delete(key string) (int, bool) {
	d.Lock()
	defer d.Unlock()
	old_value, ok := d.data[key]
	if ok {
		delete(d.data, key)
	}
	return old_value, ok
}

func write(d *SafeDict) {
	d.Put("banana", 5)
}

func read(d *SafeDict) {
	fmt.Println(d.Get("banana"))
}

func main() {
	d := NewSafeDict(map[string]int{
		"apple": 2,
		"pear"3,
	})
	go read(d)
	write(d)
}
複製程式碼

下一節我們要開始嘗試 Go 語言學習的難點之一 —— 反射。

《快學 Go 語言》第 13 課 —— 併發與安全

閱讀《快學 Go 語言》更多章節,長按圖片識別二維碼關注公眾號「碼洞」

相關文章