Golang 入門 : goroutine(協程)


在操作系統中,執行體是個抽象的概念。與之對應的實體有進程、線程以及協程(coroutine)。協程也叫輕量級的線程,與傳統的進程和線程相比,協程的最大特點是 "輕"!可以輕松創建上百萬個協程而不會導致系統資源衰竭。
多數編程語言在語法層面並不直接支持協程,而是通過庫的方式支持。但是用庫的方式支持的功能往往不是很完整,比如僅僅提供輕量級線程的創建、銷毀和切換等能力。如果在這樣的協程中調用一個同步 IO 操作,比如網絡通信、本地文件讀寫,都會阻塞其他的並發執行的協程,從而無法達到輕量級線程本身期望達到的目標。

goroutine

Golang 在語言級別支持協程,稱之為 goroutine。Golang 標准庫提供的所有系統調用操作(包括所有的同步 IO 操作),都會出讓 CPU 給其他 goroutine。這讓 goroutine 的切換管理不依賴於系統的線程和進程,也不依賴於 CPU 的核心數量,而是交給 Golang 的運行時統一調度。

goroutine 是 Golang 中並發設計的核心,更多關於並發的概念,請參考《Golang 入門 : 理解並發與並行》。 本文接下來的部分着重通過 demo 介紹 goroutine 的用法。

入門 demo

要在一個協程中運行函數,直接在調用函數時添加關鍵字 go 就可以了:

package main

import (
    "time"
    "fmt"
)

func say(s string) {
    for i := 0; i < 3; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("hello world")
    time.Sleep(1000 * time.Millisecond)
    fmt.Println("over!")
}

執行上面的代碼,輸出結果為:

hello world
hello world
hello world
over!

至於為什么要在 main 函數中調用 Sleep,如何用優雅的方式代替 Sleep,請參考《Golang 入門 : 等待 goroutine 完成任務》一文。

goroutine 的生命周期

讓我們通過下面的 demo 來理解 goroutine 的生命周期:

package main

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

func main() {
    // 分配一個邏輯處理器給調度器使用
    runtime.GOMAXPROCS(1)

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

    fmt.Println("Start Goroutines")

    // 聲明一個匿名函數,並創建一個goroutine
    go func(){
        // 在函數退出時調用Done來通知main函數工作已經完成
        defer wg.Done()

        // 顯示字母表3次
        for count := 0; count< 3; count++{
            for char := 'a'; char< 'a'+26; char++{
                fmt.Printf("%c ", char)
            }
            fmt.Println()
        }
    }()
    // 聲明一個匿名函數,並創建一個goroutine
    go func(){
        // 在函數退出時調用Done來通知main函數工作已經完成
        defer wg.Done()

        // 顯示字母表3次
        for count := 0; count< 3; count++{
            for char := 'A'; char< 'A'+26; char++{
                fmt.Printf("%c ", char)
            }
            fmt.Println()
        }
    }()

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

    fmt.Println("Terminating Program")
}

在 demo 的起始部分,通過調用 runtime 包中的 GOMAXPROCS 函數,把可以使用的邏輯處理器的數量設置為 1。
接下來通過 goroutine 執行的兩個匿名函數分別輸出三遍小寫字母和三遍大寫字母。運行上面代碼,輸出的結果如下:

Start Goroutines
Waiting To Finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
a b c d e f g h i j k l m n o p q r s t u v w x y z
a b c d e f g h i j k l m n o p q r s t u v w x y z
a b c d e f g h i j k l m n o p q r s t u v w x y z
Terminating Program

第一個 goroutine 完成所有任務的時間太短了,以至於在調度器切換到第二個 goroutine 之前,就完成了所有任務。這也是為什么會看到先輸出了所有的大寫字母,之后才輸出小寫字母。我們創建的兩個 goroutine 一個接一個地並發運行,獨立完成顯示字母表的任務。
因為 goroutine 以非阻塞的方式執行,它們會隨着程序(主線程)的結束而消亡,所以我們在 main 函數中使用 WaitGroup 來等待兩個 goroutine 完成他們的工作,更多 WaitGroup 相關的信息,請參考《Golang 入門 : 等待 goroutine 完成任務》一文。

基於調度器的內部算法,一個正運行的 goroutine 在工作結束前,可以被停止並重新調度。調度器這樣做的目的是防止某個 goroutine 長時間占用邏輯處理器。當 goroutine 占用時間過長時,調度器會停止當前正運行的 goroutine,並給其他可運行的 goroutine 運行的機會。
讓我們通過下圖來理解這一場景(下圖來自互聯網):

  • 在第 1 步,調度器開始運行 goroutine A,而 goroutine B 在運行隊列里等待調度。
  • 在第 2 步,調度器交換了 goroutine A 和 goroutine B。由於 goroutine A 並沒有完成工作,因此被放回到運行隊列。
  • 在第 3 步,goroutine B 完成了它的工作並被系統銷毀。這也讓 goroutine A 繼續之前的工作。

讓我們通過一個運行時間長些的任務來觀察該行為,運行下面的 代碼:

package main

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

func main() {
    // wg用來等待程序完成
    var wg sync.WaitGroup

    // 分配一個邏輯處理器給調度器使用
    runtime.GOMAXPROCS(1)

    // 計數加2,表示要等待兩個goroutine
    wg.Add(2)

    // 創建兩個goroutine
    fmt.Println("Create Goroutines")
    go printPrime("A", &wg)
    go printPrime("B", &wg)

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

    fmt.Println("Terminating Program")
}

// printPrime 顯示5000以內的素數值
func printPrime(prefix string, wg *sync.WaitGroup){
    // 在函數退出時調用Done來通知main函數工作已經完成
    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 內的素數,輸出的結果比較長,精簡如下:

Create Goroutines
Waiting To Finish
B:2
B:3
...
B:3851          
A:2             ** 切換 goroutine
A:3
...
A:4297          
B:3853          ** 切換 goroutine
...
B:4999
Completed B
A:4327          ** 切換 goroutine
...
A:4999
Completed A
Terminating Program

上面的輸出說明:goroutine B 先執行,然后切換到 goroutine A,再切換到 goroutine B 運行至任務結束,最后又切換到 goroutine A,運行至任務結束。注意,每次運行這個程序,調度器切換的時間點都會稍有不同。

讓 goroutine 並行執行

前面的兩個示例,通過設置 runtime.GOMAXPROCS(1),強制讓 goroutine 在一個邏輯處理器上並發執行。用同樣的方式,我們可以設置邏輯處理器的個數等於物理處理器的個數,從而讓 goroutine 並行執行(物理處理器的個數得大於 1)。
下面的代碼可以讓邏輯處理器的個數等於物理處理器的個數:

runtime.GOMAXPROCS(runtime.NumCPU())

其中的函數 NumCPU 返回可以使用的物理處理器的數量。因此,調用 GOMAXPROCS 函數就為每個可用的物理處理器創建一個邏輯處理器。注意,從 Golang 1.5 開始,GOMAXPROCS 的默認值已經等於可以使用的物理處理器的數量了。
修改上面輸出素數的程序:

runtime.GOMAXPROCS(2)

因為我們只創建了兩個 goroutine,所以邏輯處理器的數量設置為 2 就可以了,重新運行該程序,看看是不是 A 和 B 的輸出混合在一起了:

...
B:1741
B:1747
A:241
A:251
B:1753
A:257
A:263
A:269
A:271
A:277
B:1759
A:281
...

除了這個 demo 程序,在真實場景中這種並行的方式會帶來很多數據同步的問題。接下來我們將介紹如何來解決數據的同步問題。

參考:
《Go語言實戰》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM