大話 goroutine

pardon110發表於2019-11-30

goroutine本質上是大號版的非同步執行控制程式碼,比之nodejs中的單執行緒事件迴圈處理器。之所以在使用goroutine,感覺不到非同步,在於golang已經封裝了各種非同步io操作,執行時一旦發現非同步io狀態發生改變,則適時進行goroutine切換。讓你基本上感覺不到像基於事件程式設計所帶來的直觀上的任務執行亂序。

啟動 VS 執行

goroutine 這種由執行時控制,構建於執行緒之上,又比執行緒粒度小的操作,作業系統一點也不care。通常分配給執行緒記憶體會在8M左右,goroutine則只有2kb,一個MB一個KB完全在不同級別,單單效能上就提高很多。結論就是,在任務排程上,goroutine是弱於執行緒的,但是在資源消耗上,goroutine則是極低的。

非同步程式設計為了追求程式的效能,強行的將線性的程式打亂,程式變得非常的混亂與複雜。對程式狀態的管理也變得異常困難。接觸網路程式設計的同學,很容易理解啟動與執行完全是兩個概念。

    sum()    // 同步執行普通函式,等待它執行完畢
go sum()   // 用go關鍵字啟動一個goroutine立即返回,它會非同步執行

換而言之,普通函式是即調即用,用go關鍵字修飾的函式只是啟動立即返回,在當前執行緒空閒的時候(main函式是一個特殊的goroutine,一旦它沒空,意味著你的gorotuine哪怕啟動了,也是作不出什麼妖來。)作非同步執行。更直白點,你看到的goroutine是它啟動的樣子,它的非同步執行由執行時所決定,至於groutine與goroutine執行順序,用俗話講他大爺還是他大爺,回到非同步執行流亂序的常態。

建立goroutine

示例使用了select 來阻塞主執行緒,若使用sleep,則本身是不知道多長時間是恰到好處。
太長浪費資源,太短則有可能所請求的網站還沒來得及響應,主執行緒就退出了,達不到請求目標網站的目的。

package main

import (
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "time"
)

func responseSize(url string) {
    fmt.Println("Step1: ", url)
    response, err := http.Get(url)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("Step2: ", url)
    defer response.Body.Close()

    fmt.Println("Step3: ", url)
    body, err := ioutil.ReadAll(response.Body)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Step4: ", len(body))
}

func main() {
        go responseSize("https://www.stackoverflow.com")
        go responseSize("https://www.shiyanlou.com")
        go responseSize("https://www.baidu.com")

        select {}
}

執行 go run main.go。檢視4個階段響應,位元組長度資訊,以三個響應速度不一,響應資源不等網站為例。每次print都有io切換(即goroutine在執行時指揮下自動切換)。很明顯goroutine 的輸出是無序的,與goroutine的啟動順序無關。

Step1:  https://www.baidu.com
Step1:  https://www.stackoverflow.com
Step1:  https://www.shiyanlou.com
Step2:  https://www.baidu.com
Step3:  https://www.baidu.com
Step4:  227
Step2:  https://www.shiyanlou.com
Step3:  https://www.shiyanlou.com
Step4:  196083
Step2:  https://www.stackoverflow.com
Step3:  https://www.stackoverflow.com
Step4:  116044

等待goroutine執行完畢

sync.WaitGroup 在此是同步阻塞主執行緒,等待一組goroutine執行完畢

Add 方法向 WaitGroup 例項設定預設計數,Done 減少一個,Wait 方法負責阻塞等待其它goroutine執行完畢。

...
func responseSize(url string) {  
    defer wg.Done()  
    ...
}

var wg sync.WaitGroup      
func  main() {       
    wg.Add(3)     
    ...
    wg.Wait()    
    fmt.Println("terminate Program")    
}  

從goroutine中取值

goroutine 間通常使用channel,類似於linux中的pipe管道,來實現通訊。

 package main

 import (
     "fmt"
     "io/ioutil"
     "log"
     "net/http"
     "sync"
 )

 var wg sync.WaitGroup

 func responseSize(url string, nums chan<- int) {
     defer wg.Done()
     response, err := http.Get(url)
     if err != nil {
         log.Fatal(err)
     }
     defer response.Body.Close()
     body, err := ioutil.ReadAll(response.Body)
     if err != nil {
         log.Fatal(err)
     }
     nums <- len(body)      // 將值寫入通道
 }

 func main() {
     nums := make(chan int)   // 宣告通道
     wg.Add(3)
     go responseSize("https://www.stackoverflow.com", nums)
     fmt.Println(<-nums)    //  傳送資料
     wg.Wait()    
     close(nums)   // 關閉通道

控制goroutine執行

使用通道可以控制goroutine的執行與暫停,通道方便goroutine間通訊

package main

import (
    "fmt"
    "sync"
    "time"
)

var i int

func work() {
    time.Sleep(250 * time.Millisecond)
    i++
    fmt.Println(i)
}

func routine(command <-chan string, wg *sync.WaitGroup) {
    defer wg.Done()
    var status = "Play"
    for {
        select {
        case cmd := <-command:
            fmt.Println(cmd)
            switch cmd {
            case "Stop":
                return
            case "Pause":
                status = "Pause"
            default:
                status = "Play"
            }
        default:
            if status == "Play" {
                work()
            }
        }
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    command := make(chan string)
    go routine(command, &wg)

    time.Sleep(1 * time.Second)
    command <- "Pause"

    time.Sleep(1 * time.Second)
    command <- "Play"

    time.Sleep(1 * time.Second)
    command <- "Stop"

    wg.Wait()
}

執行結果

1
2
3
4
Pause
Play
5
6
7
8
9
Stop

原子函式修復條件競爭

爭用條件是由於對共享資源的不同步訪問而引起。原子函式提供了用於同步訪問整數和指標的底層鎖定機制。同步包下的atomic中的函式透過鎖定對共享資源的訪問來提供支援同步goroutine的支援。

package main

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

var (
        counter int32
        wg      sync.WaitGroup
)

func main() {
        wg.Add(3)

        go increment("python")
        go increment("java")
        go increment("Golang")

        wg.Wait()
        fmt.Println("Counter:", counter)
}

func increment(name string) {
        defer wg.Done()

        for range name {
                atomic.AddInt32(&counter, 1)
                runtime.Gosched()
        }
}

使用原子函式對值時,它會強制一次有且僅一個goroutine能夠完成值修改操作。當別goroutine試圖呼叫任何原子函式時,會自動同步所對應引用的變數。

mutex定義臨界區

mutex通常被用來定義臨界區,確保該區域的程式碼一次只能被一個goroutine訪問執行。

package main

import (
        "fmt"
        "sync"
)

var (
        counter int32
        wg      sync.WaitGroup
        mutex   sync.Mutex
)

func main() {
        wg.Add(3)

        go increment("Python")
        go increment("Go Programming Language")
        go increment("Golang")

        wg.Wait()
        fmt.Println("Counter:", counter)
}

func increment(lang string) {
        defer wg.Done()

        for i := 0; i < 3; i++ {
                mutex.Lock()
                {
                        fmt.Println(lang)
                        counter++
                }
                mutex.Unlock()
        }
}

去掉鎖,執行下面語句,分析資料競爭

go run -race main.go

goroutine併發

解決資料爭用,需要確保一次請求時僅有一個goroutine在使用臨界資源。用訊息傳遞或用鎖機制。
每個gorotuine在自己的領域內都是一個獨立的個體,goroutine間的地位是平等的,沒有誰比誰更高貴。

訊息傳遞,你不用向我請求,我有了訊息會通知你。goroutine的啟動類似於到醫院掛號,院方會在合適的時候給你看病(讓goroutine執行)。golang的通道則典型用了這種訊息傳遞機制。

用鎖,簡單粗暴。其道理好比,將goroutine間原先對臨界資源的爭用,轉移到對鎖的搶奪上。有鎖就有權使用。存在的問題在於,如果搶鎖的人多,系統發鎖/收鎖需要將通知到即將使用臨界資源的所有人。成本太高,即便改進產生的變種讀寫鎖,條件鎖等,終歸太重了。

二者的區別,在於臨界資源的使用方。用鎖是使用者主動出手,干預共享資源。用訊息傳遞,一切聽指揮,你要用事先向上級報備,上級視情況通知你去用。典型的走過去,與送過來的關係。有點控制反轉的味道。

goroutine非同步,亂序執行其意義在於最大程度壓榨CPU效能。帶來的問題,如同nodejs回撥地獄般可惡,在調式程式設計不易理解。為了協同各個goroutine有效工作,需要一些同步手段。如其它語言併發程式設計中一般,go也搞出常規的原子函式,讀寫鎖,WaitGroup,條件鎖等同步,但這已經滿足不了需求(太麻煩),golang在語言級別整出了通道channel。

Context

細細思之,goroutine本質上相當於半獨立的行省,各行其是,會天下大亂。goroutine與groutine之間也存在從屬關係(父子共存),競爭關係(誰行誰上),互斥關係(有你沒我) 等。為了協調一組goroutine共同做大事,golang1.7之後將實驗性質的 context 包歸併了主庫,其主要功能在於產生一組context上下文樹,實現了一堆有關係的goroutine協同工作,比如A協程生了B,B又產生的C程。這種子子孫孫無窮盡也的趨勢,你就無法使用waitgroup(因其一開始對要啟動的goroutine數目不確定)。借鑑了linux父子程式,goroutine小號版的程式形式,context包提供了這種樹形關係,比如父goroutine掛了之前,會先通知其子掛,同理其子也會通知到時孫輩....

不僅如此,context 還提供了超時,截止日期,讓其從屬goroutine在符合待定條件下,自行消亡,而不致於一直佔著茅坑不拉屎,最後出現因瞬時請求量巨大,服務端積存了大量未死亡(預設超時時長比較大)的goroutine,而耗盡了資源,一不小心將你本來弱小的服務搞死的局面。

context當然也提供了一個value介面,用於儲存值。類似於vue的資料流下發,避免頻繁在多個goroutine間複製資料。其在web請求處理方面有積極意義,好比用過微信的token同學知道,其存在token作用域(token請求存在範圍限制0,用這個介面你可以儲存一些帶作用域的請求標識,這樣在web網路通訊時,就可基於請求轉發到不同組的goroutine進行一系列處理。

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