Goroutine 和 Channel 的的使用和一些坑以及案例分析

a_wei發表於2019-08-16

簡單認識一下Go的併發模型

簡單聊一下併發模型,下一篇會單獨全篇聊聊多種併發模型,以及其演進過程。

硬體發展越來越快,多核cpu正是盛行,為了提高cpu的利用率,程式語言開發者們也是各顯神通,Java的多執行緒,nodejs的多程式,golang的協程等,我想大家在平時開發中都應該在各自公司的監控平臺上看到cpu利用率低到5%左右,記憶體利用率經常80%左右。

軟體執行的最小單位是程式,當一個軟體或者應用程式啟動時我們知道作業系統為其建立了一個程式;程式碼執行的最小單位是執行緒,我們平時程式設計時寫的程式碼片段在程式跑起來後一定是在一個執行緒中執行的,而這個執行緒是屬於這個程式建立的。

我們經常接觸到的併發模型是多執行緒併發模型,而Go語言中的併發模型是CSP併發模型,這裡簡單介紹一這兩種併發模型

  1. 多執行緒併發模型

多執行緒併發模型是在一個應用程式中同時存在多個執行流,這多個執行流透過記憶體共享,訊號量,鎖等方式進行通訊,CPU在多個執行緒間進行上下文切換,從而達到併發執行,提高CPU利用率,其本質是核心態執行緒和使用者態執行緒是一對一的關係

  1. CSP併發模型

CSP併發模型的意思將程式的執行和通訊劃分開來(Process和Channel),Process代表了執行任務的一個單元,Channel用來在多個單元之間進行資料互動,共享;Process內部之間沒有併發問題,所有由通訊帶來的併發問題都被壓縮在Channel中,使得聚合在一起,得到了約束,同步,競爭聚焦在Channel上,Go就是基於這種併發模型的,Go線上程的基礎上實現了這一套併發模型(MPG),執行緒之上虛擬出了協程的概念,一個協程代表一個Process,但在作業系統級別排程的基本單位依然是執行緒,只是Go自己實現了一個排程器,用來管理協程的排程,M(Machine)代表一個核心執行緒,P(Process)代表一個排程器,G(Goroutine)代表一個協程,其本質是核心執行緒和使用者態執行緒成了多對多的關係

Goroutine和Channel的使用

如下程式碼執行起來,Go的主協程就啟動起來了

package main

func main(){
    fmt.Println("主協程啟動")
}

如何透過程式碼啟動一個新的協程呢,透過go關鍵字啟動一個新的協程,主協程啟動後,等待新的協程啟動執行

package main

func main(){
    var wg sync.WaitGroup
    wg.Add(1)
    go func(){
        defer wg.Done()
        fmt.Println("新的協程啟動")
    }()
    fmt.Println("主協程啟動")
    //等待新的協程執行完畢,程式才退出
    wg.Wait()
}

channel一些介紹

//通道分為兩類: 
//無緩衝區的通道
c := make(chan int)
//有緩衝區的通道
c := make(chan int,10)
//通道的操作
//往通道寫入資料
c <- 1
//從通道讀取資料,
//temp是讀取到的值
//ok是返回此通道是否已被關閉
temp,ok := <- c
//關閉通道
close(c)
//遍歷通道
for v :=  range c{
}

兩個協程之間如何通訊呢?,那就是透過channel通道來實現,channel建立時可以指定是否帶有緩衝區,如果不帶緩衝區,那麼當一個協程往通道中寫入一個資料的時候,另一個協程必須讀取,否則第一個協程就只能出去阻塞狀態(也就是生產一個,消費一個),帶有緩衝區的channel就理解為一個佇列或者倉庫,可以一下子生產很多個先暫存起來,慢慢消費。

package main

func main(){
    var wg sync.WaitGroup
    wg.Add(1)
    //不帶緩衝區的channel
    c := make(chan string)
    go func(){        
        defer func(){                
            wg.Done()              
        }()        
        for{                
            //從通道中取出資料                
            temp := <- c               
            if temp == "寫入資料3" {                        
                break               
            }        
        }
    }()
    //主協程迴圈往通道寫入值
    for i:=1;i<4;i++{        
        c <- "寫入資料"+strconv.Itoa(i)
    }
    //等待新的協程執行完畢,程式才退出
    wg.Wait()
}
//最終程式執行結果
/**
寫入資料1
寫入資料2
寫入資料3
*/

我們再來看一個用Goroutine和Channel實現的生產者消費者例子

/**生產者消費者的例子*/
func ProductAndConsumer() {        
    wg := sync.WaitGroup{}        
    wg.Add(1)        
    //帶有緩衝區的通道
    cint := make(chan int, 10)        
    go func() {                
        //product  ,迴圈往通道中寫入一個元素              
        for i := 0; i < 100; i++ {                       
            cint <- i                        
        }        
        //關閉通道
        close(cint)        
     }()        
    go func() {                
        defer wg.Done()                
        //consumer   遍歷通道消費元素並列印        
        for temp := range cint {                        
            fmt.Println(temp) 
            //len函式可以檢視當前通道元素個數
            fmt.Println("當前通道元素個數",len(cint))
        }        
    }()        
    wg.Wait()
}

使用中的一些坑

向一個已關閉的channel寫入資料會報錯,但從一個已關閉的channel讀取資料不會報錯

package main

func main(){
    c := make(chan int,10)
    close(c)
    c <- 1
}
//結果如下
panic: send on closed channel

主程式在讀取一個沒有生產者的channel時會被判斷為死鎖,如果是在新開的協程中是沒有問題的,同理主程式在往沒有消費者的協程中寫入資料時也會發生死鎖

package main

func main(){
    c := make(chan int,10)
    //從一個永遠都不可能有值的通道中讀取資料,會發生死鎖,因為會阻塞主程式的執行
    <- c
}
func main(){
    c := make(chan int,10)
    //主程式往一個沒有消費者的通道中寫入資料時會發生死鎖, 因為會阻塞主程式的執行
    c <- 1
}
//結果如下
fatal error: all goroutines are asleep - deadlock!

當通道被兩個協程操作時,如果一方因為阻塞導致另一放阻塞則會發生死鎖,如下程式碼建立兩個通道,開啟兩個協程(主協程和子協程),主協程從c2讀取資料,子協程往c1,c2寫入資料,因為c1,c2都是無緩衝通道,所以往c1寫時會阻塞,從c2讀取時也會會阻塞,從而發生死鎖

package main

func main(){
    c1 := make(chan int)
    c2 := make(chan int)
    go func(){
        c1 <- 1
        c2 <- 2
    }()
    <- c2
}
//結果
fatal error: all goroutines are asleep - deadlock!

通道死鎖的一些注意事項,其實上面的死鎖情況主要分為如下兩種

  1. 不要往一個已經關閉的channel寫入資料
  2. 不要透過channel阻塞主協程

一些經典案例看看Gorouting和Chanel的魅力

先說說Go中select的概念,一個select語句用來選擇哪個case中的傳送或接收操作可以被立即執行。它類似於switch語句,但是它的case涉及到channel有關的I/O操作,或者換一種說法,select就是用來監聽和channel有關的IO操作,當 IO 操作發生時,觸發相應的動作,基本用法如下:


//select基本用法
select {
    case <- c1:
    // 如果c1成功讀到資料,則進行該case處理語句
    case c2 <- 1:
    // 如果成功向c2寫入資料,則進行該case處理語句
    default:
    // 如果上面都沒有成功,則進入default處理流程
}

案例一,多個不依賴的服務可以併發執行

package main

func queryUserById(id int)chan string{
    c := make(chan string)
    go func(){
        c <- "姓名"+strconv.Itoa(id)
    }()
    return c
}

func main(){
    //三個協程同時併發查詢,縮小執行時間,
    //本來一次查詢需要1秒,順序執行就得3秒,
    //現在併發執行總共1秒就執行完成
    name1 := queryUserById(1)
    name2 := queryUserById(2)
    name3 := queryUserById(3)
    //從通道中獲取執行結果
    <- name1
    <- name2
    <- name3
}

案例二:select 監聽通道合併多個通道的值到一個通道


package main

func queryUserById(id int)chan string{
    c := make(chan string)
    go func(){
        c <- "姓名"+strconv.Itoa(id)
    }()
    return c
}

func main(){    
    c1, c2, c3 := queryUserById(1), queryUserById(2), queryUserById(3)
    c := make(chan string)
    // 開一個goroutine監視各個通道資料輸出並收集資料到通道c
    go func() { 
        for {
            // 監視c1, c2, c3的流出,並全部流入通道c
            select {
               case       
                   v1 := <- c1:        
                   c <- v1
               case       
                   v2 := <- c2:        
                   c <- v2
               case       
                   v3 := <- c3:       
                   c <- v3
            }
        }
    }()
    // 阻塞主線,取出通道c的資料
    for i := 0; i < 3; i++ {
         // 從列印來看我們的資料輸出並不是嚴格的順序
        fmt.Println(<-c) 
    }
}

案例三:結束標誌

func main() {

    c, quit := make(chan int), make(chan int)
    go func() {
        c <- 2  // 新增資料
        quit <- 1 // 傳送完成訊號
    } ()
    for is_quit := false; !is_quit; {
        // 監視通道c的資料流出
        select { 
            case v := <-c: fmt.Printf("received %d from c", v)
            case <-quit: is_quit = true 
            // quit通道有輸出,關閉for迴圈
        }
    }}

Golang

本作品採用《CC 協議》,轉載必須註明作者和本文連結
那小子阿偉

相關文章