Go 語言基礎系列(十):併發與通道

通過前面的學習,我們已經掌握了 Golang 語言的基本概念,而且已經可以進行一些功能開發。前面 Golang 語言的學習路線和思維邏輯,也適應於其他高級語言的學習方式

這一篇我們終於來到了 Golang 語言的高級特性:併發優勢。Golang 語言是從語言的層面來滿足併發需求,解決併發問題的。什麼是語言層面支持呢?通俗的講:就是語言在語法層面對併發的開啓及併發的信息共享進行了支持,而不是通過調用標準庫或者對操作系統的接口進行封裝的第三方庫,進而支持併發。Golang 語言是通過實現協程調度器在語言層面解決了併發編程相關的邏輯,以及內存管理等複雜的操作,這樣就使得併發程序的編寫非常容易和方便,開發人員就可以專注於業務開發

在 Golang 中併發是通過協程(goroutine)和通道(channel)實現的,所以本篇我們圍繞這兩個概念展開介紹

思維邏輯圖如下:

下面我們從以上四個方面對 **併發 **進行相應的介紹

**   協程 goroutine  **

什麼是協程?goroutine 是比線程更輕量化的協程

在操作系統中具有進程和線程的概念。一個進程可以具有一個或多個線程,而線程之間共享所在進程的內存空間。線程的調度是通過操作系統來維護的,發生在內核中,線程在被調度時會進行上下文的切換,如果線程數增多,那麼線程間切換會帶來很大的 CPU 消耗

而協程則是發生在用戶態,Golang 語言通過協程調度器實現了協程的調度切換,這樣使得不需要經過內核態,不需要進行繁瑣的內核上下文切換,進而使得消耗降低。這也是 Golang 的併發優勢所在。同樣的,由於協程發生在用戶態一側,當進行垃圾回收時,無需操作系統中的其他線程暫停,進一步提高了效能

協程創建

Golang 中,協程的創建是通過關鍵字 go 來實現的,格式如下:

go 函數名 (函數入參)

**注:**1. 是否有函數入參,取決於該函數

      2. 細心的同學可能已經發現,那就是假如我這個函數有返回值怎麼辦?沒錯,由於在創建協程的時候,並沒有相關的返回值保存工作,所以當創建開啓協程時,會將函數的返回值忽略,此時,要想使得協程和其他協程通信,比如主協程(main 函數是主協程,啓動程序時,便會運行在一個 goroutine 上),只能通過 channel 來實現

示例如下:

分析:上例中,我們創建了兩個協程,第一個協程中的函數 goroutine1 沒有入參並且沒有返回值,第二個 goroutine2 有入參並且具有返回值,它們分別通過 go 關鍵字開啓協程。先看一下結果:

說明:1. 從結果來看,雖然 goroutine2 是先於 goroutine1 執行的,而且執行完了 goroutine2 纔開始執行 goroutine1,但是這是一個假的表面現象,這是因爲我們 for 循環執行次數太少,cpu 執行太快,還沒等切換就執行完成,實際上應該是一個無序的狀態,也就是說它們誰先執行是不確定的,而且執行過程中也會相互之間有亂序,大家可以增大 for 循環的次數進行試驗

           2. 我們發現 goroutine2 的返回值被忽略了,一會兒我們通過 channel 對其返回值進行接收

           3. 上例中 main 協程中,有一行代碼如下,

time.Sleep(2 * time.Second)

之所以需要加上這行代碼:是因爲其他一切創建開啓的協程,都會在 main 主協程結束時結束。這就意味着:如果我們想讓其他協程執行完成,則須保持主協程執行中,所以我們讓主協程等待一會兒

結論:通過上面一個簡單的例子,相信大家對協程的創建的簡單易操作會有一個直觀的感受,這便是由於語言層面的支持使得關於併發的線程操作都隱藏於 golang 的調度器之中,調度器會將我們的協程安排到合適的操作系統線程上運行

協程操作

上面我們通過 go 關鍵字非常方便的開啓了協程,那麼是不是想對其進行一些操作呢?比如讓協程暫停,退出之類的操作。下面我們便對此進行介紹

**1)**runtime.Gosched()

我們還是以上文中的例子爲例,但是做一些小的改動:

再次執行,結果如下:

分析:我們發現只有 main 主協程執行,而另外兩個協程均沒有執行,這是因爲 main 協程在獲得 cpu 執行權之後執行完便退出了,所以另外兩個協程還未執行。**那麼是否可以讓 main 協程等一會兒再獲得 cpu 執行權呢?**也就是讓另外兩個協程先獲取執行權執行呢?

我們再做如下的改動:增加了一行代碼:runtime.Gosched()

結果如下:

分析:從結果來看,我們發現另外兩個協程均執行了,達到了我們的目的,其次我們同樣發現 main 協程確實是在等了一會兒又開始執行了,也就是又獲得了 cpu 的執行權

那麼此時相信大家已經知道了 runtime.Gosched() 函數的作用了,我們先看一下源碼

上面的源碼翻譯過來便是:Gosched 會使當前協程暫時放棄 cpu 執行權,讓其他協程先執行,之後會重新自動獲得執行權

**2)**runtime.Goexit()

我們在剛剛改動的基礎上,再做一點小的改動:增加一行 runtime.Goexit()

結果如下:

分析:從結果來看,我們發現 goroutine1 這個協程並沒有打印,也就是並未執行,而不影響另外兩個協程的執行,相信看到這裏大家就明白了 runtime.Goexit() 的作用,同樣的,我們先看一下源碼

源碼中的說明翻譯過來即是:Goexit 可以中止當前協程的執行,但是不影響其他協程的正常執行。說明中還提到:在當前協程中止之前,會執行所有的 defer 後的內容,我們做個小測試:

結果如下:

**   通道 channel  **

前面我們提到:到創建開啓協程後,函數的返回值會被忽略,協程之間的通信只能通過通道來完成,下面我們開始通道的學習

a. 通道

聲明,格式如下:

var 通道名 chan 數據類型

創建,格式如下:

通道名 := make(chan 通道數據類型,通道容量)

注:1. 當通道容量爲 0 時,可簡寫爲:make(chan 通道數據類型);

  2. 通道容量爲 0 即爲無緩衝通道;通道容量大於 0 即爲有緩衝通道;

b. 通道數據填充

通道名 <- 數據

c. 通道數據讀取

1)通道數據接收變量 := <- 通道名

2)通道數據接收變量,ok := <- 通道名,可通過 bool 類型 ok 的值判別通道是否關閉,false 爲已關閉

下面我們通過一個示例,對前文示例中的 goroutine2 的返回值進行讀取

結果如下:

分析:本例中我們將 goroutine2 函數通過匿名函數的方式開啓協程,之前的文章中我們有介紹過匿名函數的相關內容,所以不再做過多介紹。同時,我們通過創建一個無緩衝通道,實現了協程之間的通信,讀取到了 goroutine2 的返回的數據

無緩衝通道

無緩衝通道是指沒有存儲能力的通道,當對通道進行數據填充後,必須等到數據被讀取後才能再次填充。下面我們通過一個直觀的例子進行闡述無緩衝通道的性質

結果如下:

分析:可見,在整個運行過程中,chan 的長度和容量始終爲 0,也就是 chan 並沒有存儲任何的值,雖然是處於 for 循環中,但是仍然是通過阻塞的方式,在同時間內將數據在接收和發送間進行交換,所以無緩衝通道可保證數據交換的同時性

帶緩衝通道

在上例的基礎上,我們對 channel 的容量進行改變

結果如下:

分析:在本例中,在創建的協程中,會先將數據填充到 channel 中,然後再從 channel 中將數據進行讀取,可見 channel 具有數據的存儲能力。而且是非阻塞式執行。只有當寫入的數據超過 channel 的容量時纔會阻塞發送,此外,只有沒有要讀取的數據時纔會接收阻塞

單向通道

上文中我們對通道的操作都是基於雙向通道,即通道均既可以接收數據,又可以發送數據,這也是 Golang 語言 channel 的默認情況。但是有時候,我們還是有一些需要:滿足單向的接收或者發送數據。比如生產者 - 消費者模型,生產者負責發送數據,消費者負責接收數據,這便是單向通道的使用,下面我們以這一模型爲例,對單向通道進行介紹

單向發送通道:

聲明:

var 單向通道名 chan<- 數據類型

創建及初始化:

單向通道名 := make(chan<- 數據類型)

單向接收通道:

聲明:

var 單向通道名 <-chan 數據類型

創建及初始化

單向通道名 := make(<-chan 數據類型)

示例:

結果如下:

分析:示例中,我們在 producer 中維護了一個接收數據的 channel,在 consumer 中維護了一個發送數據的 channel;從結果可以看出:數據在 producer 中生產,在 consumer 被消費;除此之外,本示例中還用到了 close 內置函數來關閉 channel,還有 range 關鍵字讀取 channel 內的內容,下面我們對其做簡要的介紹

通道操作

1)通道關閉

關閉通道通過調用內置的 close 函數實現。當通道被關閉後,便無法再填充數據,否則會觸發 panic;但是,已關閉的通道,還是繼續可以從其中讀取數據

在上例中,我們之所以需要關閉通道,是因爲當 producer 退出 for 循環後協程退出,沒有顯示的關閉 channel,那麼 consumer 中 for range 便不會結束,所以 main 協程阻塞便會形成死鎖,如下:

結果如下:

2)通道數據讀取

通道數據讀取可以通過 range 關鍵字完成的,在本系列第四篇關於內置容器的介紹中,數組、切片和映射均可以通過 range 實現便利,對於通道同樣可以通過 range 完成遍歷,但是當使用 range 讀取數據時,應對 channel 進行顯示的關閉操作(close)。關於 range 關鍵字我們不再做過多介紹

除此之外,還可以通過關鍵字 select 完成通道數據的讀取

**   監聽 select  **

select 關鍵字可以實現對 channel 數據的監聽,並且可以通過 select 和 for 循環完成通道數據的讀取

格式如下:

**select {
**

case <- 通道名 1 :

**        // 執行相關操作
**

case 通道名 2 <- 通道數據

**        // 執行相關操作**

**default:
**

**        // 執行相關操作
**

}

我們通過一個示例進行學習

結果如下:

分析:示例中,我們開啓一個新的協程實現監聽功能,並帶有超時機制,然後在 main 協程裏對其監聽的通道 1 填充數據;當新開啓的協程不再獲得數據並超時後,對通道 2 填充數據,main 協程接收到後退出結束。同時,通過 select 讀取數據,無需顯示的關閉通道

**   死鎖  **

上文中,我們已經接觸過死鎖的概念。具體來說,死鎖是指併發的線程或者進程在獲取資源時造成的彼此均處於等待資源釋放狀態的現象。在這裏我們再通過一個簡單的例子,幫助大家進一步認識死鎖的概念

示例:

結果如下:

分析:這是一個初學者經常容易犯錯的地方,創建一個無緩衝 channel,然後對其填充數據,然後再讀取數據,好像沒什麼問題?爲什麼會死鎖呢?對於無緩衝通道如果只進行了數據的流入無流出,便會死鎖。在本例中,ch1<- 10 只有數據的流入沒有流出,那麼怎麼流出呢?開啓新的協程對 channel 數據進行讀取

這樣便解除了死鎖。同樣的,當無緩衝通道只出現數據的流出無流入時,也會發生死鎖,我們就不再舉例說明,大家可以自己嘗試一下。

到此關於 Golang 併發與通道相關內容的分享就結束了~

此篇爲 Go 語言基礎系列分享第十篇,歡迎大家關注本公衆號**《Go 開發與計算機技術》**,Go 語言基礎系列學習不迷路。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7KM-WPXmExF2fR0_O3c9_Q