Golang 學習筆記九 并發編程 協程 通道

參考
《快學 Go 語言》第 11 課 —— 千軍萬馬跑協程
《快學 Go 語言》第 12 課 —— 通道
let's GoLang(三): Goroutine&Channel

知識回顧
并發編程基礎知識一 并發和并行
并發編程基礎知識二 線程和進程
并發編程基礎知識三 異步,非阻塞和 IO 復用

協程和通道是 Go 語言作為并發編程語言最為重要的特色之一,初學者可以完全將協程理解為線程,但是用起來比線程更加簡單,占用的資源也更少。通常在一個進程里啟動上萬個線程就已經不堪重負,但是 Go 語言允許你啟動百萬協程也可以輕松應付。如果把協程比喻成小島,那通道就是島嶼之間的交流橋梁,數據搭乘通道從一個協程流轉到另一個協程。通道是并發安全的數據結構,它類似于內存消息隊列,允許很多的協程并發對通道進行讀寫。


image.png

Go 語言里面的協程稱之為 goroutine,通道稱之為 channel。

一、協程

1.協程的啟動
Go 語言里創建一個協程非常簡單,使用 go 關鍵詞加上一個函數調用就可以了。Go 語言會啟動一個新的協程,函數調用將成為這個協程的入口。

package main

import "fmt"
import "time"

func main() {
    fmt.Println("run in main goroutine")
    go func() {
        fmt.Println("run in child goroutine")
        go func() {
            fmt.Println("run in grand child goroutine")
            go func() {
                fmt.Println("run in grand grand child goroutine")
            }()
        }()
    }()
    time.Sleep(time.Second)
    fmt.Println("main goroutine will quit")
}

-------
run in main goroutine
run in child goroutine
run in grand child goroutine
run in grand grand child goroutine
main goroutine will quit

main 函數運行在主協程(main goroutine)里面,上面的例子中我們在主協程里面啟動了一個子協程,子協程又啟動了一個孫子協程,孫子協程又啟動了一個曾孫子協程。這些協程之間似乎形成了父子、子孫、關系,但是實際上協程之間并不存在這么多的層級關系,在 Go 語言里只有一個主協程,其它都是它的子協程,子協程之間是平行關系。

值得注意的是這里的 go 關鍵字語法和前面的 defer 關鍵字語法是一樣的,它后面跟了一個匿名函數,然后還要帶上一對(),表示對匿名函數的調用。

上面的代碼中主協程睡眠了 1s,等待子協程們執行完畢。如果將睡眠的這行代碼去掉,將會看不到子協程運行的痕跡(注意測試時如果不睡眠,會看不到輸出,莫名其妙)

-------------
run in main goroutine
main goroutine will quit

這是因為主協程運行結束,其它協程就會立即消亡,不管它們是否已經開始運行。

2.協程的本質
一個進程內部可以運行多個線程,而每個線程又可以運行很多協程。線程要負責對協程進行調度,保證每個協程都有機會得到執行。當一個協程睡眠時,它要將線程的運行權讓給其它的協程來運行,而不能持續霸占這個線程。同一個線程內部最多只會有一個協程正在運行。

image.png

線程的調度是由操作系統負責的,調度算法運行在內核態,而協程的調用是由 Go 語言的運行時負責的,調度算法運行在用戶態。

協程可以簡化為三個狀態,運行態、就緒態和休眠態。同一個線程中最多只會存在一個處于運行態的協程,就緒態的協程是指那些具備了運行能力但是還沒有得到運行機會的協程,它們隨時會被調度到運行態,休眠態的協程還不具備運行能力,它們是在等待某些條件的發生,比如 IO 操作的完成、睡眠時間的結束等。


image.png

操作系統對線程的調度是搶占式的,也就是說單個線程的死循環不會影響其它線程的執行,每個線程的連續運行受到時間片的限制。

Go 語言運行時對協程的調度并不是搶占式的。如果單個協程通過死循環霸占了線程的執行權,那這個線程就沒有機會去運行其它協程了,你可以說這個線程假死了。不過一個進程內部往往有多個線程,假死了一個線程沒事,全部假死了才會導致整個進程卡死。

每個線程都會包含多個就緒態的協程形成了一個就緒隊列,如果這個線程因為某個別協程死循環導致假死,那這個隊列上所有的就緒態協程是不是就沒有機會得到運行了呢?Go 語言運行時調度器采用了 work-stealing 算法,當某個線程空閑時,也就是該線程上所有的協程都在休眠(或者一個協程都沒有),它就會去其它線程的就緒隊列上去偷一些協程來運行。也就是說這些線程會主動找活干,在正常情況下,運行時會盡量平均分配工作任務。

3.設置線程數
默認情況下,Go 運行時會將線程數會被設置為機器 CPU 邏輯核心數。同時它內置的 runtime 包提供了 GOMAXPROCS(n int) 函數允許我們動態調整線程數,注意這個函數名字是全大寫,Go 語言的設計者就是這么任性,該函數會返回修改前的線程數,如果參數 n <=0 ,就不會產生修改效果,等價于讀操作。

package main

import "fmt"
import "runtime"

func main() {
// 讀取默認的線程數
fmt.Println(runtime.GOMAXPROCS(0))
// 設置線程數為 10
runtime.GOMAXPROCS(10)
// 讀取當前的線程數
fmt.Println(runtime.GOMAXPROCS(0))
}

--------
4
10

獲取當前的協程數量可以使用 runtime 包提供的 NumGoroutine() 方法

package main

import "fmt"
import "time"
import "runtime"

func main() {
    fmt.Println(runtime.NumGoroutine())
    for i:=0;i<10;i++ {
        go func(){
            for {
                time.Sleep(time.Second)
            }
        }()
    }
    fmt.Println(runtime.NumGoroutine())
}

------
1
11
二、使用atmoic包和互斥鎖mutex解決競爭狀態

摘自《go語言實戰》P132
參考Go語言實戰筆記(十三)| Go 并發資源競爭

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,也可以是3,也可能是4。因為共享資源count變量沒有任何同步保護,所以兩個goroutine都會對其進行讀寫,會導致對已經計算好的結果覆蓋,以至于產生錯誤結果.
1.atmoic包
如果兩個或者多個 goroutine 在沒有互相同步的情況下,訪問某個共享的資源,并試圖同時讀和寫這個資源,就處于相互競爭的狀態,這種情況被稱作競爭狀態(race candition)。競爭狀態的存在是讓并發程序變得復雜的地方,十分容易引起潛在問題。對一個共享資源的讀和寫操作必須是原子化的,換句話說,同一時刻只能有一個 goroutine 對共享資源進行讀和寫操作。

func incCount() {
    defer wg.Done()
    for i := 0; i < 2; i++ {
        value := atomic.LoadInt32(&count)
        runtime.Gosched()
        value++
        atomic.StoreInt32(&count,value)
    }
}

留意這里atomic.LoadInt32和atomic.StoreInt32兩個函數,一個讀取int32類型變量的值,一個是修改int32類型變量的值,這兩個都是原子性的操作,Go已經幫助我們在底層使用加鎖機制,保證了共享資源的同步和安全,所以我們可以得到正確的結果,這時候我們再使用資源競爭檢測工具go build -race檢查,也不會提示有問題了。

atom包的Addint64會同步整型值的加法,強制同一時刻只能有一個goroutine運行并完成這個加法操作。atomic包里還有很多原子化的函數可以保證并發下資源同步訪問修改的問題,比如函數atomic.AddInt32可以直接對一個int32類型的變量進行修改,在原值的基礎上再增加多少的功能,也是原子性的,這里不再舉例,大家自己可以試試。

atomic雖然可以解決資源競爭問題,但是比較都是比較簡單的,支持的數據類型也有限,所以Go語言還提供了一個sync包,這個sync包里提供了一種互斥型的鎖,可以讓我們自己靈活的控制哪些代碼,同時只能有一個goroutine訪問,被sync互斥鎖控制的這段代碼范圍,被稱之為臨界區,臨界區的代碼,同一時間,只能又一個goroutine訪問。

更多可參考Go語言atomic原子操作

原子操作由底層硬件支持,而鎖則由操作系統提供的API實現。若實現相同的功能,前者通常會更有效率。

2.互斥鎖

// This sample program demonstrates how to use a mutex
// to define critical sections of code that need synchronous
// access.
package main

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

var (
    // counter is a variable incremented by all goroutines.
    counter int

    // wg is used to wait for the program to finish.
    wg sync.WaitGroup

    // mutex is used to define a critical section of code.
    mutex sync.Mutex
)

// main is the entry point for all Go programs.
func main() {
    // Add a count of two, one for each goroutine.
    wg.Add(2)

    // Create two goroutines.
    go incCounter(1)
    go incCounter(2)

    // Wait for the goroutines to finish.
    wg.Wait()
    fmt.Printf("Final Counter: %d\n", counter)
}

// incCounter increments the package level Counter variable
// using the Mutex to synchronize and provide safe access.
func incCounter(id int) {
    // Schedule the call to Done to tell main we are done.
    defer wg.Done()

    for count := 0; count < 2; count++ {
        // Only allow one goroutine through this
        // critical section at a time.
        mutex.Lock()
        {
            // Capture the value of counter.
            value := counter

            // Yield the thread and be placed back in queue.
            runtime.Gosched()

            // Increment our local value of counter.
            value++

            // Store the value back into counter.
            counter = value
        }
        mutex.Unlock()
        // Release the lock and allow any
        // waiting goroutine through.
    }
}

對 counter 變量的操作在第 46 行和第 60 行的 Lock()和 Unlock()函數調用定義的臨界區里被保護起來。使用大括號只是為了讓臨界區看起來更清晰,并不是必需的。同一時刻只有一個 goroutine 可以進入臨界區。之后,直到調用 Unlock()函數之后,其他 goroutine 才能進入臨界區。當第 52 行強制將當前 goroutine 退出當前線程后,調度器會再次分配這個 goroutine 繼續運行。當程序結束時,我們得到正確的值 4,競爭狀態不再存在。

三、通道

原子函數和互斥鎖都能工作,但是依靠它們都不會讓編寫并發程序變得更簡單,更不容易出錯,或者更有趣。在 Go 語言里,你不僅可以使用原子函數和互斥鎖來保證對共享資源的安全訪問以及消除競爭狀態,還可以使用通道,通過發送和接收需要共享的資源,在 goroutine 之間做同步。

不同的并行協程之間交流的方式有兩種,一種是通過共享變量,另一種是通過隊列。Go 語言鼓勵使用隊列的形式來交流,它單獨為協程之間的隊列數據交流定制了特殊的語法 —— 通道。

通道是協程的輸入和輸出。作為協程的輸出,通道是一個容器,它可以容納數據。作為協程的輸入,通道是一個生產者,它可以向協程提供數據。通道作為容器是有限定大小的,滿了就寫不進去,空了就讀不出來。通道還有它自己的類型,它可以限定進入通道的數據的類型。


image.png

1.創建通道
創建通道只有一種語法,那就是 make 全局函數,提供第一個類型參數限定通道可以容納的數據類型,再提供第二個整數參數作為通道的容器大小。大小參數是可選的,如果不填,那這個通道的容量為零,叫著「非緩沖型通道」,非緩沖型通道必須確保有協程正在嘗試讀取當前通道,否則寫操作就會阻塞直到有其它協程來從通道中讀東西。

如果兩個 goroutine沒有同時準備好,通道會導致先執行發送或接收操作的 goroutine 阻塞等待。這種對通道進行發送和接收的交互行為本身就是同步的。其中任意一個操作都無法離開另一個操作單獨存在。非緩沖型通道總是處于既滿又空的狀態。

與之對應的有限定大小的通道就是緩沖型通道。在 Go 語言里不存在無界通道,每個通道都是有限定最大容量的。

// 緩沖型通道,里面只能放整數
var bufferedChannel = make(chan int, 1024)
// 非緩沖型通道
var unbufferedChannel = make(chan int)

2.讀寫通道
Go 語言為通道的讀寫設計了特殊的箭頭語法糖 <-,讓我們使用通道時非常方便。可以把箭頭的方向理解為數據的流向。把箭頭寫在通道變量的右邊就是寫通道,把箭頭寫在通道的左邊就是讀通道。一次只能讀寫一個元素。

package main

import "fmt"

func main() {
 var ch chan int = make(chan int, 4)
 for i:=0; i<cap(ch); i++ {
  ch <- i   // 寫通道
 }
 for len(ch) > 0 {
  var value int = <- ch  // 讀通道
  fmt.Println(value)
 }
}
--------------
0
1
2
3

通道作為容器,它可以像切片一樣,使用 cap() 和 len() 全局函數獲得通道的容量和當前內部的元素個數。通道一般作為不同的協程交流的媒介,在同一個協程里它也是可以使用的。注意看輸出結果,先寫進去的先出來,說明通道是一個隊列

func writeChan(ch chan <- int){
    for i:=0; i<cap(ch); i++ {
        ch <- i   // 寫通道
    }

    for len(ch) > 0 {
        var value int = <- ch  // 讀通道
        fmt.Println(value)
    }
}

上面的代碼編譯不通過,會報錯:Invalid operation: <- ch (receive from send-only type chan<- int)。也就是說,當函數的參數中出現通道時,可以不加箭頭,表示 雙向的。如果在chan后面加上<-就表示數據要流入通道,也就是這個參數只能寫,不能讀。反過來,改成ch <- chan int,就是只能讀,不能寫的通道參數。

3.讀寫阻塞
通道滿了,寫操作就會阻塞,協程就會進入休眠,直到有其它協程讀通道挪出了空間,協程才會被喚醒。如果有多個協程的寫操作都阻塞了,一個讀操作只會喚醒一個協程。

通道空了,讀操作就會阻塞,協程也會進入睡眠,直到有其它協程寫通道裝進了數據才會被喚醒。如果有多個協程的讀操作阻塞了,一個寫操作也只會喚醒一個協程。

package main

import "fmt"
import "time"
import "math/rand"

func send(ch chan int) {
 for {
  var value = rand.Intn(100)
  ch <- value
  fmt.Printf("send %d\n", value)
 }
}

func recv(ch chan int) {
 for {
  value := <- ch
  fmt.Printf("recv %d\n", value)
  time.Sleep(time.Second)
 }
}

func main() {
 var ch = make(chan int, 1)
 // 子協程循環讀
 go recv(ch)
 // 主協程循環寫
 send(ch)
}

--------
send 81
send 87
recv 81
recv 87
send 47
recv 47
send 59

4.關閉通道
Go 語言的通道有點像文件,不但支持讀寫操作, 還支持關閉。讀取一個已經關閉的通道會立即返回通道類型的「零值」,而寫一個已經關閉的通道會拋異常。如果通道里的元素是整型的,讀操作是不能通過返回值來確定通道是否關閉的。

package main

import "fmt"

func main() {
 var ch = make(chan int, 4)
 ch <- 1
 ch <- 2
 close(ch)

 value := <- ch
 fmt.Println(value)
 value = <- ch
 fmt.Println(value)
 value = <- ch
 fmt.Println(value)
}

-------
1
2
0

這時候就需要引入一個新的知識點 —— 使用 for range 語法糖來遍歷通道

for range 語法我們已經見了很多次了,它是多功能的,除了可以遍歷數組、切片、字典,還可以遍歷通道,取代箭頭操作符。當通道空了,循環會暫停阻塞,當通道關閉時,阻塞停止,循環也跟著結束了。當循環結束時,我們就知道通道已經關閉了。

package main

import "fmt"

func main() {
 var ch = make(chan int, 4)
 ch <- 1
 ch <- 2
 close(ch)

 // for range 遍歷通道
 for value := range ch {
  fmt.Println(value)
 }
}

------
1
2

通道如果沒有顯式關閉,當它不再被程序使用的時候,會自動關閉被垃圾回收掉。不過優雅的程序應該將通道看成資源,顯式關閉每個不再使用的資源是一種良好的習慣。

5.通道寫安全
上面提到向一個已經關閉的通道執行寫操作會拋出異常,這意味著我們在寫通道時一定要確保通道沒有被關閉。

package main

import "fmt"

func send(ch chan int) {
 i := 0
 for {
  i++
  ch <- i
 }
}

func recv(ch chan int) {
 value := <- ch
 fmt.Println(value)
 value = <- ch
 fmt.Println(value)
 close(ch)
}

func main() {
 var ch = make(chan int, 4)
 go recv(ch)
 send(ch)
}

---------
1
2
panic: send on closed channel

goroutine 1 [running]:
main.send(0xc42008a000)
 /Users/qianwp/go/src/github.com/pyloque/practice/main.go:9 +0x44
main.main()
 /Users/qianwp/go/src/github.com/pyloque/practice/main.go:24 +0x66
exit status 2

那如何確保呢?Go 語言并不存在一個內置函數可以判斷出通道是否已經被關閉。即使存在這樣一個函數,當你判斷時通道沒有關閉,并不意味著當你往通道里寫數據時它就一定沒有被關閉,并發環境下,它是可能被其它協程隨時關閉的。

確保通道寫安全的最好方式是由負責寫通道的協程自己來關閉通道,讀通道的協程不要去關閉通道。

package main

import "fmt"

func send(ch chan int) {
 ch <- 1
 ch <- 2
 ch <- 3
 ch <- 4
 close(ch)
}

func recv(ch chan int) {
 for v := range ch {
  fmt.Println(v)
 }
}

func main() {
 var ch = make(chan int, 1)
 go send(ch)
 recv(ch)
}

-----------
1
2
3
4

這個方法確實可以解決單寫多讀的場景,可要是遇上了多寫單讀的場合該怎么辦呢?任意一個讀寫通道的協程都不可以隨意關閉通道,否則會導致其它寫通道協程拋出異常。這時候就必須讓其它不相干的協程來干這件事,這個協程需要等待所有的寫通道協程都結束運行后才能關閉通道。那其它協程要如何才能知道所有的寫通道已經結束運行了呢?這個就需要使用到內置 sync 包提供的 WaitGroup 對象,它使用計數來等待指定事件完成。

package main

import "fmt"
import "time"
import "sync"

func send(ch chan int, wg *sync.WaitGroup) {
 defer wg.Done() // 計數值減一
 i := 0
 for i < 4 {
  i++
  ch <- i
 }
}

func recv(ch chan int) {
 for v := range ch {
  fmt.Println(v)
 }
}

func main() {
 var ch = make(chan int, 4)
 var wg = new(sync.WaitGroup)
 wg.Add(2) // 增加計數值
 go send(ch, wg)  // 寫
 go send(ch, wg)  // 寫
 go recv(ch)
 // Wait() 阻塞等待所有的寫通道協程結束
 // 待計數值變成零,Wait() 才會返回
 wg.Wait()
 // 關閉通道
 close(ch)
 time.Sleep(time.Second)
}

---------
1
2
3
4
1
2
3
4

6.多路通道
這里可以先參考并發編程基礎知識三 異步,非阻塞和 IO 復用
在真實的世界中,還有一種消息傳遞場景,那就是消費者有多個消費來源,只要有一個來源生產了數據,消費者就可以讀這個數據進行消費。這時候可以將多個來源通道的數據匯聚到目標通道,然后統一在目標通道進行消費。

package main

import "fmt"
import "time"

// 每隔一會生產一個數
func send(ch chan int, gap time.Duration) {
 i := 0
 for {
  i++
  ch <- i
  time.Sleep(gap)
 }
}

// 將多個原通道內容拷貝到單一的目標通道
func collect(source chan int, target chan int) {
 for v := range source {
  target <- v
 }
}

// 從目標通道消費數據
func recv(ch chan int) {
 for v := range ch {
  fmt.Printf("receive %d\n", v)
 }
}


func main() {
 var ch1 = make(chan int)
 var ch2 = make(chan int)
 var ch3 = make(chan int)
 go send(ch1, time.Second)
 go send(ch2, 2 * time.Second)
 go collect(ch1, ch3)
 go collect(ch2, ch3)
 recv(ch3)
}

---------
receive 1
receive 1
receive 2
receive 2
receive 3
receive 4
receive 3
receive 5
receive 6
receive 4
receive 7
receive 8
receive 5
receive 9
....

但是上面這種形式比較繁瑣,需要為每一種消費來源都單獨啟動一個匯聚協程。Go 語言為這種使用場景帶來了「多路復用」語法糖,也就是下面要講的 select 語句,它可以同時管理多個通道讀寫,如果所有通道都不能讀寫,它就整體阻塞,只要有一個通道可以讀寫,它就會繼續。下面我們使用 select 語句來簡化上面的邏輯

package main

import "fmt"
import "time"

func send(ch chan int, gap time.Duration) {
 i := 0
 for {
  i++
  ch <- i
  time.Sleep(gap)
 }
}

func recv(ch1 chan int, ch2 chan int) {
 for {
  select {
   case v := <- ch1:
    fmt.Printf("recv %d from ch1\n", v)
   case v := <- ch2:
    fmt.Printf("recv %d from ch2\n", v)
  }
 }
}

func main() {
 var ch1 = make(chan int)
 var ch2 = make(chan int)
 go send(ch1, time.Second)
 go send(ch2, 2 * time.Second)
 recv(ch1, ch2)
}

------------
recv 1 from ch2
recv 1 from ch1
recv 2 from ch1
recv 3 from ch1
recv 2 from ch2
recv 4 from ch1
recv 3 from ch2
recv 5 from ch1

上面是多路復用 select 語句的讀通道形式,下面是它的寫通道形式,只要有一個通道能寫進去,它就會打破阻塞。

select {
  case ch1 <- v:
      fmt.Println("send to ch1")
  case ch2 <- v:
      fmt.Println("send to ch2")
}

7.非阻塞讀寫
前面我們講的讀寫都是阻塞讀寫,Go 語言還提供了通道的非阻塞讀寫。當通道空時,讀操作不會阻塞,當通道滿時,寫操作也不會阻塞。非阻塞讀寫需要依靠 select 語句的 default 分支。當 select 語句所有通道都不可讀寫時,如果定義了 default 分支,那就會執行 default 分支邏輯,這樣就起到了不阻塞的效果。下面我們演示一個單生產者多消費者的場景。生產者同時向兩個通道寫數據,寫不進去就丟棄。

package main

import "fmt"
import "time"

func send(ch1 chan int, ch2 chan int) {
 i := 0
 for {
  i++
  select {
   case ch1 <- i:
    fmt.Printf("send ch1 %d\n", i)
   case ch2 <- i:
    fmt.Printf("send ch2 %d\n", i)
   default:
  }
 }
}

func recv(ch chan int, gap time.Duration, name string) {
 for v := range ch {
  fmt.Printf("receive %s %d\n", name, v)
  time.Sleep(gap)
 }
}

func main() {
 // 無緩沖通道
 var ch1 = make(chan int)
 var ch2 = make(chan int)
 // 兩個消費者的休眠時間不一樣,名稱不一樣
 go recv(ch1, time.Second, "ch1")
 go recv(ch2, 2 * time.Second, "ch2")
 send(ch1, ch2)
}

------------
send ch1 27
send ch2 28
receive ch1 27
receive ch2 28
send ch1 6708984
receive ch1 6708984
send ch2 13347544
send ch1 13347775
receive ch2 13347544
receive ch1 13347775
send ch1 20101642
receive ch1 20101642
send ch2 26775795
receive ch2 26775795
...

從輸出中可以明顯看出有很多的數據都丟棄了,消費者讀到的數據是不連續的。如果將 select 語句里面的 default 分支干掉,再運行一次,結果如下

send ch2 1
send ch1 2
receive ch1 2
receive ch2 1
receive ch1 3
send ch1 3
receive ch2 4
send ch2 4
send ch1 5
receive ch1 5
receive ch1 6
send ch1 6
receive ch1 7

可以看到消費者讀到的數據都連續了,但是每個數據只給了一個消費者。select 語句的 default 分支非常關鍵,它是決定通道讀寫操作阻塞與否的關鍵。

8.通道內部結構
Go 語言的通道內部結構是一個循環數組,通過讀寫偏移量來控制元素發送和接受。它為了保證線程安全,內部會有一個全局鎖來控制并發。對于發送和接受操作都會有一個隊列來容納處于阻塞狀態的協程。


image.png
type hchan struct {
  qcount uint  // 通道有效元素個數
  dataqsize uint   // 通道容量,循環數組總長度
  buf unsafe.Pointer // 數組地址
  elemsize uint16 // 內部元素的大小
  closed uint32 // 是否已關閉 0或者1
  elemtype *_type // 內部元素類型信息
  sendx uint // 循環數組的寫偏移量
  recvx uint // 循環數組的讀偏移量
  recvq waitq // 阻塞在讀操作上的協程隊列
  sendq waitq // 阻塞在寫操作上的協程隊列
  
  lock mutex // 全局鎖
}

這個循環隊列和 Java 語言內置的 ArrayBlockingQueue 結構如出一轍。從這個數據結構中我們也可以得出結論:隊列在本質上是使用共享變量加鎖的方式來實現的,共享變量才是并行交流的本質。

class ArrayBlockingQueue extends AbstractQueue {
  Object[] items;
  int takeIndex;
  int putIndex;
  int count;
  ReentrantLock lock;
  ...
}

所以讀者請不要認為 Go 語言的通道很神奇,Go 語言只是對通道設計了一套便于使用的語法糖,讓這套數據結構顯的平易近人。它在內部實現上和其它語言的并發隊列大同小異。

四、sync.Once的實現分析

sync.once可以控制函數只能被調用一次,不能多次重復調用。

我們可以用下面的代碼實現一個線程安全的單例模式

package singleton
import (
    "fmt"
    "sync"
)
type object struct {
    name string
}
var once sync.Once
var obj *object //單例指針
//公開方法 外包調用
func Instance() *object {
    once.Do(getObj)
    return obj
}
func getObj() {
    if obj == nil {
        obj = new(object)
        //可以做其他初始化事件
    }
}
//單例測試
func (obj *object) Test() {
    fmt.Println(obj.name)
}

如果我們要自己實現這么一個功能如何做呢?

  • 定義一個status變量用來描述是否已經執行過了
  • 使用sync.Mutex 或者sync.Atomic實現線程安全的獲取status狀態, 根據狀態判斷是否執行特定的函數

然后看下sync.Once實際是如何實現的

// Once is an object that will perform exactly one action.
type Once struct {
    m    Mutex
    done uint32
}
//使用了雙層檢查機制 
func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 1 {
        return
    }
    // Slow-path.
    o.m.Lock()
    defer o.m.Unlock()
    //這里需要再次重新判斷下,因為 atomic.LoadUint32取出狀態值
    //到  o.m.Lock() 之間是有可能存在其它gotoutine改變status的狀態值的
    if o.done == 0 {
        f()
        atomic.StoreUint32(&o.done, 1)
    }
}

也有網友寫出了更簡潔的代碼,不知道官方為什么沒有采用下面的實現方式。

type Once struct {
    done int32
}
func (o *Once) Do(f func()) {
    if atomic.LoadInt32(&o.done) == 1 {
        return
    }
    // Slow-path.
    if atomic.CompareAndSwapInt32(&o.done, 0, 1) {
        f()
    }
}
五、擴展閱讀

golang 關于鎖 mutex,踩過的坑
Golang并發:再也不愁選channel還是選鎖

面對一個并發問題的時候,應當選擇合適的并發方式:channel還是mutex。選擇的依據是他們的能力/特性:channel的能力是讓數據流動起來,擅長的是數據流動的場景,mutex的能力是數據不動,某段時間只給一個協程訪問數據的權限擅長數據位置固定的場景

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,619評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,155評論 3 425
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,635評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,539評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,255評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,646評論 1 326
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,655評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,838評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,399評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,146評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,338評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,893評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,565評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,983評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,257評論 1 292
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,059評論 3 397
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,296評論 2 376

推薦閱讀更多精彩內容