1. 程式人生 > >Go語言併發模型——goroutine

Go語言併發模型——goroutine

       Go語言裡的併發指的是能讓某個函式獨立於其他函式執行的能力。當一個函式建立為goroutine時,Go會將其視為一個獨立的工作單元。這個單元會被排程到可用的邏輯處理器上執行。Go語言執行時的排程器是一個複雜的軟體,能管理被建立的所有goroutine併為其分配執行時間。這個排程器在作業系統之上,將作業系統的執行緒與語言執行時的邏輯處理器繫結,並在邏輯處理器上執行goroutine。排程器在任何給定的時間,都會全面控制哪個goroutine要在哪個邏輯處理器上執行。
       Go語言的併發同步模型來自一個叫作通訊順序程序(CSP)的泛型。CSP是一種訊息傳遞模型,通過在goroutine之間同步和傳遞資料來傳遞訊息,而不是對資料進行加鎖來實現同步訪問。用於在goroutine之間同步和傳遞資料的關鍵資料型別叫作通道(channel)。
一、併發與並行


       當執行一個應用程式(如一個IDE或者編輯器)的時候,作業系統會為這個應用程式啟動一個程序。可以將這個程序看作一個包含了應用程式在執行中需要用到和維護的各種資源的容器。
       這些資源包括但不限於記憶體地址空間、檔案和裝置的控制代碼以及執行緒。一個執行緒是一個執行空間,這個空間會被作業系統排程來執行函式中所寫的程式碼。每個程序至少包含一個執行緒,每個程序的初始執行緒被稱作主執行緒。因為執行這個執行緒的空間是應用程式的本身的空間,所以當主執行緒終止時,應用程式也會終止。作業系統將執行緒排程到某個處理器上執行,這個處理器並不一定是程序所在的處理器。不同作業系統使用的執行緒排程演算法一般都不一樣。
       作業系統會在物理處理器上排程執行緒來執行,而Go語言的執行時會在邏輯處理器上排程goroutine來執行。每個邏輯處理器分別繫結到單個作業系統執行緒。即便只有一個邏輯處理器,Go也可以神奇的效率和效能,併發排程無數個goroutine。
       現在來了解一下作業系統執行緒、邏輯處理器和本地執行佇列之間的關係。如果建立一個goroutine並準備執行,這個goroutine就會被放到排程器的全域性執行佇列中。之後,排程器就將這些佇列中的goroutine分配給一個邏輯處理器,並放到這個邏輯處理器對應的本地執行佇列中。本地執行佇列中的goroutine會一直等待直到自己被分配的邏輯處理器執行。
       有時,正在執行的goroutine需要執行一個阻塞的系統呼叫,如開啟一個檔案。當這類呼叫發生時,執行緒和goroutine會從邏輯處理器上分離,該執行緒會繼續阻塞,等待系統呼叫的返回。與此同時,這個邏輯處理器就失去了用來執行的執行緒。所以,排程器會建立一個新執行緒,並將其繫結到該邏輯處理器上。之後,排程器會從本地執行佇列裡選擇另一個goroutine來執行。一旦被阻塞的系統呼叫執行完成並返回,對應的goroutine會放回到本地執行佇列,而之前的執行緒會儲存好,以便之後可以繼續使用。
       如果一個goroutine需要做一個網路I/O呼叫,流程上會有些不一樣。在這種情況下,goroutine會和邏輯處理器分離,並移到集成了網路輪詢期的執行時。一旦該輪詢器指示某個網路讀或寫操作已經就緒,對應的goroutine就會重新分配到邏輯處理器上來完成操作。排程器對可以建立的邏輯處理器的數量沒有限制,但語言執行時預設限制每個程式最多建立10000個執行緒。這個限制值可以通過runtime/debug包的SetMaxThreads方法來更改。如果程式試圖用更多的執行緒,就會崩壞。
       併發不是並行。並行是讓不同的程式碼片段同時在不同的物理處理器上執行。並行的關鍵是同時做很多事情,而併發是指同時管理很多事情,這些事情可能只做了一半就被暫停去做別的事情了。
      如果希望讓goroutine並行,必須使用多於一個邏輯處理器。當有多個邏輯處理器時,排程器會將goroutine平等分配到每個邏輯處理器上。這會讓goroutine在不同的執行緒上執行。不過要想真的實現並行的效果,使用者需要讓自己的程式執行在有多個物理處理器的機器上。不過要想真的實現並行的效果,使用者需要讓自己的程式執行在有多個物理處理器的機器上。否則,哪怕Go語言執行時使用多個執行緒,goroutine依然會在同一個物理處理器上併發執行,達不到並行的效果。

二、goroutine
       我們來了解一下排程器的行為,以及排程器是如何建立goroutine並管理其壽命的。我們先通過在一個邏輯處理器上執行的例子講解。程式碼所示的程式會建立兩個goroutine,以併發地形式分別顯示大寫和小寫英文字母。

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main(){
    runtime.GOMAXPROCS(1)//分配一個處理器給排程器使用

    //wg用來等待程式完成
    //計數加2,表示要等待兩個goroutine
    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("Start Goroutines")

    go func(){
        //在函式退出時呼叫Done來通知main函式工作已經完成
        defer wg.Done()

        for count:=0;count<3;count++{
            for char:='a';char<'a'+26;char++{
                fmt.Printf("%c ",char)
            }
        }
    }()

    go func(){
        //在函式退出時呼叫Done來通知main函式工作已經完成
        defer wg.Done()

        for count:=0;count<3;count++{
            for char:='A';char<'A'+26;char++{
                fmt.Printf("%c ",char)
            }
        }
    }()

    //等待goroutine結束
    fmt.Println("Waiting To Finish")
    wg.Wait()

    fmt.Println("\nTerminating Program")
}

       呼叫了runtime包的GOMAXPRACS函式。這個函式允許程式更改排程器可以使用的邏輯處理器的數量。我們聲明瞭兩個匿名函式,用來顯示英文字母表,每個goroutine執行的程式碼在一個邏輯處理器上併發執行的效果。第一個goroutine完成所有顯示需要花時間太短了,以至於在排程器切換到第二個goroutine之前,就完成了所有任務。這也是為什麼會看到先輸出了所有的大寫字母,之後才輸出小寫字母。
       WaitGroup是一個計數訊號量,可以用來記錄並維護執行的goroutine。如果WaitGroup的值大於0,Wait方法就會阻塞。我們建立了一個WaitGroup型別的變數,之後將這個WaitGroup的值設定為2,表示有兩個正在執行的goroutine。為了減小WaitGroup的值並最終釋放main函式,使用defer宣告在函式退出時呼叫Done方法。
       關鍵字defer會修改函式呼叫時機,在正在執行的函式返回時才真正呼叫defer宣告的函式。對這裡的示例程式來說,我們使用關鍵字defer保證,每個goroutine一旦完成其工作就呼叫Done方法。
       基於排程器的內部演算法,一個正執行的goroutine在工作結束前,可以被停止並重新排程。排程器這樣的目的是防止某個goroutine長時間佔用邏輯處理器。當goroutine佔用時間過長時,排程器會停止當前正執行的goroutine,並給其他可執行的goroutine執行的機會。
可以通過建立一個需要長時間才能完成其工作的goroutine來看到排程的這個行為,如下所示:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var wg sync.WaitGroup

func main(){
    runtime.GOMAXPROCS(1)
    wg.Add(2)

    fmt.Println("Create Goroutines")
    go printPrime("A")
    go printPrime("B")

    fmt.Println("Waiting to Finish")
    wg.Wait()

    fmt.Println("Terminating Program")
}

func printPrime(prefix string){
    defer wg.Done()

    next:
        for outer:=2;outer<5000;outer++{
            for inner:=2;inner<outer;inner++{
                if outer%inner == 0{
                    continue next
                }
            }
            fmt.Printf("%s:%d\n",prefix,outer)
        }
        fmt.Println("Completed",prefix)
}

        程式建立了兩個goroutine,分別列印1~5000內的素數。查詢並顯示素數會消耗不少時間。這會讓排程器有機會在第一個goroutine找到所有素數之前,切換該goroutine的時間片。
        goroutine B先顯示素數。一旦goroutineB列印到素數4591,排程器就會將正執行的goroutine切換為goroutine A。之後goroutine A線上程上執行了一段時間,再次切換為goroutine B。這次goroutine B完成了所有的工作。一旦goroutine B返回,就會看到執行緒再次切換到goroutine A並完成所有的工作。
        如果給排程器分配多個邏輯處理器,我們會看到之前的示例程式的輸出行為會有些不同。下面是程式碼:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main(){
    runtime.GOMAXPROCS(2)//分配2個處理器給排程器使用

    //wg用來等待程式完成
    //計數加2,表示要等待兩個goroutine
    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("Start Goroutines")

    go func(){
        //在函式退出時呼叫Done來通知main函式工作已經完成
        defer wg.Done()

        for count:=0;count<3;count++{
            for char:='a';char<'a'+26;char++{
                fmt.Printf("%c ",char)
            }
        }
    }()

    go func(){
        //在函式退出時呼叫Done來通知main函式工作已經完成
        defer wg.Done()

        for count:=0;count<3;count++{
            for char:='A';char<'A'+26;char++{
                fmt.Printf("%c ",char)
            }
        }
    }()

    //等待goroutine結束
    fmt.Println("Waiting To Finish")
    wg.Wait()

    fmt.Println("\nTerminating Program")
}

       通過呼叫GOMAXPROCS函式建立了兩個邏輯處理器。這會讓goroutine並行執行。兩個goroutine幾乎是同時開始執行的,大小寫字母是混合在一起顯示的。這是在一臺2核的電腦上執行程式的輸出,所以每個goroutine獨自執行在自己的核上。只有在多個邏輯處理器且可以同時讓每個goroutine執行在一個可用的物理處理器上的時候,goroutine才會並行執行。