15 Go語言併發1——Goroutine

Go語言併發1——Goroutine

一、併發和並行

1.1 進程和線程

進程:程序啓動時(好比qq),操做系統位程序開啓一個進程。能夠把它看作是操做系統進行資源分配和調度的一個容器,裏面包含了該應用程序用到的全部資源。算法

線程:是一個獨立的執行空間,用來被系統調度來運行程序代碼。好比我下載文件,操做系統調度會安排到合適的cpu上進行執行,而且不必定是該程序進程所在的cpu。這個調度咱們不用關心。編程

也就是一個進程能夠有好多個線程,線程用來執行具體的任務。每一個進程的初始線程叫作主線程,因此進程至少有一個線程。安全

在這裏插入圖片描述

上圖,系統的調度器來調度線程在合適的cpu上運行。網絡

1.2 併發和並行的概念

併發:多線程或多協程在一核cpu上運行就是所謂的併發,都是cpu經過切換時間片給人一種併發的感受。多線程

並行:是真正意義上的併發,就是多核cpu同時去處理多個線程,互不干擾,並行處理。併發

併發不是並行:並行是讓不一樣的代碼片斷同時在不一樣的cpu上執行,利用多核的優點。併發是經過很是快切換時間片來實現「同時」運行。並行的關鍵是同時作不少的事情,而併發是指同時管理不少事情,這些事情可能只作了一半就暫停作別的任務。函數

總結:併發優於並行,能夠有效利用資源。go語言能夠利用多核,經過goroutine 高效併發。atom

1.3 go語言邏輯處理器和調度器瞭解

go語言經過一個叫作goroutine的東西進行併發執行,每個goroutine就是一個獨立的工做單元,能夠執行咱們的程序代碼。goroutine是相似協程(coroutine)的東西,能夠理解爲一個更輕量的線程。goroutine的具體使用後面再講,目前咱們來看一下go語言是如何經過goroutine實現併發的。spa

目前能夠理解爲:goroutine是個執行代碼的獨立工做單元,須要將它放到合適的線程和cpu上進行執行。這就須要go語言的邏輯處理器和調度器。操作系統

go語言中支撐整個scheduler實現的主要有4個重要結構,分別是M、G、P、Sched。

  • Sched結構就是調度器,它維護有存儲M和G的隊列以及調度器的一些狀態信息等。
  • M結構是Machine,系統線程,它由操做系統管理的,goroutine就是跑在M之上的;M是一個很大的結構,裏面維護小對象內存cache(mcache)、當前執行的goroutine、隨機數發生器等等很是多的信息。
  • P結構是Processor,邏輯處理器,它的主要用途就是用來執行goroutine的,它維護了一個goroutine隊列,即runqueue。Processor是讓咱們從N:1調度到M:N調度的重要部分。
  • G是goroutine實現的核心結構,它包含了棧,指令指針,以及其餘對調度goroutine很重要的信息,例如其阻塞的channel。

這裏借用一張圖來看下:

在這裏插入圖片描述

注意:go1.5以後爲每一個cpu建立一個邏輯處理器。

咱們從一個goroutine被建立到goroutine被執行的過程來看一下go是如何實現調度的。

(1)建立一個goroutine,它會放在全局運行隊列中,等待調度器調度

(2)調度器將這個goroutine 分配給一個邏輯處理器A,將它放到了這個邏輯處理器的本地隊列中,這個goroutine就會等待邏輯處理器A執行

(3)每一個邏輯處理器默認綁定了一個線程,它是在線程中去執行本身本地隊列中的goroutine。

  • 若是邏輯處理器目前運行的goroutine是阻塞的,好比打開文件操做。

(4)邏輯處理器和原來的線程分離,調度器從新建立一個線程和這個邏輯處理器綁定。這時候邏輯處理器在新的線程上繼續執行本地運行隊列的其餘goroutine。 同時,阻塞的goroutine隨着線程分離,從本地隊列移除。

(5)那個阻塞的goroutine和分離的線程會繼續阻塞,等待系統調用的返回。一旦執行完成並返回,這個goroutine就會從新放回到原來邏輯處理器的本地隊列。

(6)以前的線程目前沒有goroutine了,可是它會被保存,以備以後使用。

調度器對能夠建立的邏輯處理器的數量沒有限制,但語言運行時默認限制每一個程序最多建立 10 000 個線程。這個
限制值能夠經過調用 runtime/debug 包的 SetMaxThreads 方法來更改。

小結:

概念 說明
進程 一個程序對應的資源容器
線程 一個獨立的執行空間,一個進程能夠有多個線程
goroutine 一樣是獨立的執行空間,可是一個線程能夠有多個goroutine
邏輯處理器 綁定一個線程,運行goroutine
調度器 將goroutine分配到合適的邏輯處理器
全局運行隊列 全部剛建立的goroutine都在這
本地運行隊列 邏輯處理器的goroutine隊列

因此,咱們能夠利用多核cpu,調度器建立多個邏輯處理器,而後每一個邏輯處理器能夠綁定一個線程去運行多個goroutine。這樣咱們就充分利用了多核資源實現併發處理,比單純的多線程更加優秀,高效,省資源。

注意:上面第(4)(5)步,若是goroutine 是在執行網絡io的操做,這個goroutine就不必定就回到這個邏輯處理器了。它實際上會先從邏輯處理器分離,移到集成了網絡輪詢器的運行時 ,一旦該輪詢器指示某個網絡讀或者寫操做已經就緒,對應的 goroutine 就會從新分配到邏輯處理器上來完成操做 。

看到這咱們重溫下併發和並行:

go語言實現併發,建立多個goroutine,調度器會將goroutine分配到邏輯處理器的本地運行隊列,邏輯處理器去運行goroutine。若是隻有一個邏輯處理器,只會實現併發,不會實現並行。

要實現並行,就須要多個邏輯處理器,在不一樣的cpu上,而後調度器會平等的將goroutine分配到每一個邏輯處理器,這樣多個線程多個goroutine就實現了並行和併發。 至於這些算法怎麼調度,咱們根本不須要關心,咱們只要記住goroutine是咱們進行併發編程的一個獨立單元就能夠了。

二、goroutine使用

goroutine實際上是官方實現的超級「輕量線程池」。每一個實例4~5kb的佔內存佔用,更加輕量。只需在函數調⽤語句前添加 go 關鍵字,就可建立併發執⾏單元。開發⼈員⽆需瞭解任何執⾏細節,調度器會⾃動將其安排到合適的系統線程上執⾏。

2.1 go 關鍵字建立goroutine

package main
import (
    "fmt"
    "time"
)
func main() {
    //經過go 關鍵字 +匿名函數就能夠開啓一個goroutine
    go func() { 
        fmt.Println("Hello, World!")
    }()
    //因爲main函數也是一個goroutine,若是不讓線程等待,那麼main方法執行完,就退出了,還來不及打印helloworld
    time.Sleep(1 * time.Second)
}

2.2 簡單使用waitgroup同步

WaitGroup可以一直等到全部的goroutine執行完成,而且阻塞主線程的執行,直到全部的goroutine執行完成。

WaitGroup總共有三個方法:Add(delta int),Done(),Wait()。簡單的說一下這三個方法的做用。

方法名 說明
Add 添加或者減小等待goroutine的數量;
Done 至關於Add(-1),減小一個須要等待的goroutine數量
Wait 進行等待,需等待的goroutine數量爲0

WaitGroup用於線程同步,WaitGroup等待一組線程集合完成,纔會繼續向下執行。 主線程(goroutine)調用Add來設置等待的線程(goroutine)數量。 而後每一個線程(goroutine)運行,並在完成後調用Done。 同時,Wait用來阻塞,直到全部線程(goroutine)完成纔會向下執行。

package main
import (
    "fmt"
    "runtime"
    "sync"
)
func main() {
    runtime.GOMAXPROCS(1)//只使用1個物理處理器
    var wg sync.WaitGroup
    wg.Add(2) //添加須要等待goroutine數量
    fmt.Println("開啓兩個goroutine")
    go func() {
        defer  wg.Done()//函數結束時通知main函數執行完畢
        for i:=0;i<1000;i++{
            fmt.Println("A:",i)
        }
    }()
    go func() {
        defer wg.Done()//函數結束時通知main函數執行完畢
        for i := 0; i < 1000; i++ {
            fmt.Println("B:",i)
        }
    }()
    fmt.Println("等待goroutine運行")
    wg.Wait()
    fmt.Println("程序結束")
}

從上面的代碼咱們能夠看到,咱們用sync包下的waitgroup 來進行線程等待,避免main函數執行完來不及執行goroutine就退出的狀況。waitgroup詳情下面會講。咱們用go關鍵字+匿名函數 開啓了兩個goroutine,來併發的去打印,可是由於這個1000個打印速度太快了,還沒來得及切換goroutine就第一個就已經打印完了,因此你可能會輸出,A打印完了纔打印B這種順序輸出。咱們能夠增長一下打印時間,就能看到他們是併發打印的了。好比加個time.Sleep(time.Millisecond) 。 這段代碼中有一個runtime.GOMAXPROCS(1),這是go語言能夠指定程序運行使用的cpu核數,咱們能夠設置多個,來實現並行。

通常來講,經過runtime.GOMAXPROCS(runtime.NumCPU())能夠設置本機邏輯CPU的數量,不是物理CPU,好比一個雙核CPU,帶有超線程技術,則會被認爲是4個邏輯CPU。 runtime.Gosched () 可讓出底層線程,讓其餘goroutine 使用,runtime.Goexit 將當即終止當前goroutine 執行

runtime 小結:

runtime.GOMAXPROCS()  //設置使用的邏輯處理器數量
runtime.NumCPU()   //本地邏輯cpu的數量
runtime.Gosched()  // 將當前goroutine的線程讓給別的goroutine,本身進入運行隊列等待
runtime.Goexit()   //當即終止當前goroutine 運行
runtime.GOROOT()    //獲取go的根目錄
runtime.GOOS        // 獲取操做系統信息

2.3 WaitGroup 傳值問題

sync.WaitGroup 類型的變量是一個值類型,若是在函數間進行傳遞,是值傳遞,這樣執行Done()和 wait()方法就不是同一個 WaitGroup了,就會出現死鎖,因此傳遞時必須傳遞指針。 代碼以下:

package main
import (
    "fmt"
    "sync"
)
func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(wg *sync.WaitGroup, i int) {
            fmt.Printf("i=>%d\n", i)
            wg.Done()
        }(&wg, i) //這裏要傳指針就對了
    }
    wg.Wait()
}

三、資源競爭

若是兩個或者多個 goroutine 在沒有互相同步的狀況下,訪問某個共享的資源,並試圖同時讀和寫這個資源,就處於相互競爭的狀態,這種狀況被稱做競爭狀態(race condition)。 咱們要作的是:同一時刻只能有一個 goroutine 對共享資源進行讀和寫操做 。

package main
import (
    "fmt"
    "runtime"
    "sync"
)
var (
    count int
    wg sync.WaitGroup
)
func main(){
    wg.Add(2)
    //開啓兩個goroutine
    go incCount(1)
    go incCount(2)
    wg.Wait()
    fmt.Println("最終結果:",count)
}
//執行兩次  count++
func incCount(id int) {
    defer  wg.Done()
    for i:=0;i<2;i++{
        value:=count
        runtime.Gosched()
        value++
        count=value
    }
}
//輸出
最終結果: 2

從上面能夠看出,咱們開啓兩個goroutine,每一個goroutine,都執行了兩個value++並賦值給count,也就是說最終的結果應該是4,可是如今確是2。 毫無疑問,在對count 進行讀寫的時候,兩個goroutine進行了資源競爭,而且沒有同步。

在這裏插入圖片描述

程序運行就像圖中所示,兩個goroutine在進行切換的時候,並無同步count的數量,而且他們相互覆蓋了對方,致使各自有通常的工做白作了。

3.1 實用go 自帶的競爭監測命令-race

go run -race goDemo.go//  -race go自帶的競爭監測命令,能夠查看哪一行哪些方法有資源競爭。
==================
WARNING: DATA RACE
Read at 0x0000005fa2d0 by goroutine 7:
  main.incCount()
      D:/gopath/src/awesomeProject/goroutine/godemo.go:23 +0x76

Previous write at 0x0000005fa2d0 by goroutine 6:
  main.incCount()
      D:/gopath/src/awesomeProject/goroutine/godemo.go:26 +0x97

Goroutine 7 (running) created at:
  main.main()
      D:/gopath/src/awesomeProject/goroutine/godemo.go:16 +0x90

Goroutine 6 (finished) created at:
  main.main()
      D:/gopath/src/awesomeProject/goroutine/godemo.go:15 +0x6f
==================
最終結果: 4
Found 1 data race(s)
exit status 66

如何解決資源競爭和線程同步,這就有兩類,一類是傳統的方式——加鎖,另外一類是go語言有的經過chanel,採用csp模型,即經過通訊去共享內存,而不是經過共享內存而通訊。

四、資源同步傳統方式——加鎖

咱們要實現同一時間只能有一個goroutine對共享資源進行讀寫操做,go語言提供了傳統的解決方案,atomic和sync 包。 另外一種方式是使用channel,下一篇單獨講。

4.1原子函數atomic

atomic 包提供了一些函數來保證對資源的讀寫安全。好比LoadInt32 和 StoreInt32兩個函數,一個讀取int32類型的值,一個寫入int32類型的值。還有AddInt32()同步整型加法等,以下:

package main
import (
    "fmt"
    "runtime"
    "sync"
    "sync/atomic"
)
var (
    count int32
    wg sync.WaitGroup
)
func main(){
    wg.Add(2)
    go incCount(1)
    go incCount(2)
    wg.Wait()
    fmt.Println("最終結果:",count)
}
func incCount(id int) {
    defer  wg.Done()
    for i:=0;i<2;i++{
        // 安全地對 count 加 1
        atomic.AddInt32(&count, 1)
        runtime.Gosched()
    }
}

這時候執行結果是4,讀寫安全了。atomic雖然能夠解決資源競爭問題,可是比較都是比較簡單的,支持的數據類型也有限。因此,sync 提供了互斥鎖來解決。

4.2 互斥鎖 mutex

sync包裏提供了一種互斥型的鎖,可讓咱們本身靈活的控制哪些代碼,同時只能有一個goroutine訪問,被sync互斥鎖控制的這段代碼範圍,被稱之爲臨界區,臨界區的代碼,同一時間,只能有一個goroutine訪問。代碼以下:

package main
import (
    "fmt"
    "runtime"
    "sync"
)
var (
    count int32
    wg    sync.WaitGroup  
    mutex sync.Mutex  //聲明 mutex  互斥鎖變量
)

func main() {
    wg.Add(2)  //2個等待的goroutine
    go incCount()
    go incCount()
    wg.Wait()
    fmt.Println(count)
}

func incCount() {
    defer wg.Done() 
    for i := 0; i < 2; i++ {
        mutex.Lock()  //臨界區開始位置
        value := count
        runtime.Gosched()
        value++
        count = value
        mutex.Unlock()//臨界區結束位置
    }
}

咱們仍是使用 sync.WaitGroup 來進行等待兩個goroutine都執行完再推出main函數。 重點看咱們還聲明瞭一個

mutext sync.Mutex 這個互斥鎖,經過mutex.Lock()加鎖, mutex.Unlock()解鎖。它將中間的代碼塊造成一個臨界區,因此,這段代碼塊同時只能有一個goroutine進行操做,因此,goroutine1 將count賦值給value,讓出線程,此時goroutine2也沒法進入臨界區的代碼,等待goroutine1 執行完臨界區的代碼,goroutine2再進行執行。這樣就保證了資源的讀寫安全。

固然goroutine 同步還有更好,更簡單的方式,使用channel。即所謂的:經過通訊來共享內存,而不是經過共享內存來通訊。

相關文章
相關標籤/搜索