GO語言併發

dead_lee發表於2021-09-09

理解併發和並行
併發:同時管理多件事情。
並行:同時做多件事情。表示同時發生了多件事情,透過時間片切換,哪怕只有單一的核心,也可以實現“同時做多件事情”這個效果。

預熱——使用者級執行緒和核心級執行緒

執行緒被分為兩類:使用者級執行緒(User Level Thread)和核心級執行緒(Kernal Level Thread)

使用者級執行緒

· 使用者級執行緒只存在於使用者空間,有關它的建立、排程和管理工作都由使用者級執行緒庫來支援。使用者級執行緒庫是用於使用者級執行緒管理的例程包,支援執行緒的建立、終止,以及排程執行緒的執行並儲存和恢復執行緒的上下文,這些操作都在使用者空間執行,無需核心的支援。
· 由於核心無法感知使用者級執行緒的存在,因此核心是以程式為單位進行排程的。當核心排程一個程式執行時,使用者級執行緒庫排程該程式的一個執行緒執行,如果時間片允許,該程式的其他執行緒也可能被執行。即該程式的多個執行緒共享該程式的執行時間片。
· 若該程式的一個執行緒進行IO操作,則該執行緒呼叫系統呼叫進入核心,啟動IO裝置後,核心會把該程式阻塞,並把CPU交給其他程式。即使被阻塞的程式的其他執行緒可以執行,核心也不會發現這一情況。在該程式的狀態變為就緒前,核心不會排程它執行。屬於該程式的執行緒都不可能執行,因而使用者級執行緒的並行性會受到一定的限制。

核心級執行緒

· 核心級執行緒的所有建立、排程及管理操作都由作業系統核心完成,核心儲存執行緒的狀態及上下文資訊。
· 當一個執行緒引起阻塞的系統呼叫時,核心可以排程程式的其他執行緒執行,多處理器系統上,核心分派屬於同一程式的多個執行緒在多個處理器上執行,提升程式的並行度。
· 核心管理執行緒效率比使用者態管理執行緒慢的多。

作業系統的三種執行緒模型
1.多對一模型

圖片描述

多對一模型


允許將多個使用者級執行緒對映到一個核心執行緒。執行緒管理是在使用者空間進行的,效率比較高。如果有一個執行緒執行了阻塞系統呼叫,那麼整個程式就會阻塞。所以任意時刻只允許一個執行緒訪問核心,這樣多個執行緒不能並行執行在多處理器上。雖然多對一模型對建立使用者級執行緒的數目並沒有限制,但這些執行緒在同一時刻只能有一個被執行。

2.一對一模型

圖片描述

一對一模型


每個使用者執行緒對映到一個核心執行緒。當一個執行緒執行阻塞系統呼叫,該模型允許另一個執行緒繼續執行。這樣提供了更好的併發功能。該模型也允許多個執行緒執行在多核處理器上。一對一模型可以獲得高併發性,但因耗費資源而使執行緒數會受到限制。

3.多對多模型

圖片描述

多對多模型


在多對一模型和一對一模型中取了個折中,克服了多對一模型的併發度不高的缺點,又克服了一對一模型的一個使用者程式佔用太多核心級執行緒,開銷太大的缺點。又擁有多對一模型和一對一模型各自的優點,可謂集兩者之所長。

GO併發排程模型——G-P-M模型

GO可以使用如下方式建立一個"執行緒"(GO語言中所謂的goroutine)。

go func(paramName paramType, ...){  //函式體}(param, ...)

等價於Java程式碼

new java.lang.Thread(() -> { 
    // do something in one new thread
}).start();

G-P-M模型圖解


圖片描述

G-P-M模型


其圖中的G, P和M都是Go語言執行時系統(其中包括記憶體分配器,併發排程器,垃圾收集器等元件,可以想象為Java中的JVM)抽象出來概念和資料結構物件。
G:G就是goroutine,透過go關鍵字建立,封裝了所要執行的程式碼邏輯,可以稱為是使用者執行緒。屬於使用者級資源,對OS透明,具備輕量級,可以大量建立,上下文切換成本低等特點。

P:Processor即邏輯處理器,預設GO執行時的Processor數量等於CPU數量,也可以透過GOMAXPROCS函式指定P的數量。P的主要作用是管理G執行,每個P擁有一個本地佇列,併為G在M上的執行提供本地化資源。

M:是作業系統建立的系統執行緒,作用就是執行G中包裝的併發任務,被稱為物理處理器。其屬於OS資源,可建立的數量上也受限了OS,通常情況下G的數量都多於活躍的M的。Go執行時排程器將G公平合理的安排到多個M上去執行。

·G和M的關係:G是要執行的邏輯,M具體執行G的邏輯。Java中Thread實際上就是對M的封裝,透過指定run()函式指定要執行的邏輯。GO語言中講二者分開,透過P建立G和M的聯絡從而執行。
·G和P的關係:P是G的管理者,P將G交由M執行,並管理一定系統資源供G使用。一個P管理儲存在其本地佇列的所有G。P和G是1:n的關係。
·P和M的關係:P和M是1:1的關係。P將管理的G交由M具體執行,當遇到阻塞時,P可以與M解綁,並找到空閒的M進行繫結繼續執行佇列中其他可執行的G。

問題:為什麼要有P?

G是對需要執行的程式碼邏輯的封裝,M具體執行G,P存在的意義是什麼?
Go語言執行時系統早期(Go1.0)的實現中並沒有P的概念,Go中的排程器直接將G分配到合適的M上執行。但這樣帶來了很多問題,例如,不同的G在不同的M上併發執行時可能都需向系統申請資源(如堆記憶體),由於資源是全域性的,將會由於資源競爭造成很多系統效能損耗。
Go 1.1起執行時系統加入了P,讓P去管理G物件,M要想執行G必須先與一個P繫結,然後才能執行該P管理的G。P物件中預先申請一些系統資源作為本地資源,G需要的時候先向自己的P申請(無需鎖保護),如果不夠用或沒有再向全域性申請,而且從全域性拿的時候會多拿一部分,以供後面高效的使用。
P的存在解耦了G和M,當M執行的G被阻塞時,P可以繫結到其他M上繼續執行其管理的G,提升併發效能。

GO排程過程

①建立一個goroutine,排程器會將其放入全域性佇列。
②排程器為每個goroutine分配一個邏輯處理器。並放到邏輯處理器的本地佇列中。
③本地佇列中的goroutine會一直等待直到被邏輯處理器執行。

func task1() {    go task2()
    go task3()}

假設現在task1在稱為G1的goroutine中執行,並在執行過程中建立兩個新的goroutine,新建立的兩個goroutine將會被放到全域性佇列中,排程器會再將他們分配給合適的P。

問題:如果遇到阻塞的情況怎麼處理?

假設正在執行的goroutine要執行一個阻塞的系統呼叫,如開啟一個檔案,在這種情況下,這個M將會被核心排程器排程出CPU並處於阻塞狀態。相應的與M相關聯的P的本地佇列中的其他G將無法被執行,但Go執行時系統的一個監控執行緒(sysmon執行緒)能探測到這樣的M,將M與P解綁,M將繼續被阻塞直到系統呼叫返回。P則會尋找新的M(沒有則建立一個)與之繫結並執行剩餘的G。
當之前被阻塞的M得到返回後,相應的G將會被放回本地佇列,M則會儲存好,等待再次使用。


圖片描述

goroutine阻塞處理

問題:GO有時間片概念嗎?

和作業系統按時間片排程執行緒不同,Go並沒有時間片的概念。如果一個G沒有發生阻塞的情況(如系統呼叫或阻塞在channel上),M是如何讓G停下來並排程下一個G的呢?
G是被搶佔排程的。GO語言執行時,會啟動一個名為sysmon的M,該M無需繫結P。sysmon每20us~10ms啟動一次,按照《Go語言學習筆記》中的總結,sysmon主要完成如下工作:

1.回收閒置超過5分鐘的span實體記憶體
2.如果超過2分鐘沒有垃圾回收,強制執行
3.向長時間執行的G任務發出搶佔排程
4.收回因syscall長時間阻塞的P
5.將長時間未處理的netpoll結果新增到任務佇列

注:go的記憶體分配也是基於兩種粒度的記憶體單位:span和object。span是連續的page,按page的數量進行歸類,比如分為2個page的span,4個page的span等。object是span中按預設大小劃分的塊,也是按大小分類。同一個span中,只有一種型別大小的object。
sysmom的大致工作思路:

//$GOROOT/src/runtime/proc.go// main方法func main() {
     ... ...
    systemstack(func() {
        newm(sysmon, nil)
    })
    .... ...
}func sysmon() {    // 回收閒置超過5分鐘的span實體記憶體
    scavengelimit := int64(5 * 60 * 1e9)
    ... ...    if  .... {
        ... ...        // 如果P因syscall阻塞
        //透過retake方法搶佔G
        if retake(now) != 0 {
            idle = 0
        } else {
            idle++
        }
       ... ...
    }
}

搶佔方法retake

// forcePreemptNS是指定的時間片,超過這一時間會嘗試搶佔const forcePreemptNS = 10 * 1000 * 1000 // 10msfunc retake(now int64) uint32 {
          ... ...           // 實施搶佔
            t := int64(_p_.schedtick)            if int64(pd.schedtick) != t {
                pd.schedtick = uint32(t)
                pd.schedwhen = now                continue
            }            if pd.schedwhen+forcePreemptNS > now {                continue
            }
            preemptone(_p_)
         ... ...
}

可以看出,如果一個G任務執行10ms,sysmon就會認為其執行時間太久而發出搶佔式排程的請求。

goroutine

1.gouroutine切換
package mainimport (    "runtime"
    "sync"
    "fmt")func main() {    //指定排程器所能排程的邏輯處理器數量
    runtime.GOMAXPROCS(1)    //使用wg等待程式完成
    var wg sync.WaitGroup    //計數器+2,等待兩個goroutine
    wg.Add(2)

    fmt.Println("Start GoRoutine")    //宣告一個匿名函式,使用go關鍵字建立goroutine
    go func() {        //函式退出時透過Done通知main函式工作已經完成
        defer wg.Done()        for i := 1; i <= 3; i++ {            for char := 'a'; char < 'a' + 26; char++ {
                fmt.Printf("%c ", char)
            }
        }
    }()    go func() {

        defer wg.Done()        for i := 1; i <= 3; i++ {            for char := 'A'; char < 'A' + 26; char++ {
                fmt.Printf("%c ", char)
            }
        }
    }()    //等待goroutine結束
    fmt.Println("Waiting for finish")
    wg.Wait()

    fmt.Println("Program end")
}

輸出:

Start GoRoutine
Waiting for 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 
Program end

這個程式給人的感覺是序列的,原因是當排程器還沒有準備切換列印小寫字母的goroutine時,列印大寫字母的goroutine就執行完了。
修改下,列印6000以內的素數。

package mainimport (    "sync"
    "runtime"
    "fmt")var wg sync.WaitGroup

func main(){

    runtime.GOMAXPROCS(1)

    wg.Add(2)    go printPrime("A")
    go printPrime("B")

    fmt.Println("Wait for finish")
    wg.Wait()
    fmt.Println("Program End")}

func printPrime(prefix string){

    defer wg.Done()

    nextNum:    for i := 2; i < 6000; i++ {        for j := 2; j < i; j++ {            if i % j == 0 {                continue nextNum
            }
        }
        fmt.Printf("%s:%dn", prefix, i)
    }
    fmt.Printf("complete %sn", prefix)
}

輸出結果:

Wait for finishB:2B:3B:5B:7B:11...B:457B:461B:463B:467A:2A:3A:5A:7...A:5981A:5987complete AB:5939B:5953B:5981B:5987complete BProgram End

在列印素數的過程中,goroutine的輸出是混在一起的,由於runtime .GOMAXPROCS(1),可以看出兩個G是在一個P上併發執行的。

package mainimport (    "runtime"
    "sync"
    "fmt")func main() {

    runtime.GOMAXPROCS(2)

    var wgp sync.WaitGroup

    wgp.Add(2)

    fmt.Println("Start Goroutines")    //第一個goroutine列印3遍小寫字母
    go func() {

        defer wgp.Done()        for i := 0; i < 3; i++ {            for char := 'a'; char < 'a'+26; char++ {
                fmt.Printf("%c ", char)
            }
        }
    }()    //第二個goroutine列印三遍大寫字母
    go func() {

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

    fmt.Println("Waiting for finish")
    wgp.Wait()
    fmt.Println("nAll finish")
}

輸出:

Start Goroutines
Waiting for 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 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 M N O P Q R S T U V 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 W X Y Z n o p q r s t u v w x y z 
All finish

設定兩個P,短時間內會有類似上面列印0-6000所有素數的效果,證明兩個goroutine是並行執行的,但只有在多個P且每個可以同時讓每個G執行再一個可用的M上時,G才會達到並行的效果。

競爭狀態

多個goroutine再沒有同步的情況下,同時讀寫某個共享資源就會產生競爭狀態。對一個資源的讀寫必須是原子化的,即同一時刻只能有一個goroutine進行讀寫操作。
包含競爭狀態的例項:

package mainimport (    "sync"
    "runtime"
    "fmt")var (
    counter int
    wgs sync.WaitGroup
)func main(){

    wgs.Add(2)    go incrCounter()
    go incrCounter()

    fmt.Println("adding")
    wgs.Wait()
    fmt.Printf("now counter is %dn", counter)}//非原子操作加法func incrCounter()  {

    defer wgs.Done()    for i := 1; i <= 2000; i++ {
        val := counter        //讓出處理器
        runtime.Gosched()

        val++

        counter = val
    }
}

輸出:

adding
now counter is 2000

每次讀取完count後存入副本,手動讓出M,再次輪到G執行時會將之前的副本的值進行增1的操作,覆蓋了另一個goroutine的操作。


圖片描述

goroutine同步的幾種方式

①原子函式

原子函式以作業系統底層的枷鎖機制同步訪問變數,使用原子鎖方式修改之前的程式碼:

package mainimport (    "sync"
    "fmt"
    "sync/atomic"
    "runtime")var (
    counter2 int64
    wg2 sync.WaitGroup
)func main()  {

    wg2.Add(2)    go incrCounterAtomic()
    go incrCounterAtomic()

    fmt.Println("adding...")
    wg2.Wait()
    fmt.Printf("now counter is : %dn", counter2)
    fmt.Println("Program end")}

func incrCounterAtomic()  {

    defer wg2.Done()    for i := 1; i <= 2000; i++ {
        atomic.AddInt64(&counter2, 1)
        runtime.Gosched()
    }
}

輸出:

adding...
now counter is : 4000Program end

互斥鎖

互斥鎖用於在程式碼當中建立一個臨界區,保證同一時間只有一個G執行臨界區的程式碼。

package mainimport (    "sync"
    "fmt"
    "runtime")var (
    counter3 int
    wg3 sync.WaitGroup
    mutex sync.Mutex
)func main()  {

    wg3.Add(2)    go incrCounterMutex()
    go incrCounterMutex()

    fmt.Println("Adding...")
    wg3.Wait()
    fmt.Printf("now counter is : %dn", counter3)}

func incrCounterMutex()  {

    defer wg3.Done()    for i := 1; i <= 2000; i++ {        //建立臨界區
        mutex.Lock()
        {

            val := counter3

            runtime.Gosched()

            val++

            counter3 = val

        }
        mutex.Unlock()
    }
}
通道

資源再goroutine之間共享時,可以使用通道實現同步。宣告通道時,需要指定將要被共享的資料型別,可以透過通道共享內建型別、命名型別、結構型別、引用型別的值或指標。GO語言使用make函式建立通道。

①無緩衝通道

無緩衝通道使用make(chan type)宣告,通道中存取訊息都是阻塞的。
樣例:

func main() {
    var messages chan string = make(chan string)
    go func(message string) {
        messages <- message // 存訊息
    }("hello!")

    fmt.Println(<-messages) // 取訊息}

阻塞即無緩衝的通道道在取訊息和存訊息的時候都會掛起當前的goroutine,除非另一端已經準備好。例:

package mainimport (    "fmt"
    "strconv")var ch chan int = make(chan int)func main() {    go input(0)
    <- ch
    fmt.Println("main finish")}

func input(i int) {    for i := 0; i < 10; i++ {
        fmt.Print(strconv.Itoa(i) + " ")
    }
    ch <- i
}

嘗試註釋掉ch的輸入和輸出,對比執行結果

不註釋:
0 1 2 3 4 5 6 7 8 9 main finish
註釋:
main finish

如果不用通道來阻塞主線的話,主線就會過早跑完,loop線將沒有機會執行。

無緩衝的通道永遠不會儲存資料,只負責資料的流通。

如果從無緩衝通道讀資料,必須要有資料寫入通道,否則讀取操作的goroutine將一直阻塞。
如果向無緩衝通道寫資料,必須要有其他goroutine讀取,否則該goroutine將一直被阻塞。
如果無緩衝通道中有資料,再向其寫入,或者從無流入的通道資料中讀取,則會形成死鎖。


圖片描述

死鎖

為什麼會死鎖?非緩衝通道上如果發生了流入無流出,或者流出無流入,也就導致了死鎖。或者這樣理解 Go啟動的所有goroutine裡的非緩衝通道一定要一個線裡存資料,一個線裡取資料,要成對才行 。

c, quit := make(chan int), make(chan int)go func() {
   c <- 1  // c通道的資料沒有被其他goroutine讀取走,堵塞當前goroutine
   quit <- 0 // quit始終沒有辦法寫入資料}()

<- quit // quit 等待資料的寫

是否所有不成對向通道存取資料的情況都是死鎖?反例:

package main

var ch chan int = make(chan int)func main() {    go input(0)}

func input(i int) {
    ch <- i
}

通道ch中只有流入沒有流出,但執行不會報錯。原因是main沒等待其它goroutine,自己先跑完了, 所以沒有資料流入c通道,一共執行了一個goroutine, 並且沒有發生阻塞,所以沒有死鎖錯誤。
②緩衝通道
緩衝通道不僅可以流通資料,還可以快取資料。它是有容量的,存入一個資料的話 , 可以先放在通道里,不必阻塞當前線而等待該資料取走。但是當緩衝通道達到滿的狀態的時候,就會表現出阻塞了。

package main

var ch chan int = make(chan int, 3)func main() {
    ch <- 1
    ch <- 1
    ch <- 1
    //ch <- 1}

如果是非緩衝通道,這段程式碼會報死鎖。但是當有緩衝的通道時,程式碼正常執行。如果再加入一行資料流入,才會報死鎖。
通道可以看做是一個先進先出的佇列。

package mainimport "fmt"var ch chan int = make(chan int, 3)func main() {
    ch <- 1
    ch <- 2
    ch <- 3
    
    fmt.Println(<- ch)
    fmt.Println(<- ch)
    fmt.Println(<- ch)
}

會按照資料寫入的順序依次讀取

1
2
3
通道的其他基本操作

除了<-外,range也可以進行讀取。

package mainimport (    "fmt")var ch chan int = make(chan int, 3)func main() {
    ch <- 1
    ch <- 2
    ch <- 3

    for v := range ch {
        fmt.Println(v)
    }
}

輸出

1
2
3
fatal error: all goroutines are asleep - deadlock!

可以讀取但產生死鎖,原因是range不等到通道關閉不結束讀取,在沒有資料流入的情況下,產生死鎖。有兩種方式可以避免這一情況。
1.通道長度為0即結束

package mainimport (    "fmt")var ch chan int = make(chan int, 3)func main() {
    ch <- 1
    ch <- 2
    ch <- 3

    for v := range ch {
        fmt.Println(v)        if len(ch) == 0 {            break
        }
    }
}

2.手動關閉通道
關閉通道只是關閉了向通道寫入資料,但可以從通道讀取。

package mainimport (    "fmt")var ch chan int = make(chan int, 3)func main() {
    ch <- 1
    ch <- 2
    ch <- 3

    close(ch)    
    for v := range ch {
        fmt.Println(v)
    }
}

有緩衝通道圖解:


圖片描述



作者:Chuck_Hu
連結:


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4686/viewspace-2819643/,如需轉載,請註明出處,否則將追究法律責任。

相關文章