Go并發

并發和并行

Go是并發語言,而不是并行語言。(Go is a concurrent language and not a parallel one.)

并發(Concurrency)的關鍵在于你有處理多個任務的能力,不一定要同時
并行(Parallellism)的關鍵是你有同時處理多個任務的能力
最關鍵的點就是:是否是『同時』

下面舉個例子來說明:
假設我們正在編寫一個web瀏覽器。web瀏覽器有各種組件。其中兩個是web頁面呈現區域和下載文件從internet下載的下載器。假設我們以這樣的方式構建了瀏覽器的代碼,這樣每個組件都可以獨立地執行。
當這個瀏覽器運行在單個核處理器中時,處理器將在瀏覽器的兩個組件之間進行上下文切換。它可能會下載一個文件一段時間,然后它可能會切換到呈現用戶請求的網頁的html。這就是所謂的并發性。并發進程從不同的時間點開始,它們的執行周期重疊。在這種情況下,下載和呈現從不同的時間點開始,它們的執行重疊。
假設同一瀏覽器運行在多核處理器上。在這種情況下,文件下載組件和HTML呈現組件可能同時在不同的內核中運行。這就是所謂的并行性

并發與并行

需要說明的是:并行性Parallelism不會總是導致更快的執行時間,這是因為并行運行的組件可能需要相互通信。

進程,線程和協程

  1. 進程(Process)
    進程是可并發執行的程序在某個數據集合上的一次計算活動,也是操作系統進行資源分配和調度的基本單位。進程一般由程序、數據集、進程控制塊三部分組成。進程的局限是創建、撤銷和切換的開銷比較大。

  2. 線程(Thread)
    線程是操作系統進程中能夠并發執行的實體,是處理器調度和分派的基本單位。每個進程內可包含多個可并發執行的線程。線程的優點是減小了程序并發執行時的開銷,提高了操作系統的并發性能,缺點是線程自己基本不擁有系統資源,只擁有少量必不可少的資源:程序計數器、一組寄存器、棧。同屬一個進程的線程共享進程所擁有的主存空間和資源。

  3. 協程(Coroutine)
    協程是一種用戶態的輕量級線程,又稱微線程,英文名Coroutine,協程的調度完全由用戶控制。人們通常將協程和子程序(函數)比較著理解。 子程序調用總是一個入口,一次返回,一旦退出即完成了子程序的執行。

    Go語言對于并發的實現是靠協程:Goroutine。

Go的并發調度:G-P-M模型

在操作系統提供的內核線程之上,Go搭建了一個特有的兩級線程模型。goroutine機制實現了M : N的線程模型,goroutine機制是協程(coroutine)的一種實現,golang內置的調度器,可以讓多核CPU中每個CPU執行一個協程。

1. 調度器如何工作

// 用go關鍵字加上一個函數(這里用了匿名函數)
// 調用就做到了在一個新的“線程”并發執行任務
go func() { 
// do something in one new goroutine
}()

調度器的實現主要包括4個結構:M,P,G,Sched,前三個定義在runtime.h中,Sched定義在proc.c中。

* 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。

說明:Processor的數量是在啟動時被設置為環境變量GOMAXPROCS的值,也可以通過運行時調用函數runtime.GOMAXPROCS()進行設置。Processor數量固定意味著任意時刻只有GOMAXPROCS個線程在運行go代碼。

在單核處理器的場景下,所有goroutine運行在同一個M系統線程中,每一個M系統線程維護一個Processor,任何時刻,一個Processor中只有一個goroutine,其他goroutine在runqueue中等待。一個goroutine運行完自己的時間片后,讓出上下文,回到runqueue中。 多核處理器的場景下,為了運行goroutines,每個M系統線程會持有一個Processor。


2. 線程阻塞
正常情況下,Go調度器會按照上面的流程進行調度,但當發生阻塞時,比如Goroutine進行系統調用,Go調度器會再創建一個線程(或者從線程池取),當前的M線程放棄了它的Processor,P轉到新的線程中去運行。

3. runqueue執行完成
當其中一個Processor的runqueue為空,沒有goroutine可以調度。它會從另外一個上下文偷取一半的goroutine。

Go原生支持并發:Go調度器負責將并發任務分配到不同的內核線程上運行,然后內核調度器接管內核線程在CPU上的執行與調度。


共享資源安全問題

如果兩個或者多個 goroutine 在沒有互相同步的情況下,訪問某個共享的資源,并試圖同時 讀和寫這個資源,就處于相互競爭的狀態,這種情況被稱作競爭狀態(race candition)。如果這種競爭狀態處理不當,可能會出現安全問題。

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    count int32
    wg    sync.WaitGroup
)

func main() {
    wg.Add(2)
    go incCount()
    go incCount()
    wg.Wait()
    fmt.Println(count)
}

func incCount() {
    defer wg.Done()
    for i := 0; i < 2; i++ {
        value := count
        runtime.Gosched()
        value++
        count = value
    }
}
//輸出:2
競爭狀態下程序行為的分析

鎖住共享資源

  1. 原子函數
    原子函數能夠以很底層的加鎖機制來同步訪問整型變量和指針。
    sync/atomic
  2. 互斥鎖
    互斥鎖用于在代碼上創建一個臨界區,保證同一時間只有一個 goroutine 可以 執行這個臨界區代碼。
package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    count int32
    wg    sync.WaitGroup//聲明互斥鎖
    mutex sync.Mutex
)

func main() {
    wg.Add(2)
    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()//解鎖
    }
}

Channel

使用通道,通過發送和接收需要共享的資源,在 goroutine 之間做同步。聲明通道時,需要指定將要被共享的數據的類型。通道分為有緩沖通道和無緩沖通道。

// 無緩沖的整型通道
unbuffered := make(chan int)
// 有緩沖的字符串通道
buffered := make(chan string, 10)
//定義單向管道
var send chan<- int //只能發送
var receive <-chan int //只能接收
// 有緩沖的字符串通道
buffered := make(chan string, 10)
// 通過通道發送一個字符串 
buffered <- "Gopher"
// 從通道接收一個字符串
value := <-buffered

對通道的操作行為總結:

操作 unbuffered channel closed channel not-closed buffered channel
close 成功close panic 成功 close
寫 ch <- 阻塞 panic 通道滿:阻塞;通道未滿:成功寫入數據
讀 <- ch 阻塞 通道內有數據可讀:成功讀取數據;通道內沒有數據可讀:返回對應類型的零值 通道空:阻塞;通道非空:成功讀取數據

讀取一個已關閉的 channel 時,總是能讀取到對應類型的零值,為了和讀取非空未關閉 channel 的行為區別,可以使用兩個接收值來加以判斷:

// ok is false when ch is closed
v, ok := <-ch

優雅的關閉Channel

關閉channel應遵循以下準則:

  • 不要在讀取端關閉 channel ,因為寫入端無法知道 channel 是否已經關閉,往已關閉的 channel 寫數據會 panic ;
  • 有多個寫入端時,不要再寫入端關閉 channle ,因為其他寫入端無法知道 channel 是否已經關閉,關閉已經關閉的 channel 會發生 panic ;
  • 如果只有一個寫入端,可以在這個寫入端放心關閉 channel ;

具體分析:

  1. 一寫多讀
    這種場景下這個唯一的寫入端可以關閉 channel 用來通知讀取端所有數據都已經寫入完成了。讀取端只需要用 for range 把 channel 中數據遍歷完就可以了,當? channel 關閉時,for range 仍然會將 channel 緩沖中的數據全部遍歷完然后再退出循環:
package main

import (
    "fmt"
    "sync"
)

func main() {
    wg := &sync.WaitGroup{}
    ch := make(chan int, 100)

    send := func() {
        for i := 0; i < 100; i++ {
            ch <- i
        }
        // signal sending finish
        close(ch)
    }

    recv := func(id int) {
        defer wg.Done()
        for i := range ch {
            fmt.Printf("receiver #%d get %d\n", id, i)
        }
        fmt.Printf("receiver #%d exit\n", id)
    }

    wg.Add(3)
    go recv(0)
    go recv(1)
    go recv(2)
    send()

    wg.Wait()
}
  1. 多寫一讀
    這種場景下雖然可以用 sync.Once 來解決多個寫入端重復關閉 channel 的問題,但更優雅的辦法設置一個額外的 channel ,由讀取端通過關閉來通知寫入端任務完成不要再繼續再寫入數據了:
package main

import (
    "fmt"
    "sync"
)

func main() {
    wg := &sync.WaitGroup{}
    ch := make(chan int, 100)
    done := make(chan struct{})

    send := func(id int) {
        defer wg.Done()
        for i := 0; ; i++ {
            select {
            case <-done:
                // get exit signal
                fmt.Printf("sender #%d exit\n", id)
                return
            case ch <- id*1000 + i:
            }
        }
    }

    recv := func() {
        count := 0
        for i := range ch {
            fmt.Printf("receiver get %d\n", i)
            count++
            if count >= 1000 {
                // signal recving finish
                close(done)
                return
            }
        }
    }

    wg.Add(3)
    go send(0)
    go send(1)
    go send(2)
    recv()

    wg.Wait()
}
  1. 多寫多讀
    這種場景稍微復雜,和上面的例子一樣,也需要設置一個額外 channel 用來通知多個寫入端和讀取端。另外需要起一個額外的協程來通過關閉這個 channel 來廣播通知:
package main

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

func main() {
    wg := &sync.WaitGroup{}
    ch := make(chan int, 100)
    done := make(chan struct{})

    send := func(id int) {
        defer wg.Done()
        for i := 0; ; i++ {
            select {
            case <-done:
                // get exit signal
                fmt.Printf("sender #%d exit\n", id)
                return
            case ch <- id*1000 + i:
            }
        }
    }

    recv := func(id int) {
        defer wg.Done()
        for {
            select {
            case <-done:
                // get exit signal
                fmt.Printf("receiver #%d exit\n", id)
                return
            case i := <-ch:
                fmt.Printf("receiver #%d get %d\n", id, i)
                time.Sleep(time.Millisecond)
            }
        }
    }

    wg.Add(6)
    go send(0)
    go send(1)
    go send(2)
    go recv(0)
    go recv(1)
    go recv(2)

    time.Sleep(time.Second)
    // signal finish
    close(done)
    // wait all sender and receiver exit
    wg.Wait()
}

參考:
Golang并發
并發模型的一些實例和詳細分析
Golang channel使用總結

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

推薦閱讀更多精彩內容

  • 一、聊聊并發這件事 在基礎系列我們學習了Go的并發編程,對并發的概念已經有了一定的了解。在各種現代高級語言中,對并...
    GoFuncChan閱讀 2,390評論 0 4
  • 進程、線程、協程 進程:進程是系統進行資源分配的基本單元,有獨立的內存空間線程:線程是cpu調度和分派的基本單位,...
    GGBond_8488閱讀 503評論 0 1
  • GO并發使用goroutine運行程序,檢測并修正狀態,利用通道共享數據。通常程序會被編寫為一個順序執行并完成一個...
    小線亮亮閱讀 565評論 0 1
  • 昨天晚上20:00手機關機 12小時后早上8:00開機 這些年來手機一直是24小時開機 可能源于內心的一種責任……...
    全息心空間閱讀 184評論 0 0
  • 半城梁子閱讀 141評論 0 1