go語句及其執行規則

參考:https://time.geekbang.org/column/article/39841?utm_source=weibo&utm_medium=xuxiaoping&utm_campaign=promotion&utm_content=columns編程

不要經過共享數據來通信,偏偏相反,要以通信的方式共享數據。安全

Don’t communicate by sharing memory; share memory by communicating.併發

一個進程至少會包含一個線程。若是一個進程只包含了一個線程,那麼它裏面的全部代碼都只會被串行地執行。每一個進程的第一個線程都會隨着該進程的啓動而被建立,它們能夠被稱爲其所屬進程的主線程。異步

相對應的,若是一個進程中包含了多個線程,那麼其中的代碼就能夠被併發地執行。除了進程的第一個線程以外,其餘的線程都是由進程中已存在的線程建立出來的。ide

也就是說,主線程以外的其餘線程都只能由代碼顯式地建立和銷燬。這須要咱們在編寫程序的時候進行手動控制,操做系統以及進程自己並不會幫咱們下達這樣的指令,它們只會忠實地執行咱們的指令。不過,在 Go 程序當中,Go 語言的運行時(runtime)系統會幫助咱們自動地建立和銷燬系統級的線程。這裏的系統級線程指的就是咱們剛剛說過的操做系統提供的線程。函數

這帶來了不少優點,好比,由於它們的建立和銷燬並不用經過操做系統去作,因此速度會很快,又好比,因爲不用等着操做系統去調度它們的運行,因此每每會很容易控制而且能夠很靈活。ui

不過別擔憂,Go 語言不但有着獨特的併發編程模型,以及用戶級線程 goroutine,還擁有強大的用於調度 goroutine、對接系統級線程的調度器。atom

這個調度器是 Go 語言運行時系統的重要組成部分,它主要負責統籌調配 Go 併發編程模型中的三個主要元素,
即:
G(goroutine 的縮寫)
P(processor 的縮寫)一種能夠承載若干個 G,且可以使這些 G 適時地與 M 進行對接,並獲得真正運行的中介
M(machine 的縮寫) 系統級線程操作系統

從宏觀上說,G 和 M 因爲 P 的存在能夠呈現出多對多的關係。當一個正在與某個 M 對接並運行着的 G,須要因某個事件(好比等待 I/O 或鎖的解除)而暫停運行的時候,調度器總會及時地發現,並把這個 G 與那個 M 分離開,以釋放計算資源供那些等待運行的 G 使用。線程

其中的 M 指代的就是系統級線程。而 P 指的是一種能夠承載若干個 G,且可以使這些 G 適時地與 M 進行對接,並獲得真正運行的中介。

而當一個 G 須要恢復運行的時候,調度器又會盡快地爲它尋找空閒的計算資源(包括 M)並安排運行。另外,當 M 不夠用時,調度器會幫咱們向操做系統申請新的系統級線程,而當某個 M 已無用時,調度器又會負責把它及時地銷燬掉。

正由於調度器幫助咱們作了不少事,因此咱們的 Go 程序才老是能高效地利用操做系統和計算機資源。程序中的全部 goroutine 也都會被充分地調度,其中的代碼也都會被併發地運行,即便這樣的 goroutine 有數以十萬計,也仍然能夠如此。

go語句及其執行規則

什麼是主 goroutine,它與咱們啓用的其餘 goroutine 有什麼不一樣?

與一個進程總會有一個主線程相似,每個獨立的 Go 程序在運行時也總會有一個主 goroutine。這個主 goroutine 會在 Go 程序的運行準備工做完成後被自動地啓用,並不須要咱們作任何手動的操做。

每條go語句通常都會攜帶一個函數調用,這個被調用的函數經常被稱爲go函數。而主 goroutine 的go函數就是那個做爲程序入口的main函數。

go函數真正被執行的時間,總會與其所屬的go語句被執行的時間不一樣。當程序執行到一條go語句的時候,Go 語言的運行時系統,會先試圖從某個存放空閒的 G 的隊列中獲取一個 G(也就是 goroutine),它只有在找不到空閒 G 的狀況下才會去建立一個新的 G。

這也是爲何我總會說「啓用」一個 goroutine,而不說「建立」一個 goroutine 的緣由。已存在的 goroutine 老是會被優先複用

然而,建立 G 的成本也是很是低的。建立一個 G 並不會像新建一個進程或者一個系統級線程那樣,必須經過操做系統的系統調用來完成,在 Go 語言的運行時系統內部就能夠徹底作到了,更況且一個 G 僅至關於爲須要併發執行代碼片斷服務的上下文環境而已。

在拿到了一個空閒的 G 以後,Go 語言運行時系統會用這個 G 去包裝當前的那個go函數(或者說該函數中的那些代碼),而後再把這個 G 追加到某個存放可運行的 G 的隊列中

這類隊列中的 G 老是會按照先入先出的順序,很快地由運行時系統內部的調度器安排運行。雖然這會很快,可是因爲上面所說的那些準備工做仍是不可避免的,因此耗時仍是存在的。

go函數的執行時間老是會明顯滯後於它所屬的go語句的執行時間。固然了,這裏所說的「明顯滯後」是對於計算機的 CPU 時鐘和 Go 程序來講的。咱們在大多數時候都不會有明顯的感受。

在說明了原理以後,咱們再來看這種原理下的表象。請記住,只要go語句自己執行完畢,Go 程序徹底不會等待go函數的執行,它會馬上去執行後邊的語句。這就是所謂的異步併發地執行。

這裏「後邊的語句」指的通常是for語句中的下一個迭代。然而,當最後一個迭代運行的時候,這個「後邊的語句」是不存在的。

在 demo38.go 中的那條for語句會以很快的速度執行完畢。當它執行完畢時,那 10 個包裝了go函數的 goroutine 每每尚未得到運行的機會。

go函數中的那個對fmt.Println函數的調用是以for語句中的變量i做爲參數的。你能夠想象一下,若是當for語句執行完畢的時候,這些go函數都尚未執行,那麼它們引用的變量i的值將會是什麼?

它們都會是10,對嗎?那麼這道題的答案會是「打印出 10 個10」,是這樣嗎?

在肯定最終的答案以前,你還須要知道一個與主 goroutine 有關的重要特性,即:一旦主 goroutine 中的代碼(也就是main函數中的那些代碼)執行完畢,當前的 Go 程序就會結束運行。

若是在 Go 程序結束的那一刻,還有 goroutine 未獲得運行機會,那麼它們就真的沒有運行機會了,它們中的代碼也就不會被執行了。

當for語句的最後一個迭代運行的時候,其中的那條go語句便是最後一條語句。因此,在執行完這條go語句以後,主 goroutine 中的代碼也就執行完了,Go 程序會當即結束運行。那麼,若是這樣的話,還會有任何內容被打印出來嗎?

嚴謹地講,Go 語言並不會去保證這些 goroutine 會以怎樣的順序運行。因爲主 goroutine 會與咱們手動啓用的其餘 goroutine 一塊兒接受調度,又由於調度器極可能會在 goroutine 中的代碼只執行了一部分的時候暫停,以期全部的 goroutine 有更公平的運行機會。

因此哪一個 goroutine 先執行完、哪一個 goroutine 後執行完每每是不可預知的,除非咱們使用了某種 Go 語言提供的方式進行了人爲干預。然而,在這段代碼中,咱們並無進行任何人爲干預。

那答案究竟是什麼呢?就 demo38.go 中如此簡單的代碼而言,絕大多數狀況都會是「不會有任何內容被打印出來」。

package main

import "fmt"

func main() {
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
}
go run demo38.go //輸出爲空

怎樣才能讓主 goroutine 等待其餘 goroutine?

一旦主 goroutine 中的代碼執行完畢,當前的 Go 程序就會結束運行,不管其餘的 goroutine 是否已經在運行了。那麼,怎樣才能作到等其餘的 goroutine 運行完畢以後,再讓主 goroutine 結束運行呢?

其實有不少辦法能夠作到這一點。其中,最簡單粗暴的辦法就是讓主 goroutine「小睡」一下子。

既然不容易預估時間,那咱們就讓其餘的 goroutine 在運行完畢的時候告訴咱們好了。這個思路很好,但怎麼作呢?

你是否想到了通道呢?咱們先建立一個通道,它的長度應該與咱們手動啓用的 goroutine 的數量一致。在每一個手動啓用的 goroutine 即將運行完畢的時候,咱們都要向該通道發送一個值。注意,這些發送表達式應該被放在它們的go函數體的最後面。對應的,咱們還須要在main函數的最後從通道接收元素值,接收的次數也應該與手動啓用的 goroutine 的數量保持一致。關於這些你能夠到 demo39.go 文件中,去查看具體的寫法。

其中有一個細節你須要注意。我在聲明通道sign的時候是以chan struct{}做爲其類型的。其中的類型字面量struct{}有些相似於空接口類型interface{},它表明了既不包含任何字段也不擁有任何方法的空結構體類型。

注意,struct{}類型值的表示法只有一個,即:struct{}{}。而且,它佔用的內存空間是0字節。確切地說,這個值在整個 Go 程序中永遠都只會存在一份。雖然咱們能夠無數次地使用這個值字面量,可是用到的卻都是同一個值。

再說回當下的問題,有沒有比使用通道更好的方法?若是你知道標準庫中的代碼包sync的話,那麼可能會想到sync.WaitGroup類型。沒錯,這是一個更好的答案

package main

import (
    "fmt"
    //"time"
)

func main() {
    num := 10
    sign := make(chan struct{}, num)

    for i := 0; i < num; i++ {
        go func() {
            fmt.Println(i)
            sign <- struct{}{}
        }()
    }

    // 辦法1。
    //time.Sleep(time.Millisecond * 500)

    // 辦法2。
    for j := 0; j < num; j++ {
        <-sign
    }
}
go run demo39.go 
10
10
10
10
10
10
10
10
6
10

怎樣讓咱們啓用的多個 goroutine 按照既定的順序運行?

go函數中先聲明瞭一個匿名的函數,並把它賦給了變量fn。這個匿名函數作的事情很簡單,只是調用fmt.Println函數以打印go函數的參數i的值

在這以後,我調用了一個名叫trigger的函數,並把go函數的參數i和剛剛聲明的變量fn做爲參數傳給了它。注意,for語句聲明的局部變量i和go函數的參數i的類型都變了,都由int變爲了uint32。至於爲何,我一下子再說。

再來講trigger函數。該函數接受兩個參數,一個是uint32類型的參數i, 另外一個是func()類型的參數fn。你應該記得,func()表明的是既無參數聲明也無結果聲明的函數類型。

trigger函數會不斷地獲取一個名叫count的變量的值,並判斷該值是否與參數i的值相同。若是相同,那麼就當即調用fn表明的函數,而後把count變量的值加1,最後顯式地退出當前的循環。不然,咱們就先讓當前的 goroutine「睡眠」一個納秒再進入下一個迭代

我操做變量count的時候使用的都是原子操做。這是因爲trigger函數會被多個 goroutine 併發地調用,因此它用到的非本地變量count,就被多個用戶級線程共用了。所以,對它的操做就產生了競態條件(race condition),破壞了程序的併發安全性。

老是應該對這樣的操做加以保護,在sync/atomic包中聲明瞭不少用於原子操做的函數。

因爲我選用的原子操做函數對被操做的數值的類型有約束,因此我纔對count以及相關的變量和參數的類型進行了統一的變動(由int變爲了uint32)。

縱觀count變量、trigger函數以及改造後的for語句和go函數,我要作的是,讓count變量成爲一個信號,它的值老是下一個能夠調用打印函數的go函數的序號。

這個序號其實就是啓用 goroutine 時,那個當次迭代的序號。也正由於如此,go函數實際的執行順序纔會與go語句的執行順序徹底一致。此外,這裏的trigger函數實現了一種自旋(spinning)。除非發現條件已知足,不然它會不斷地進行檢查。

最後要說的是,由於我依然想讓主 goroutine 最後一個運行完畢,因此還須要加一行代碼。不過既然有了trigger函數,我就沒有再使用通道。

調用trigger函數徹底能夠達到相同的效果。因爲當全部我手動啓用的 goroutine 都運行完畢以後,count的值必定會是10,因此我就把10做爲了第一個參數值。又因爲我並不想打印這個10,因此我把一個什麼都不作的函數做爲了第二個參數值。
總之,經過上述的改造,我使得異步發起的go函數獲得了同步地(或者說按照既定順序地)執行,你也能夠動手本身試一試,感覺一下。

package main

import (
    "fmt"
    "sync/atomic"
    "time"
)

func main() {
    var count uint32
    trigger := func(i uint32, fn func()) {
        for {
            if n := atomic.LoadUint32(&count); n == i {
                fn()
                atomic.AddUint32(&count, 1)
                break
            }
            time.Sleep(time.Nanosecond)
        }
    }
    for i := uint32(0); i < 10; i++ { //咱們傳給go函數的參數i會先被求值,如此就獲得了當次迭代的序號。以後,不管go函數會在何時執行,這個參數值都不會變
        go func(i uint32) {
            fn := func() {
                fmt.Println(i)
            }
            trigger(i, fn)
        }(i)
    }
    trigger(10, func() {})
}
go run demo40.go 
0
1
2
3
4
5
6
7
8
9
相關文章
相關標籤/搜索