golang 碎片整理之 併發

併發與並行

併發:同一時間段內執行多個任務。
並行:同一時刻執行多個任務。

Go語言的併發通過goroutine實現。goroutine類似於線程,屬於用戶態的線程,我們可以根據需要創建成千上萬個goroutine併發工作。goroutine是由go語言的運行調度完成的,而線程是由操作系統調度完成的。
Go語言還提供了channel在多個goroutine間進行通信。goroutine和channel是go語言秉承的CSP(Communicating Sequential Process)併發模式的重要實現基礎。

goroutine

在Java/c++中我們要實現併發編程的時候,我們通常要自己維護一個線程池,並且需要自己去包裝一個又一個的任務和然後自己去調度線程執行任務並維護上線文的切換,這一切通常會耗費程序員的大量心智。能不能有一種機制,程序員只需要定義很多個任務,讓系統去幫忙我們把這些任務分配到CPU上實現併發執行呢? Go語言中的goroutine就是這樣一種機制,Go語言之所以能被稱爲現代化的編程語言,就是因爲它在語言層面已經內置了調度和上下文切換的機制。

使用goroutine

Go程序中使用go關鍵字爲一個函數創建一個goroutine。一個函數可以被創建多個goroutine,一個goroutine必定對應一個函數。

啓動單個goroutine

啓動goroutine的方式非常簡單,只需要在調用的函數(普通函數和匿名函數)前面加一個go 關鍵字。
舉個例子:

package main

import (
    "fmt"
)

func hello() {
    fmt.Println("hello goroutine")
}
func main() {
    hello()
    fmt.Println("main goroutine done!")
}

這個示例中hello函數和下面的語句是串行的,執行的結果是打印完hello goroutine 後打印main goroutine done!
接下來我們在調用hello 函數前面加上go關鍵字,也就是啓動一個goroutine區執行hello這個函數。

func main() {
    go hello()
    fmt.Println("main goroutine done!")
}

這一次的執行結果只打印了 main goroutine done! , 並沒有打印 hello goroutine,爲什麼呢?
在程序啓動時,Go程序就會爲main()函數創建一個默認的goroutine。當main函數返回的時候該goroutine就結束了,所有在main()函數中啓動的goroutine 會一同結束,main函數所在的goroutine就像是權利的遊戲中的夜王,其他的goroutine就像是異鬼,夜王一死它轉化的那些異鬼也就全部GG了。
所以我們要想辦法讓main函數等一等hello函數,最簡單粗暴的方式就是sleep了。

func main(){
    go hello()
    fmt.Println("main goroutine done!")
    time.Sleep(time.Second)
}

執行上面的代碼你會發現,這一次先打印main goroutine done! ,然後緊接着打印Hello Goroutine!
首先爲什麼會打印main goroutine done! 是因爲我們在創建新的goroutine 的時候需要花費一些時間,而此時main函數所在的goroutine是繼續執行的。

sync.WaitGroup

在代碼中生硬的使用time.sleep肯定是不合適的,Go語言中可以使用sync.WaitGroup來實現併發任務的同步。sync.WaitGroup有一下幾個方法:

方法名 功能
(wg WaitGroup)Add(delta int) 計數器+delta
(wg
WaitGroup)Done() 計算器-1
(wg *WaitGroup)Wait() 阻塞直到計數器變爲0

sync.WaitGroup內部維護着一個計數器,計數器的值可以增加和減少。例如當我們啓動了N個併發任務時,就將計數器的值增加N, 每個任務完成時通過調用Done()方法將計數器減1,通過調用Wait()來等待併發任務執行完,當計數器值爲0時,表示所有併發任務已經完成。
我們利用sync.WaitGroup將上面的代碼優化一下:

var wg sync.WaitGroup

func hello() {
    defer wg.Done()
    fmt.Println("hello 1")
}
func main() {
    wg.Add(1)
    go hello()
    fmt.Println("main 2")
    wg.Wait()
}

需要注意的是sync.WaitGroup是一個結構體,傳遞的時候需要傳遞指針。

啓動多個goroutine

在go語言中實現併發就是這麼簡單,我們還可以啓動多個goroutine。讓我們再來一個例子:

var wg sync.WaitGroup

func hello(i int) {
    defer wg.Done()
    fmt.Println("hello ,", i)
}
func main() {
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go hello(i)
    }
    wg.Wait()
}

多次執行上面的代碼,會發現每次打印的數字的順序都不一樣,這是因爲10個goroutine是併發執行的,而goroutine的調度室隨機的。

goroutine 與線程

可增長的棧

OS線程一般都有固定的棧內存(通常爲2MB),一個goroutine的棧在其生命週期開始時只有很小的棧(典型情況下2KB),goroutine的棧不是固定的,他可以按需增大和縮小,goroutine的棧大小限制可以達到1GB,雖然極少會用到這麼大。所以在go語言中一次創建十萬左右的goroutine也是可以的。

goroutine的調度

os線程是由os內核來調度的,goroutine則是由go運行時自己的調度器來調度的,這個調度器使用一個稱爲m:n調度的技術(複用/調度m個goroutine到n個OS線程上)。goroutine的調度不需要切換內核語境,所以調度一個goroutine比調度一個線程成本低很多。

GOMAXPROCS

go運行時的調度器使用GOMAXPROCS 參數來確定需要使用多少個OS線程來同時執行GO代碼,默認值是機器上的CPU的核心數,例如在一個8核的機器上,調度器會把go代碼同時調度到8個OS線程上。go語言中可以通過runtime.GOMAXPROCS()函數設置當前程序併發時佔用的CPU邏輯核心數。
go1.5版本之前,默認使用的是單核心執行,Go1.5版本之後,默認使用全部的CPU邏輯核心數。

GO語言中的操作系統線程和goroutine的關係:
1.一個操作系統線程對應用戶態多個goroutine。
2.go程序可以同時多個操作系統線程。
3.goroutine 和OS線程是多對多的關係,即m:n

channel

單純的將函數併發執行是沒有意義的,函數與函數間需要交換數據才能體現併發執行函數的意義。
雖然可以使用共享內存進行數據交換,但是共享內存存在不同的goroutine中容易發生的競態問題。爲了保證數據交換的正確性,必須使用互斥量對內存進行加鎖,這種做法必然導致性能問題。
go語言的併發模型是CSP,提倡通過通信共享內存,而不是通過共享內存實現通信。
如果說goroutine是Go程序併發的執行體,channel就是它們之間的連接,channel是可以讓一個goroutine發送特定值到另一個goroutine的通信機制。
go語言中的通道channel是一種特殊的類型,通道像一個傳送帶或者隊列,總是遵循先入先出的規則,保證收發數據的順序,每個通道都是一個具體類型的導管,也就是聲明channel的時候需要爲其指定元素類型。

聲明channel

聲明通道類型的格式如下:

var 變量  chan 元素類型

舉幾個例子:

var ch1 chan int
var ch2 chan bool
var ch3 chan []int

創建channel

通道是引用類型,通道類型的空值是nil。

var ch chan int
fmt.Println(ch)    //<nil>

聲明的通道後需要使用make函數初始化之後才能使用。創建channel的格式如下:

make(chan 元素類型, [緩衝大小])

緩衝大小是可選的。
舉幾個例子:

cha4 := make(chan int)
cha5 := make(chan bool)
cha6 := make(chan []int)

channel操作

通道有發送(send)、接收(receive)和關閉(close)三種操作。發送和接收都使用<- 符號。
現在我們先使用以下語句定義一個通道:

ch := make(chan int)

發送

將一個值發送到通道中。

ch <- 10  //把10發送到ch中

接收

從一個通道中接收值。

x := <- ch     //從ch中接收值並賦值給變量x
<- ch       // 從ch中接收值,忽略結果

關閉

我們通過調用內置的close函數來關閉通道。

close(ch)

關於關閉通道需要注意的事情是,只有在通知接收方goroutine所有的數據都發送完畢的時候才需要關閉通道。通道是可以被垃圾回收機制回收的,他和關閉文件不一樣,在結束操作之後關閉文件是必須做的,但是關閉通道不是必須的。
關閉後的通道有以下特點:

  1. 對一個關閉的通道再發送值會導致panic。
  2. 對一個關閉的通道進行接收會一直獲取值直到通道爲空。
  3. 對一個關閉的並且沒有值得通道執行接收操作會得到對應類型的零值。
  4. 關閉一個已經關閉的通道會導致panic。

    無緩衝的通道

    無緩衝的通道又稱爲阻塞的通道。我們來看一下下面的代碼:

    func main(){
    ch := make(chan int)
    ch <- 10
    fmt.Println("發送成功了")
    }

    上面的代碼能夠通過編譯,但是執行的時候會出現以下錯誤:
    fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
/Users/huoshuaibing/gowork/src/github.com/studygolang/day12/main.go:9 +0x54
exit status 2
爲什麼會出現deadlock錯誤呢?
因爲我們使用ch := make(chan int)創建的是無緩衝的通道,無緩衝的通道只有在有人接收值的時候才能發送值。就像你住的小區沒有代收點和快遞櫃,快遞員給你打電話必須把這個物品送到你的手中,簡單來說就是無緩衝的通道必須有接收才能發送。
上面的代碼會阻塞在 ch <- 10 這一行代碼形成死鎖,那麼如何解決這個問題呢?
一種方法是啓用一個goroutine去接收值,例如:

func recv(c chan int) {
    ret := <-c
    fmt.Println("接收成功", ret)
}
func main() {
    ch := make(chan int)
    go recv(ch)
    ch <- 10
    fmt.Println("發送成功")
}

無緩衝的通道上的發送操作會阻塞,直到另一個goroutine在該通道上執行接收操作,這時值才能發送成功,兩個goroutine將繼續執行;相反,如果接收操作先執行,接收方的goroutine將阻塞,直到另一個goroiutine在該通道上發送一個值。
使用無緩衝的通道進行通信將導致發送和接收的goroutine同步化,因此,無緩衝的通道也被稱爲同步通道。

有緩衝的通道

解決上面的問題的方法還有一種就是使用有緩衝區的通道。我們可以使用make函數初始通道的時候爲其指定通道的容量,例如:

func main() {
    ch := make(chan int, 1)
    ch <- 10
    fmt.Println("發送成功")
}

只要通道的容量大於零,那麼該通道就是有緩衝的通道,通道的容量表示通道中能存放元素的數量。就像你小區的快遞櫃只有那麼多個格子,格子滿了就裝不下了,就阻塞了,等到別人取走一個快遞員才能往裏面放一個。
我們可以使用內置的len函數獲取通道內元素的數量,使用cap函數獲取通道的容量。

如何優雅的從通道中循環取值

當通過通道發送有限的數據時,我們可以通過close函數關閉通道來告知從該通道接收值的goroutine停止等待。當通道關閉時,往該通道發送值會引發panic,從該通道里接收的值一直都是類型零值。那如何判斷一個通道是否被關閉了呢?
我們來看下面的例子:

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go func() {
        for i := 0; i < 100; i++ {
            ch1 <- i
        }
        close(ch1)
    }()
    go func() {
        for {
            i, ok := <-ch1
            if !ok {
                break
            }
            ch2 <- i * i
        }
        close(ch2)
    }()
    for i := range ch2 {
        fmt.Println(i)
    }
}

從上面的例子中我們看到兩種方式在接收值得時候判斷通道是否被關閉,我們通常使用的是for range 的方式。

單向通道

有時候我們會將通道作爲參數在多個任務函數間傳遞,很多時候我們在不同的任務函數中使用通道都會對其進行限制,比如只能發送或接收。Go語言中提供了單向通道來處理這種情況。例如,我們把上面的例子改造如下:

func counter(out chan<- int) {
    for i := 0; i < 100; i++ {
        out <- i
    }
    close(out)
}
func squarer(out chan<- int, in <-chan int) {
    for i := range in {
        out <- i * i
    }
    close(out)
}
func printer(in <-chan int) {
    for i := range in {
        fmt.Println(i)
    }
}
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go counter(ch1)
    go squarer(ch2, ch1)
    printer(ch2)
}

其中,chan<- int 是一個只能發送的通道,可以發送但是不能接收; <-chan int 是一個只能接收的通道,可以接收但是不能發送。在函數傳參及任何賦值操作中將雙向通道轉換爲單向通道是可以的,但是反過來不可以的。

select多路複用

在某些場景下我們需要同時從多個通道接收數據。通道在接收數據時,如果沒有數據可以接收將會發生阻塞。你也許會寫出如下代碼使用遍歷的方式來實現:

for {
    data,ok := <- ch1
    data,ok := <- ch2
    ...
}

這種方式雖然可以實現從多個通道接收值的需求,但是運行性能會差很多。爲了應對這種場景,GO內置了select關鍵字,可以同時響應多個通道的操作。select的使用類似於switch語句,它有一些列case分支和一個默認分支,每個case會對應一個通道的通信過程。select會一直等待,直到某個case的通信操作完成,就會執行case分支對應的語句。具體格式如下:

select {
case <- ch1:
      ...
case <-ch2
      ...
default:
      默認操作
}

舉個小例子來演示一下select 的使用:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int, 1)
    for i := 0; i < 10; i++ {
        select {
        case x := <-ch:
            fmt.Println(x)
        case ch <- i:
        }
    }
}

使用select語句能提高代碼的可讀性。如果多個case同時滿足,那麼select會隨機挑選一個。對於沒有case的select{}會一直等待。

併發安全和鎖

有時候在Go代碼中可能會存在多個goroutine同時操作一個資源(臨界區),這種情況會發生競態問題。類比生活中的例子有十字路口被各個方向的汽車競爭;還有火車上的衛生間被車廂裏面的人競爭。舉個例子:

var x int64
var wg sync.WaitGroup

func add() {
    for i := 0; i < 100; i++ {
        x = x + 1
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

上面的代碼我們開啓了兩個goroutine 去累加變量x的值,這兩個goroutine在訪問和修改x變量的時候就會存在數據競爭,導致最後的結果與期待的不符。

互斥鎖

互斥鎖是一種常用的控制共享資源訪問的方法,他們能夠保證同時只有一個goroutine可以訪問共享資源。Go語言中使用sync包的Mutex來實現互斥鎖。使用互斥鎖來修復上面代碼的問題:

var x int64
var wg sync.WaitGroup
var lock sync.Mutex

func add() {
    for i := 0; i < 100; i++ {
        lock.Lock()
        x = x + 1
        lock.Unlock()
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

使用互斥鎖能夠保證同一時間有且只有一個goroutine進入臨界區,其他的goroutine 則在等待鎖;當互斥鎖釋放後,等待goroutine纔可以獲得鎖進入臨界區,多個goroutine同時等待一個鎖時,喚醒的策略是隨機的。

讀寫互斥鎖

互斥鎖時完全互斥的,實際情況是很多情景下是讀多寫少的,當我們併發的去讀一個資源不涉及資源修改的時候是沒有必要加鎖的,這種場景下使用讀寫鎖是更好的一種選擇。讀寫鎖在Go語言中使用sync包的RWMutex 類型。
讀寫鎖分兩種:讀鎖和寫鎖。當一個goroutine獲取讀鎖之後,其他的goroutine如果是獲取讀鎖會繼續獲得鎖,如果獲取的是寫鎖就會等待,當一個goroutine獲取寫鎖之後,其他的goroutine無論是獲取讀鎖還是寫鎖都會等待。

讀寫鎖示例:

var (
    x      int64
    wg     sync.WaitGroup
    lock   sync.Mutex
    rwlock sync.RWMutex
)

func write() {
    // lock.Lock()   // 加互斥鎖
    rwlock.Lock() // 加寫鎖
    x = x + 1
    time.Sleep(10 * time.Millisecond) // 假設讀操作耗時10毫秒
    rwlock.Unlock()                   // 解寫鎖
    // lock.Unlock()                     // 解互斥鎖
    wg.Done()
}

func read() {
    // lock.Lock()                  // 加互斥鎖
    rwlock.RLock()               // 加讀鎖
    time.Sleep(time.Millisecond) // 假設讀操作耗時1毫秒
    rwlock.RUnlock()             // 解讀鎖
    // lock.Unlock()                // 解互斥鎖
    wg.Done()
}

func main() {
    start := time.Now()
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go write()
    }

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go read()
    }

    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

sync.Once

說在前面:這是一個進階知識點。延遲一個開銷很大的初始化操作到真正用到它的時候再執行是一個很好的實踐。因爲預先初始化一個變量(比如在init函數中完成初始化)會增加程序啓動延時,而且有可能實際執行過程中這個變量沒有用上,那麼這個初始化操作就不是必須要的。我們來看一個例子:
sync.Once其實內部包含一個互斥鎖和一個布爾值,互斥鎖保證布爾值和數據的安全,而布爾值用來記錄初始化是否完成。這樣設計就能保證初始化操作的時候是併發安全的並且初始化操作也不會執行多次。

sync.Map

Go語言中內置的map不是併發安全的。請看下面的示例:

var m = make(map[string]int)

func get(key string) int {
    return m[key]
}
func set(key string, value int) {
    m[key] = value
}
func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(n int) {
            key := strconv.Itoa(n)
            set(key, n)
            fmt.Println("k=:%v, v:=%v\n", key, get(key))
            wg.Done()
        }(i)
    }
    wg.Wait()
}

上面的代碼開啓少量幾個goroutine 的時候沒有問題,當併發對了之後,執行就會報fatal error: concurrent map writes錯誤。
像這種場景下就需要爲map加鎖來保證併發的安全性,go語言的sync包中提供了一個開箱即用的併發安全版map-sync.Map。同時sync.Map 內置了諸如Store、Load、LoadOrStore、Delete、Range等操作方法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章