1. 程式人生 > >goroutine和channel與死鎖詳解

goroutine和channel與死鎖詳解

Go語言中有個概念叫做goroutine, 這類似我們熟知的執行緒,但是更輕。

goroutine和執行緒的具體區別在於:

1. OS的執行緒由OS核心排程,每隔幾毫秒,一個硬體時鐘中斷髮到CPU,CPU呼叫一個排程器核心函式。這個函式暫停當前正在執行的執行緒,把他的暫存器資訊儲存到記憶體中,檢視執行緒列表並決定接下來執行哪一個執行緒,再從記憶體中恢復執行緒的登錄檔資訊,最後繼續執行選中的執行緒。這種執行緒切換需要一個完整的上下文切換:即儲存一個執行緒的狀態到記憶體,再恢復另外一個執行緒的狀態,最後更新排程器的資料結構。某種意義上,這種操作還是很慢的。Go執行的時候包涵一個自己的排程器,這個排程器使用一個稱為一個M:N排程技術,m個goroutine到n個os執行緒(可以用GOMAXPROCS來控制n的數量),Go的排程器不是由硬體時鐘來定期觸發的,而是由特定的go語言結構來觸發的,他不需要切換到核心語境,所以排程一個goroutine比排程一個執行緒的成本低很多。

2. 從棧空間上,goroutine的棧空間更加動態靈活。每個OS的執行緒都有一個固定大小的棧記憶體,通常是2MB,棧記憶體用於儲存在其他函式呼叫期間哪些正在執行或者臨時暫停的函式的區域性變數。這個固定的棧大小,如果對於goroutine來說,可能是一種巨大的浪費。作為對比goroutine在生命週期開始只有一個很小的棧,典型情況是2KB, 在go程式中,一次建立十萬左右的goroutine也不罕見(2KB*100,000=200MB)。而且goroutine的棧不是固定大小,它可以按需增大和縮小,最大限制可以到1GB。

以下的程式,我們序列地去執行兩次loop函式:

func loop() {
    for i := 0; i < 10; i++ {
        fmt.Printf("%d ", i)
    }
}


func main() {
    loop()
    loop()
}

毫無疑問,輸出會是這樣的:

0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

下面我們把一個loop放在一個goroutine裡跑,我們可以使用關鍵字go來定義並啟動一個goroutine:

func main() {
    go loop() // 啟動一個goroutine
    loop()
}

這次的輸出變成了:

0 1 2 3 4 5 6 7 8 9

在goroutine還沒來得及跑loop的時候,主函式已經退出了。main函式退出地太快了,我們要想辦法阻止它過早地退出,一個辦法是讓main等待一下:

func main() {
    go loop()
    loop()
    time.Sleep(time.Second) // 停頓一秒
}

這次確實輸出了兩趟,目的達到了。

可是採用等待的辦法並不好,如果goroutine在結束的時候,告訴下主線說“Hey, 我要跑完了!”就好了, 即所謂阻塞主線的辦法,回憶下我們Python裡面等待所有執行緒執行完畢的寫法:

for thread in threads:
    thread.join()

是的,我們也需要一個類似join的東西來阻塞住主線。那就是通道

通道是什麼?簡單說,是goroutine之間互相通訊的東西。類似我們Unix上的管道(可以在程序間傳遞訊息), 用來goroutine之間發訊息和接收訊息。其實,就是在做goroutine之間的記憶體共享。

使用make來建立一個通道:

var channel chan int = make(chan int)
// 或
channel := make(chan int)

那如何向通道存訊息和取訊息呢? 一個例子:

func main() {
    var messages chan string = make(chan string)
    go func(message string) {
        messages <- message // 存訊息
    }("Ping!")

    fmt.Println(<-messages) // 取訊息
}

預設的,通道的存訊息和取訊息都是阻塞的 (叫做無緩衝的通道,不過緩衝這個概念稍後瞭解,先說阻塞的問題)。

也就是說, 無緩衝的通道在取訊息和存訊息的時候都會掛起當前的goroutine,除非另一端已經準備好。

比如以下的main函式和foo函式:

var ch chan int = make(chan int)

func foo() {
    ch <- 0  // 向ch中加資料,如果沒有其他goroutine來取走這個資料,那麼掛起foo, 直到main函式把0這個資料拿走
}

func main() {
    go foo()
    <- ch // 從ch取資料,如果ch中還沒放資料,那就掛起main線,直到foo函式中放資料為止
}

那既然通道可以阻塞當前的goroutine, 那麼回到上一部分「goroutine」所遇到的問題「如何讓goroutine告訴主線我執行完畢了」 的問題來, 使用一個通道來告訴主線即可:

var complete chan int = make(chan int)

func loop() {
    for i := 0; i < 10; i++ {
        fmt.Printf("%d ", i)
    }

    complete <- 0 // 執行完畢了,發個訊息
}


func main() {
    go loop()
    <- complete // 直到執行緒跑完, 取到訊息. main在此阻塞住
}

如果不用通道來阻塞主線的話,主線就會過早跑完,loop線都沒有機會執行、、、

其實,無緩衝的通道永遠不會儲存資料,只負責資料的流通,為什麼這麼講呢?

  • 從無緩衝通道取資料,必須要有資料流進來才可以,否則當前線阻塞

  • 資料流入無緩衝通道, 如果沒有其他goroutine來拿走這個資料,那麼當前線阻塞

無論如何,我們測試到的無緩衝通道的大小都是0 (len(channel))

如果通道正有資料在流動,我們還要加入資料,或者通道乾澀,我們一直向無資料流入的空通道取資料呢? 就會引起死鎖

死鎖

一個死鎖的例子:

func main() {
    ch := make(chan int)
    <- ch // 阻塞main goroutine, 通道c被鎖
}

執行這個程式你會看到Go報這樣的錯誤:

fatal error: all goroutines are asleep - deadlock!

何謂死鎖? 作業系統有講過的,所有的執行緒或程序都在等待資源的釋放。如上的程式中, 只有一個goroutine, 所以當你向裡面加資料或者存資料的話,都會鎖死通道, 並且阻塞當前 goroutine, 也就是所有的goroutine(其實就main線一個)都在等待通道的開放(沒人拿走資料通道是不會開放的),也就是死鎖咯。

我發現死鎖是一個很有意思的話題,這裡有幾個死鎖的例子:

  1. 只在單一的goroutine裡操作無緩衝通道,一定死鎖。比如你只在main函式裡操作通道:

    func main() {
        ch := make(chan int)
        ch <- 1 // 1流入通道,堵塞當前線, 沒人取走資料通道不會開啟
        fmt.Println("This line code wont run") //在此行執行之前Go就會報死鎖
    }
    
  2. 如下也是一個死鎖的例子:

    var ch1 chan int = make(chan int)
    var ch2 chan int = make(chan int)
    
    func say(s string) {
        fmt.Println(s)
        ch1 <- <- ch2 // ch1 等待 ch2流出的資料
    }
    
    func main() {
        go say("hello")
        <- ch1  // 堵塞主線
    }
    

    其中主線等ch1中的資料流出,ch1等ch2的資料流出,但是ch2等待資料流入,兩個goroutine都在等,也就是死鎖。

  3. 其實,總結來看,為什麼會死鎖?非緩衝通道上如果發生了流入無流出,或者流出無流入,也就導致了死鎖。或者這樣理解 Go啟動的所有goroutine裡的非緩衝通道一定要一個線裡存資料,一個線裡取資料,要成對才行 。所以下面的示例一定死鎖:

    c, quit := make(chan int), make(chan int)
    
    go func() {
       c <- 1  // c通道的資料沒有被其他goroutine讀取走,堵塞當前goroutine
       quit <- 0 // quit始終沒有辦法寫入資料
    }()
    
    <- quit // quit 等待資料的寫
    

    仔細分析的話,是由於:主線等待quit通道的資料流出,quit等待資料寫入,而func被c通道堵塞,所有goroutine都在等,所以死鎖。

    簡單來看的話,一共兩個線,func線中流入c通道的資料並沒有在main線中流出,肯定死鎖。

但是,是否果真 所有不成對向通道存取資料的情況都是死鎖?

如下是個反例:

func main() {
    c := make(chan int)

    go func() {
       c <- 1
    }()
}

程式正常退出了,很簡單,並不是我們那個總結不起作用了,還是因為一個讓人很囧的原因,main又沒等待其它goroutine,自己先跑完了, 所以沒有資料流入c通道,一共執行了一個goroutine, 並且沒有發生阻塞,所以沒有死鎖錯誤。

那麼死鎖的解決辦法呢?

最簡單的,把沒取走的資料取走,沒放入的資料放入, 因為無緩衝通道不能承載資料,那麼就趕緊拿走!

具體來講,就死鎖例子3中的情況,可以這麼避免死鎖:

c, quit := make(chan int), make(chan int)

go func() {
    c <- 1
    quit <- 0
}()

<- c // 取走c的資料!
<-quit

另一個解決辦法是緩衝通道, 即設定c有一個數據的緩衝大小:

c := make(chan int, 1)

這樣的話,c可以快取一個數據。也就是說,放入一個數據,c並不會掛起當前線, 再放一個才會掛起當前線直到第一個資料被其他goroutine取走, 也就是隻阻塞在容量一定的時候,不達容量不阻塞。

這十分類似我們Python中的佇列Queue不是嗎?

無緩衝通道的資料進出順序

我們已經知道,無緩衝通道從不儲存資料,流入的資料必須要流出才可以。

觀察以下的程式:

var ch chan int = make(chan int)

func foo(id int) { //id: 這個routine的標號
    ch <- id
}

func main() {
    // 開啟5個routine
    for i := 0; i < 5; i++ {
        go foo(i)
    }

    // 取出通道中的資料
    for i := 0; i < 5; i++ {
        fmt.Print(<- ch)
    }
}

我們開了5個goroutine,然後又依次取資料。其實整個的執行過程細分的話,5個線的資料 依次流過通道ch, main列印之, 而巨集觀上我們看到的即 無緩衝通道的資料是先到先出,但是 無緩衝通道並不儲存資料,只負責資料的流通

緩衝通道

終於到了這個話題了, 其實快取通道用英文來講更為達意: buffered channel.

緩衝這個詞意思是,緩衝通道不僅可以流通資料,還可以快取資料。它是有容量的,存入一個數據的話 , 可以先放在信道里,不必阻塞當前線而等待該資料取走。

當緩衝通道達到滿的狀態的時候,就會表現出阻塞了,因為這時再也不能承載更多的資料了,「你們必須把 資料拿走,才可以流入資料」。

在宣告一個通道的時候,我們給make以第二個引數來指明它的容量(預設為0,即無緩衝):

var ch chan int = make(chan int, 2) // 寫入2個元素都不會阻塞當前goroutine, 儲存個數達到2的時候會阻塞

如下的例子,緩衝通道ch可以無緩衝的流入3個元素:

func main() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3
}

如果你再試圖流入一個數據的話,通道ch會阻塞main線, 報死鎖。

也就是說,緩衝通道會在滿容量的時候加鎖。

其實,緩衝通道是先進先出的,我們可以把緩衝通道看作為一個執行緒安全的佇列:

func main() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3

    fmt.Println(<-ch) // 1
    fmt.Println(<-ch) // 2
    fmt.Println(<-ch) // 3
}

通道資料讀取和通道關閉

你也許發現,上面的程式碼一個一個地去讀取通道簡直太費事了,Go語言允許我們使用range來讀取通道:

func main() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3

    for v := range ch {
        fmt.Println(v)
    }
}

如果你執行了上面的程式碼,會報死鎖錯誤的,原因是range不等到通道關閉是不會結束讀取的。也就是如果 緩衝通道乾涸了,那麼range就會阻塞當前goroutine, 所以死鎖咯。

那麼,我們試著避免這種情況,比較容易想到的是讀到通道為空的時候就結束讀取:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
for v := range ch {
    fmt.Println(v)
    if len(ch) <= 0 { // 如果現有資料量為0,跳出迴圈
        break
    }
}

以上的方法是可以正常輸出的,但是注意檢查通道大小的方法不能在通道存取都在發生的時候用於取出所有資料,這個例子 是因為我們只在ch中存了資料,現在一個一個往外取,通道大小是遞減的。

另一個方式是顯式地關閉通道:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3

// 顯式地關閉通道
close(ch)

for v := range ch {
    fmt.Println(v)
}

被關閉的通道會禁止資料流入, 是隻讀的。我們仍然可以從關閉的通道中取出資料,但是不能再寫入資料了。

等待多gorountine的方案

那好,我們回到最初的一個問題,使用通道堵塞主線,等待開出去的所有goroutine跑完。

這是一個模型,開出很多小goroutine, 它們各自跑各自的,最後跑完了向主線報告。

我們討論如下2個版本的方案:

  1. 只使用單個無緩衝通道阻塞主線

  2. 使用容量為goroutines數量的緩衝通道

對於方案1, 示例的程式碼大概會是這個樣子:

var quit chan int // 只開一個通道

func foo(id int) {
    fmt.Println(id)
    quit <- 0 // ok, finished
}

func main() {
    count := 1000
    quit = make(chan int) // 無緩衝

    for i := 0; i < count; i++ {
        go foo(i)
    }

    for i := 0; i < count; i++ {
        <- quit
    }
}

對於方案2, 把通道換成緩衝1000的:

quit = make(chan int, count) // 容量1000

其實區別僅僅在於一個是緩衝的,一個是非緩衝的。

對於這個場景而言,兩者都能完成任務, 都是可以的。

  • 無緩衝的通道是一批資料一個一個的「流進流出」

  • 緩衝通道則是一個一個儲存,然後一起流出去