GO語言并發

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

預熱——用戶級線程和內核級線程

線程被分為兩類:用戶級線程(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 // 10ms

func 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 main

import (
    "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 main

import (
    "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:%d\n", prefix, i)
    }
    fmt.Printf("complete %s\n", prefix)
}

輸出結果:

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

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

package main

import (
    "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 main

import (
    "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 %d\n", 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 main

import (
    "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 : %d\n", 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 : 4000
Program end

互斥鎖

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

package main

import (
    "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 : %d\n", 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 main

import (
    "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 main

import "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 main

import (
    "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 main

import (
    "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 main

import (
    "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)
    }
}

有緩沖通道圖解:


?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容