天天看點

golang并發

goroutine

goroutine是Go并行設計的核心。goroutine說到底其實就是線程,但是它比線程更小,十幾個goroutine可能展現在底層就是五六個線程,Go語言内部幫你實作了這些goroutine之間的記憶體共享。執行goroutine隻需極少的棧記憶體(大概是4~5KB),當然會根據相應的資料伸縮。也正因為如此,可同時運作成千上萬個并發任務。goroutine比thread更易用、更高效、更輕便。

goroutine是通過Go的runtime管理的一個線程管理器。goroutine通過go關鍵字實作了,其實就是一個普通的函數。

go hello(a, b, c)
           

通過關鍵字go就啟動了一個goroutine。我們來看一個例子

package main
import (
    "fmt"
    "runtime"
)
func say(s string) {
    for i := 0; i < 5; i++ {
        runtime.Gosched()
        fmt.Println(s)
    }
}
func main() {
    go say("world") //開一個新的Goroutines執行
    say("hello") //目前Goroutines執行
}
// 以上程式執行後将輸出:
// hello
// world
// hello
// world
// hello
// world
// hello
// world
// hello
           

我們可以看到go關鍵字很友善的就實作了并發程式設計。 上面的多個goroutine運作在同一個程序裡面,共享記憶體資料,不過設計上我們要遵循:不要通過共享來通信,而要通過通信來共享。

goroutine的排程機制

Go runtime的排程器:

在了解Go的運作時的scheduler之前,需要先了解為什麼需要它,因為我們可能會想,OS核心不是已經有一個線程scheduler了嘛?

熟悉POSIX API的人都知道,POSIX的方案在很大程度上是對Unix process進場模型的一個邏輯描述和擴充,兩者有很多相似的地方。 Thread有自己的信号掩碼,CPU affinity等。但是很多特征對于Go程式來說都是累贅。 尤其是context上下文切換的耗時。另一個原因是Go的垃圾回收需要所有的goroutine停止,使得記憶體在一個一緻的狀态。垃圾回收的時間點是不确定的,如果依靠OS自身的scheduler來排程,那麼會有大量的線程需要停止工作。

單獨的開發一個GO得排程器,可以是其知道在什麼時候記憶體狀态是一緻的,也就是說,當開始垃圾回收時,運作時隻需要為當時正在CPU核上運作的那個線程等待即可,而不是等待所有的線程。

使用者空間線程和核心空間線程之間的映射關系有:N:1,1:1和M:N

N:1是說,多個(N)使用者線程始終在一個核心線程上跑,context上下文切換确實很快,但是無法真正的利用多核。

1:1是說,一個使用者線程就隻在一個核心線程上跑,這時可以利用多核,但是上下文switch很慢。

M:N是說, 多個goroutine在多個核心線程上跑,這個看似可以集齊上面兩者的優勢,但是無疑增加了排程的難度。

golang并發

Go的排程器内部有三個重要的結構:M,P,S

M:代表真正的核心OS線程,和POSIX裡的thread差不多,真正幹活的人

G:代表一個goroutine,它有自己的棧,instruction pointer和其他資訊(正在等待的channel等等),用于排程。

P:代表排程的上下文,可以把它看做一個局部的排程器,使go代碼在一個線程上跑,它是實作從N:1到N:M映射的關鍵。

golang并發

圖中看,有2個實體線程M,每一個M都擁有一個context(P),每一個也都有一個正在運作的goroutine。

P的數量可以通過GOMAXPROCS()來設定,它其實也就代表了真正的并發度,即有多少個goroutine可以同時運作。

圖中灰色的那些goroutine并沒有運作,而是出于ready的就緒态,正在等待被排程。P維護着這個隊列(稱之為runqueue),

Go語言裡,啟動一個goroutine很容易:go function 就行,是以每有一個go語句被執行,runqueue隊列就在其末尾加入一個

goroutine,在下一個排程點,就從runqueue中取出(如何決定取哪個goroutine?)一個goroutine執行。

為何要維護多個上下文P?因為當一個OS線程被阻塞時,P可以轉而投奔另一個OS線程!

圖中看到,當一個OS線程M0陷入阻塞時,P轉而在OS線程M1上運作。排程器保證有足夠的線程來運作是以的context P。

golang并發

圖中的M1可能是被建立,或者從線程緩存中取出。

當MO傳回時,它必須嘗試取得一個context P來運作goroutine,一般情況下,它會從其他的OS線程那裡steal偷一個context過來,

如果沒有偷到的話,它就把goroutine放在一個global runqueue裡,然後自己就去睡大覺了(放入線程緩存裡)。Contexts們也會周期性的檢查global runqueue,否則global runqueue上的goroutine永遠無法執行。

golang并發
golang并發

另一種情況是P所配置設定的任務G很快就執行完了(配置設定不均),這就導緻了一個上下文P閑着沒事兒幹而系統卻任然忙碌。但是如果global runqueue沒有任務G了,那麼P就不得不從其他的上下文P那裡拿一些G來執行。一般來說,如果上下文P從其他的上下文P那裡要偷一個任務的話,一般就‘偷’run queue的一半,這就確定了每個OS線程都能充分的使用。

channels

goroutine運作在相同的位址空間,是以通路共享記憶體必須做好同步。那麼goroutine之間如何進行資料的通信呢,Go提供了一個很好的通信機制channel。channel可以與Unix shell 中的雙向管道做類比:可以通過它發送或者接收值。這些值隻能是特定的類型:channel類型。定義一個channel時,也需要定義發送到channel的值的類型。注意,必須使用make 建立channel:

ci := make(chan int)
cs := make(chan string)
cf := make(chan interface{})
           

channel通過操作符<-來接收和發送資料

ch <- v    // 發送v到channel ch.
v := <-ch  // 從ch中接收資料,并指派給v
           

我們把這些應用到我們的例子中來:

package main
import "fmt"
func sum(a []int, c chan int) {
    total := 0
    for _, v := range a {
        total += v
    }
    c <- total  // send total to c
}
func main() {
    a := []int{7, 2, 8, -9, 4, 0}
    c := make(chan int)
    go sum(a[:len(a)/2], c)
    go sum(a[len(a)/2:], c)
    x, y := <-c, <-c  // receive from c
    fmt.Println(x, y, x + y)
}
           

預設情況下,channel接收和發送資料都是阻塞的,除非另一端已經準備好,這樣就使得Goroutines同步變的更加的簡單,而不需要顯式的lock。所謂阻塞,也就是如果讀取(value := <-ch)它将會被阻塞,直到有資料接收。其次,任何發送(ch<-5)将會被阻塞,直到資料被讀出。無緩沖channel是在多個goroutine之間同步很棒的工具。

Buffered Channels

上面我們介紹了預設的非緩存類型的channel,不過Go也允許指定channel的緩沖大小,很簡單,就是channel可以存儲多少元素。ch:= make(chan bool, 4),建立了可以存儲4個元素的bool 型channel。在這個channel 中,前4個元素可以無阻塞的寫入。當寫入第5個元素時,代碼将會阻塞,直到其他goroutine從channel 中讀取一些元素,騰出空間。

ch := make(chan type, value)
/*
value == 0 ! 無緩沖(阻塞)
value > 0 ! 緩沖(非阻塞,直到value 個元素)
*/
           

我們看一下下面這個例子,你可以在自己本機測試一下,修改相應的value值

package main
import "fmt"
func main() {
    c := make(chan int, 2)//修改2為1就報錯,修改2為3可以正常運作
    c <- 1
    c <- 2
    fmt.Println(<-c)
    fmt.Println(<-c)
}
    //修改為1報如下的錯誤:
    //fatal error: all goroutines are asleep - deadlock!
           

Range和Close

上面這個例子中,我們需要讀取兩次c,這樣不是很友善,Go考慮到了這一點,是以也可以通過range,像操作slice或者map一樣操作緩存類型的channel,請看下面的例子

package main
import (
    "fmt"
)
func fibonacci(n int, c chan int) {
    x, y := 1, 1
    for i := 0; i < n; i++ {
        c <- x
        x, y = y, x + y
    }
    close(c)
}
func main() {
    c := make(chan int, 10)
    go fibonacci(cap(c), c)
    for i := range c {
        fmt.Println(i)
    }
}
           

for i := range c能夠不斷的讀取channel裡面的資料,直到該channel被顯式的關閉。上面代碼我們看到可以顯式的關閉channel,生産者通過内置函數close關閉channel。關閉channel之後就無法再發送任何資料了,在消費方可以通過文法v, ok := <-ch測試channel是否被關閉。如果ok傳回false,那麼說明channel已經沒有任何資料并且已經被關閉。

記住應該在生産者的地方關閉channel,而不是消費的地方去關閉它,這樣容易引起panic

另外記住一點的就是channel不像檔案之類的,不需要經常去關閉,隻有當你确實沒有任何發送資料了,或者你想顯式的結束range循環之類的

Select

我們上面介紹的都是隻有一個channel的情況,那麼如果存在多個channel的時候,我們該如何操作呢,Go裡面提供了一個關鍵字select,通過select可以監聽channel上的資料流動。

select預設是阻塞的,隻有當監聽的channel中有發送或接收可以進行時才會運作,當多個channel都準備好的時候,select是随機的選擇一個執行的。

package main
import "fmt"
func fibonacci(c, quit chan int) {
    x, y := 1, 1
    for {
        select {
        case c <- x:
            x, y = y, x + y
        case <-quit:
            fmt.Println("quit")
            return
        }
    }
}
func main() {
    c := make(chan int)
    quit := make(chan int)
    go func() {
        for i := 0; i < 10; i++ {
            fmt.Println(<-c)
        }
        quit <- 0
    }()
    fibonacci(c, quit)
}
           

在select裡面還有default文法,select其實就是類似switch的功能,default就是當監聽的channel都沒有準備好的時候,預設執行的(select不再阻塞等待channel)。

select {
case i := <-c:
    // use i
default:
    // 當c阻塞的時候執行這裡
}
           

逾時

有時候會出現goroutine阻塞的情況,那麼我們如何避免整個程式進入阻塞的情況呢?我們可以利用select來設定逾時,通過如下的方式實作:

func main() {
    c := make(chan int)
    o := make(chan bool)
    go func() {
        for {
            select {
                case v := <- c:
                    println(v)
                case <- time.After(5 * time.Second):
                    println("timeout")
                    o <- true
                    break
            }
        }
    }()
    <- o
}
           

runtime goroutine

runtime包中有幾個處理goroutine的函數:

  • Goexit

    退出目前執行的goroutine,但是defer函數還會繼續調用

  • Gosched

    讓出目前goroutine的執行權限,排程器安排其他等待的任務運作,并在下次某個時候從該位置恢複執行。

  • NumCPU

    傳回 CPU 核數量

  • NumGoroutine

    傳回正在執行和排隊的任務總數

  • 《go web程式設計》
  • https://www.zhihu.com/question/20862617/answer/27964865
上一篇: Java多線程