學習go語言編程之併發編程

併發基礎

併發包含如下幾種主流的實現模型:

  • 多進程
  • 多線程
  • 基於回到的非阻塞/異步IO
  • 協程

協程

與傳統的系統級線程和進程相比,協程最大的優勢在於“輕量級”,可以輕鬆創建上百萬個而不會導致系統資源枯竭,而線程和進程通常最多不超過1萬個。
Golang在語言級別支持協程,叫goroutine

goroutine

goroutine是Golang中輕量級線程的實現,由Go運行時管理,使用go關鍵字來觸發一個新的goroutine執行。
具體來說,在一個函數調用前加上關鍵字go,這次調用就會在一個新的goroutine中併發執行。
當被調用的函數返回時,這個goroutine也自動結束了。需要注意的是:如果這個函數有返回值,那麼這個返回值會被丟棄。

func Add(a, b int) {
	z := a + b
	fmt.Println("z=", z)
}

func main() {
	for i := 0; i < 10; i++ {
		go Add(1, 1) // 在函數調用前使用關鍵字go,使得函數的調用是在goroutine中執行
	}
}

上述代碼演示瞭如何在Golang中使用goroutine。

但是上述代碼運行時並沒有任何輸出!原因:Go程序從初始化main package並執行main()函數開始,當main()函數返回時,程序退出,且程序並不會等待其他goroutine(非主goroutine)結束。

併發通信

在工程上,有2種最常見的併發通信模型:共享數據和消息。
被共享的數據可能有多種形式,如:內存數據塊,磁盤文件,網絡數據等。
如果是通過共享內存來實現併發通信,那就只能使用鎖了。
Golang以併發編程作爲語言的最核心優勢,提供了另一種通信模型,即:以消息機制而非共享內存作爲併發通信方式。
Golang提供的消息機制被稱爲channel。

channel

channel是Golang在語言級別提供的goroutine間通信方式,可以使用channel在兩個或多個goroutine之間傳遞消息。
channel是進程內的通信方式,因此通過channel傳遞對象的過程和調用函數時的參數傳遞行爲比較一致,比如也可以傳遞指針等。
channel是類型相關的,即:一個channel只能傳遞一種類型的值,這個類型需要在聲明channel時指定。

基本語法

一般channel的聲明形式爲:

// 與聲明一般變量的不同在於需要在類型前面加了關鍵字chan
// ElementType指定這個channel所能傳遞的元素類型
var chanName chan ElementType

示例:

// 聲明一個傳遞類型爲int的channel
var ch chan int

// 聲明一個map,元素類型爲bool的channel,即:這個channel傳遞的元素類型爲map,map的值類型爲bool
var m map[string] chan bool

定義一個channel也很簡單,使用內置的函數make()即可:

// 聲明並初始化了一個傳遞類型爲int的channel
ch := make(chan int)

在channel的用法中,最常見的包括寫入和讀取。
將一個數據寫入channel的語法:ch <- value,向channel寫入數據通常會導致程序阻塞,直到有其他goroutine從這個channle中讀取數據。
從channel中讀取數據的語法是:value := <- ch,如果channel之前沒有寫入數據,那麼從channel讀取數據也會導致程序阻塞,直到channel中被寫入數據爲止。

select

Golang在語言級別支持select關鍵字,用於處理異步IO問題。
select與用法結構如下:

select {
case <-ch1:
    // 如果從ch1成功讀取到數據,執行該case處理語句
case ch2 <- 1:
    // 如果成功向ch2寫入數據,執行該case處理語句
default:
    // 如果上面都沒有成功,則進入default處理流程
}

select的用法中,要求:每個case語句都必須是一個面向channel的操作。

如下是基於select的一段有趣的代碼:

c := 0
ch := make(chan int, 1)
for {
    // 使用select隨機向ch中寫入0或1
    select {
    case ch <- 0:
    case ch <- 1:
    }

    i := <-ch
    fmt.Println("Received: ", i)

    c++
    if c > 10 {
        break
    }
}

緩衝機制

不帶緩衝的channel,對於傳遞單個數據的場景可以接受,但是對於需要傳遞大量數據的場景就不合適了。
創建一個帶緩衝的channel:

// 在調用make()時將緩衝區大小作爲第二個參數傳入即可
c := make(chan int, 1024)

帶緩衝區的channel即使沒有讀取方,寫入方也可以一直往channel中寫入數據,在緩衝區填滿之前都不會阻塞。

從帶緩衝區的channel中讀取數據可以使用與常規非緩衝channel完全一致的方法,但是也可以使用range關鍵字來實現更簡便的循環讀取。

// 使用range關鍵字來實現帶緩衝區channel的循環讀取
for v := range ch {
    fmt.Println("Received:", v)
}

超時機制

如果不能很好地處理超時問題,可能會導致goroutine永遠阻塞而沒有挽回的機會!
Golang中沒有提供直接的超時處理機制,但是可以使用select很方便地解決超時問題(因爲select的特點是隻要其中一個case已經完成,程序就會繼續往下執行,而不會考慮其他case的情況)。

ch := make(chan int, 1024)

// 首先,實現並執行一個匿名的超時等待函數
timeout := make(chan bool, 1)
go func() {
    time.Sleep(1e9) // 等待一秒鐘
    timeout <- true
}()

// 然後,把timeout這個channel利用起來
select {
case <-ch:
    // 從目標channel中讀取數據
case <-timeout:
    // 如果從目標channel中一直沒有讀取到數據,但是從timeout這個channel上讀取到了數據
    // 這樣就使用select機制可以避免永久等待的問題
    // 這是在Golang開發中避免channel通信超時的最有效辦法
}

channel的傳遞

在Golang中channel本身也是一種原生類型,與map之類的類型地位一樣,因此channel本身在定義後也可以通過channel來傳遞。
可以使用這個特性來實現管道,管道也是使用非常廣泛的一種設計模式。

type PipeData struct {
	value   int
	handler func(int) int
	next    chan int
}

首先限定一個基本的數據結構PipeData,然後寫一個常規的處理函數。只要定義一系列PipeData的數據結構並一起傳遞給這個函數,就可以達到流式處理數據的目的。

func handle(queue chan *PipeData) {
	for data := range queue {
		data.next <- data.handler(data.value)
	}
}

單向channel

單向channel只能用於發送或接收數據。
可以在將一個channel變量傳遞給一個函數時,通過指定其爲單向channel變量,從而限制在該函數中可以對此channel執行的操作,比如只能往這個channel寫,或者只能從這個channel讀。

單向channel的聲明非常簡單,如下:

var ch1 chan int       // ch1是一個正常的channel,不是單向的
var ch2 chan<- float64 // ch2是一個用於只寫float64數據單項channel
var ch3 <-chan int     // ch3是一個用於只讀int數據的channel

單向channel的初始化:

ch4 := make(chan int)
ch5 := <-chan int(ch4) // ch5是一個單向讀取的channel
ch6 := chan<- int(ch4) // ch6是一個單向寫入的channel

如上,基於一個正常的channel可以實現單向channel的初始化。
即類型轉換對於channel的意義:在單向channel和雙向channel之間進行轉換。

使用單向channel可以起到一種契約的作用:

func parse(ch <-chan int) {
	for value := range ch {
		fmt.Println("Received:", value)
	}
}

如上,除非這個函數的實現者使用了類型轉換,否則這個函數就不會因爲各種原因而對ch變量執行寫操作,因而避免在ch中出現非期望的數據,從而很好地實踐最小權限原則。

關閉channel

使用內置函數close()關閉channel。

close(ch)

如何判斷一個channel是否已經關閉?可以通過在讀取的時候使用多重返回值進行判斷:

// 使用多重返回值檢查channel是否已經關閉
val, ok := <-ch
if ok {
    // channel未關閉,可以正常使用返回值
    fmt.Println("Received:", val)
}

多核並行化

多核並行化是指儘量利用CPU多核特性來將任務並行化執行。
具體到Golang中,就是要知道CPU核心的數量,並針對性地將計算任務分解到多個goroutine中並行運行。

// 獲取CPU核心數量
runtime.NumCPU()

出讓時間片

使用runtime.Gosched()在每個goroutine中控制何時主動出讓時間片給其他goroutine。

同步

同步鎖

Golang的sync包中提供了兩種鎖類型:sync.Mutexsync.RWMutex
Mutex是最簡單的鎖類型,同時也比較暴力,當一個goroutine獲得Mutex後,其他goroutine就只能等待這個goroutine釋放該Mutex
RWMutex相對友好,是經典的單寫多讀模型。在讀鎖佔用的情況下,會阻止寫,但不阻止讀。也就是多個goroutine可同時獲取讀鎖,而寫鎖會阻止任何其他goroutine進來,整個鎖相當於由該goroutine獨佔。獲取讀鎖:sync.RWMutex.RLock(),獲取寫鎖:sync.RWMutex.Lock()

對於這兩種鎖類型,任何一個Lock()RLock()均需要保證對應有Unlock()RUnlock()調用與之對應,否則可能導致等待該鎖的所有goroutine處於飢餓狀態,甚至可能導致死鎖。
鎖的典型使用模式如下:

// 先聲明一個鎖
var lock sync.Mutex
func foo() {
	lock.Lock()
	defer lock.Unlock() // defer關鍵字的方便之處
	// 獲得鎖之後需要執行的操作
}

全局唯一性操作

對於從全局的角度只需要運行一次的代碼,比如全局初始化,Golang提供了一個Once類型來保證全局的唯一性操作。

var a string
var once sync.Once

func setup() {
	a = "Hello, World!"
	fmt.Println("初始化a")
}

func doPrint() {
	once.Do(setup) // 使用Once來控制函數在全局角度只會執行一次
	fmt.Println(a)
}

func twoPrint() {
	go doPrint()
	go doPrint()
}

如上示例代碼,onceDo()方法可以保證在全局範圍內只調用指定的函數一次,而且其他所有goroutine在調用到此語句時,將會先被阻塞,直到全局唯一的once.Do()調用結束之後才繼續。

原子性操作

如果Golang中沒有提供Once類型來保證全局唯一性操作,對於那些需要控制在全局只執行一次的操作來說,只能通過別的辦法來處理了。

// 設置一個全局變量表示初始化操作是否完畢
var done bool = false

func setup() {
	a = "Hello, World!"
	done = true
	fmt.Println("初始化a")
}

func doPrint() {
	if !done {
		setup()
	}
	fmt.Println(a)
}

這段代碼看起來合理,但是細看還是會有問題,因爲setup()並不是一個原子性操作。這種寫法可能會導致setup()被調用多次,從而無法達到全局只執行一次的目標。

爲了更好地控制並行中的原子性操作,sync包中還包含了一個atomic子包,它提供了對於一些基礎數據類型的原子操作函數。

// 比較和交換2個uint64類型數據
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)

有了這些原子操作函數,開發者就無需再爲這樣的操作專門添加Lock控制。

總結

關於Golang中併發編程有如下總結。

1.核心內容:協程
2.重要的關鍵字:changoselectdefer

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章