走進Golang之Channel的數據結構

2021-02-21 大愚Talk

上篇文章講了 channel 的基本使用,講了一些使用時需要注意的事項,本文將重點介紹 channel 中的兩個數據結構:循環隊列雙端鍊表

channel 的需求描述

為了理解這些數據結構解決了什麼問題,我們先來做個簡單的回顧,看看為什麼需要這兩個數據結構,他們解決了什麼問題。我們知道 goroutine 是用戶態的線程,不同的 goroutine 之間是有消息傳遞這個需求的。在原始的進程與線程(系統線程)編程中我們會採用管道的方式,而 channel 就是用戶態線程傳遞消息的管道實現,並且是類型安全的。

既然 channel 是一個管道,用來滿足不同 goroutine 間交換消息的。那麼實現這樣一個管道要怎麼做呢?

來看看我們日常傳遞消息的需求:

能夠有多個 goroutine 向 channel 進行讀寫,並且保證沒有競爭問題,需要用 隊列 來管理阻塞的 goroutine,解決競爭問題;需要管理髮送到 channel 的消息(有緩衝、無緩衝),對於有緩存的 channel 可以採用 循環隊列 來管理多個消息。

當然上面的需求是經過簡化的,比如 channel 還需要具備阻塞、喚醒 goroutine 的能力,不過為了本文我們更加專注焦點問題,先只關註上面兩個問題。

channel 的數據結構

接下來我們分析一下 channel 在實際運行中,它的結構體是怎麼樣的。當然這又分為兩種類型,有緩衝與無緩衝的。我們先來看一個無緩衝的情況。

無緩衝

先把示例代碼貼出來。就是兩個讀的 goroutine 被阻塞在一個無緩衝的 channel 上。

func main() {
 ch := make(chan int) // 無緩衝

 go goRoutineA(ch)
 go goRoutineB(ch)
 ch <- 1

 time.Sleep(time.Second * 1)
}

func goRoutineA(ch chan int) {
 v := <-ch
 fmt.Printf("A received data: %d\n", v)
}

func goRoutineB(ch chan int) {
 v := <-ch
 fmt.Printf("B received data: %d\n", v)
}

來看看當代碼執行到 ch <- 1 這一行之後 channel 的結構體被填充成什麼樣子了!

無緩衝 channel

注意其中 buf 欄位可存儲的長度是0,這是因為 無緩衝 channel 不會用到循環隊列來存儲數據。它一定是等讀、寫 goroutine 都準備好了,然後直接把數據交給對方。我們用一副圖來看一下無緩衝的數據交換過程。

交換數據

上圖描述的是數據交換過程,再看一下讀 goroutine 被阻塞的結構示意圖。被阻塞的 goroutine 會掛載到對應的隊列上,該隊列是一個雙端隊列。

上面的例子,由於兩個讀 goroutine 在啟動的時候,寫還沒有準備好,因此讀全部被掛起在隊列中;當有寫goroutine準備好的時候,由於此時讀已經就緒,因此寫不會阻塞,掛起放到 sendq 中。大家可以修改上面的代碼,自己看一下寫阻塞,讀立馬執行的情況。

結構示例有緩衝

我們將上面的代碼改成有緩衝的通道,然後再來看看有緩衝的情況。

func main() {
 ch := make(chan int, 3) // 有緩衝

 // 都不會阻塞
 ch <- 1
 ch <- 2
    ch <- 3
    // 會阻塞,被掛起到 sendq 中
 go func() {
  ch <- 4
 }()

 // 只是為了debug
 var a int
 fmt.Println(a)

 go goRoutineA(ch)
 go goRoutineA(ch)
 go goRoutineB(ch)
 go goRoutineB(ch) // 猜猜這裡會被掛起嗎?

 time.Sleep(time.Second * 2)
}

func goRoutineA(ch chan int) {
 v := <-ch
 fmt.Printf("A received data: %d\n", v)
}

func goRoutineB(ch chan int) {
 v := <-ch
 fmt.Printf("B received data: %d\n", v)
}

貼出執行到第一行的 go goRoutineA(ch) 時, hchan 的結構填充情況。

有緩衝 channel

在這裡可以看到緩衝的大小是3,由於增加了緩衝,只要寫 goroutine 沒有把緩衝寫滿,則不會導致協程阻塞。但是一旦緩衝沒有多餘的空間,則會把寫 goroutine 掛起到 sendq 中,直到有空間時將他喚醒(還有其它喚醒的場景,這一略過)。

其實有緩衝的 channel,就是把同步的通信變為了異步的通信。寫的 channel 不需要關注讀 channel,只要有空間它就寫;而讀也一樣,只要有數據就正常讀就可以,如果沒有就掛起到隊列中,等待被喚醒。下圖形象的展示了有緩衝 channel 是如何交換數據的。

交換數據

我們再來用圖的形式看一下此時結構體的樣子,這裡圖有些偷懶,只是在上面圖的基礎上增加了循環隊列部分的描述,實際到該例子中,讀 goroutine時不會被阻塞的,看的時候需要注意這一點。

結構示例循環隊列

今天最重要的是理解 channel 中兩個關鍵的數據結構。為了下一講閱讀源碼做準備,我把 channel 中的循環隊列部分的代碼抽象出來了。

// 隊列滿了
var ErrQFull = errors.New("circular is full")

// 沒有值
var ErrQEmpty = errors.New("circular is empty")

// 定義循環隊列
// 如何確定隊空,還是隊滿?q.sendx = (q.sendx+1) % q.dataqsiz
type queue struct {
 buf      []int // 隊列元素存儲
 dataqsiz uint  // circular 隊列長度
 qcount   uint  // 有多少元素在buf中 qcount = len(buf)
 sendx    uint  // 可以理解為隊尾指針,向隊列寫入數據
 recvx    uint  // 可以理解為隊頭指針,從隊列讀取數據
}

func makeQ(size int) *queue {
 q := &queue{
  dataqsiz: uint(size),
  buf:      nil,
 }

 q.buf = make([]int, q.dataqsiz)

 return q
}

// 向buf中寫入數據
// 請看 chansend 函數
func (c *queue) insert(ele int) error {
 // 檢查隊列是否有空間
 if c.dataqsiz > 0 && c.qcount == c.dataqsiz {
  return ErrQFull
 }

 // 存入數據
 c.buf[c.sendx] = ele
 c.sendx++                  // 尾指針後移
 if c.sendx == c.dataqsiz { // 如果相等,說明隊列寫滿了,sendx放到開始位置
  c.sendx = 0
 }
 c.qcount++

 return nil
}

// 從buf中讀取數據
func (c *queue) read() (int, error) {
 // 隊列中沒有數據了
 if c.dataqsiz > 0 && c.qcount == 0 {
  return 0, ErrQEmpty
 }

 ret := c.buf[c.recvx] // 取出元素
 c.buf[c.recvx] = 0
 c.recvx++
 if c.recvx == c.dataqsiz { // 如果相等,說明寫到末尾了,recvx放到開始位置
  c.recvx = 0
 }
 c.qcount--

 return ret, nil
}

上面的代碼基本上就是 channel 的循環隊列部分的實現。這個隊列的實現與我們平常實現的循環隊列稍微有些不一樣。一般我們為了方便判空,會浪費一個buf的空間來方便判空,公式是:(tail+1)%n=head ;但是在 channel 這裡的循環隊列,由於有了一個循環隊列元素的計數,確保了這個空間不會被浪費,並且同時也能夠滿足 O(1) 時間複雜度計算有緩衝 channel 元素個數。

總結

總結一下今天的主要信息。

channel 中用到了兩個數據結構:循環隊列雙端鍊表循環隊列 只有在有緩衝 channel 中才會使用,它主要是做為消息的緩衝、保證消息的有序性;雙端鍊表 是用來掛起阻塞的讀、寫 goroutine 的,在被喚醒時會按照入隊順序公平的進行通知;無緩衝的 channel 不會用到 循環隊列 相關的結構,它必須讀寫 goroutine 都準備好後才能進行消息交換;做為緩衝消息的 循環隊列 通過一個當前元素個數欄位的標記,避免了浪費一個數據空間。

下一章我們就嘗試閱讀一下 channel 的源碼,想要嘗試錄製一個視頻來講這部分源碼!

參考資料

[1] [Diving Deep Into The Golang Channels.](https://codeburst.io/diving-deep-into-the-golang-channels-549fd4ed21a8)

[2] [Understanding Channels](https://speakerdeck.com/kavya719/understanding-channels)

[3] [The Nature Of Channels In Go](https://www.ardanlabs.com/blog/2014/02/the-nature-of-channels-in-go.html)

相關焦點

  • 【Golang】圖解channel之數據結構
    channel被設計用來實現goroutine間的通信,按照golang的設計思想:以通信的方式共享內存。
  • 走進Golang之Channel的使用
    好吧,簡單說人話就是,channel 是用來在 不同的 的 goroutine 中交換數據的。一定要注意這裡 不同的 三個字。千萬不要把 channel 拿來在不同函數(同一個 goroutine 中)間交換數據。
  • Golang並發:再也不愁選channel還是選鎖
    在Golang裡,channel也不是萬能的,這是由channel的特性和局限造成的。下面就給大家介紹channel的特點、核心方法和缺點。channel解決並發問題的思路和示例channel的核心是數據流動,關注到並發問題中的數據流動,把流動的數據放到channel中,就能使用channel解決這個並發問題。
  • Golang 入門筆記 - Channel
    Go 語言通過 channel 實現了安全的數據通信,只有一個子線程可以對數據進行操作。channel 裡的有效數據只能被單個 goroutine 訪問。數據的接收方和發送方是一對一的關係。等待獲取 channel 的數據上面第 6 行代碼,main 函數會一直等待,直到從 channel 接收到數據為止。上面的代碼,沒任何其他 goroutine 向 channel 發送數據,程序會在等待接收數據過程發生死鎖。我們看下輸出:從運行結果可以看出,程序發生了死鎖,因為 main 函數在一直等待 channel 返回數據。
  • golang開發:channel使用
    ,如下make(chan Type, [buffer])chan Type 通道的類型buffer 是可選參數,代表通道緩衝區的大小(省略則代表無緩衝)向channel裡面寫入數據使用 <- 符號q := make(chan bool)q<-true從channel裡面讀取數據也是使用 <- 符號,只不過寫入的channel
  • 「Golang」for range 使用方法及避坑指南
    前言循環控制結構[1]是一種在各種程式語言中常用的程序控制結構,其與順序控制結構、選擇控制結構組成了程序的控制結構,程序控制結構是指以某種順序執行的一系列動作
  • Golang context你必須要知道的
    Context 有四種結構,即 CancelContext、TimeoutContext、DeadLineContext 和 ValueContext。在 Go http 包的 Server 中,每一個請求在都有一個對應的 goroutine 去處理。
  • golang之context使用
    背景golang中並發編程的三種實現方式:chan管道、waitGroup和Context。本篇將重點介紹context的使用,告訴大家基本的使用方式,做到會用。Context概念介紹context譯為上下文,golang在1.6.2的時候還沒有自己的context,在1.7的版本中就把golang.org/x/net/context包被加入到了官方的庫中。golang 的 Context包,是專門用來處理多個goroutine之間與請求域的數據、取消信號、截止時間等相關操作。
  • 【Golang】Context基礎篇
    、取消信號及其他與請求相關的數據。通過`ctx1.Done()`獲取一個channel,監聽這個channel可以獲得`ctx1`的Cancel通知,還可以通過`ctx.Err()`獲取`ctx1`被取消時寫入的錯誤信息。
  • Golang 性能分析工具簡要介紹
    是因為在上面 flag 這個 bool 變量並沒有賦值,永遠為 false,因此 ch 這個 channel 並沒有被讀取,因此 37 行代碼會一直阻塞,這個程序比較簡單,但是實際的產品代碼要複雜的多,因此在查找問題的時候並不能一眼就能找到問題,還需要大家多多練習,多多積累。
  • Golang入門教程——map篇
    今天是golang專題的第7篇文章,我們來聊聊golang當中map的用法。map這個數據結構我們經常使用,存儲的是key-value的鍵值對。在C++/java當中叫做map,在Python中叫做dict。
  • Golang之nil解密
    type Type int// nil is a predeclared identifier representing the zero value for a// pointer, channel, func, interface, map, or slice type.
  • 走進Golang之Context的使用
    看到這裡可能有人要叫了,完全可以用 channel 來搞啊!那麼我們看看 channel 是否可以滿足。想一個問題,如果是微服務架構,channel 怎麼實現跨進程的邊界呢?另外一個問題,就算不跨進程,如果嵌套很多個分支,想一想這個消息傳遞的複雜度。
  • 一文帶你解密 Go 語言之通道 channel
    接下來我們一步步的剖開 channel,看看裡面到底是什麼,怎麼實現的跨 goroutine 通信,數據結構又是什麼,兩者又如何實現數據傳輸的?基本原理本質上 channel 在設計上就是環形隊列。其包含發送方隊列、接收方隊列,加上互斥鎖 mutex 等結構。
  • Golang 需要避免踩的 50 個坑(二)
    最近準備寫一些關於golang的技術博文,本文是之前在GitHub上看到的golang技術譯文,感覺很有幫助,先給各位讀者分享一下。
  • Golang入門教程——面向對象篇
    今天是golang專題的第9篇文章,我們一起來看看golang當中的面向對象的部分。在現在高級語言當中,面向對象幾乎是不可或缺也是一門語言最重要的部分之一。我們可以給結構體類型定義方法,為了表明該方法的適用對象是當前結構體,我們需要在方法當中定義接收者,位於func關鍵字和方法名之間。
  • 理解 Golang Context 機制
    【導讀】golang編程中經常用到Context,理解context設計和掌握其機制,對實現我們需要的功能有很大幫助。本文詳細介紹了go Context。1.Go的設計者在設計Goroutine時,已經考慮到「多個Goroutine共享數據,以及多Goroutine管理機制」。golang.org/x/net/context包就是這種機制的實現。
  • Golang入門教程——基本操作篇
    在許多語言當中,如果需要返回多個值,往往需要用一個結構體或者是tuple、list等數據結構將它們包裝起來。但是在golang當中支持同時返回多個結果,這將會極大地方便我們的編碼。或者是很容易產生遺漏、搞混順序之類的問題,golang當中針對這個問題也進行優化,支持我們對返回值進行命名。當命名的變量賦值完成之後,我們就可以直接用return關鍵字返回所有數據。
  • NIO-Channel簡介
    一個channel(通道)表示一個連接,一個channel可以表示一個底層的文件描述符
  • 「對比Python學習Go」- 高級數據結構下篇
    上篇說道,Go和Python的數據結構可分為類數組和哈希結構。本篇我們來看下哈希結構相關的類型。哈希結構哈希結構又叫做散列表(hash table),它是數組的一種擴展。在PySetObject中直接保存了存儲數據的數組。根據集合的底層數據結構分析,它解決哈希衝突也是使用的「開發尋址法」。