1. 程式人生 > >Golang 入門系列(十五)如何理解go的併發?

Golang 入門系列(十五)如何理解go的併發?

前面已經講過很多Golang系列知識,感興趣的可以看看以前的文章,https://www.cnblogs.com/zhangweizhong/category/1275863.html,

接下來要說的是golang的併發,其實之前簡單介紹過協程(goroutine)和管道(channel) 等基礎內容,只是比較簡單,只講了基本的語法。今天就詳細說說golang的併發程式設計。

 

一、併發和並行

Go是併發語言,而不是並行語言。所以我們在討論,我們首先必須瞭解什麼是併發,以及它與並行性有什麼不同。

 

什麼是併發

併發就是一段時間內處理許多事情。

比如,一個人在晨跑。在晨跑時,他的鞋帶鬆了。現在這個人停止跑步,繫鞋帶,然後又開始跑步。這是一個典型的併發。這個人能夠同時處理跑步和繫鞋帶,這是一個人能夠同時處理很多事情。

 

什麼是並行

並行就是同一時刻做很多事情。這聽起來可能與併發類似,但實際上是不同的。

再比如,這個人正在慢跑,並且使用他的手機聽音樂。在這種情況下,一個人一邊慢跑一邊聽音樂,那就是他同時在做很多事情。這就是所謂的並行。

 

併發不是並行。併發更關注的是程式的設計層面,併發的程式完全是可以順序執行的,只有在真正的多核CPU上才可能真正地同時執行。並行更關注的是程式的執行層面,並行一般是簡單的大量重複,例如GPU中對影象處理都會有大量的並行運算。為更好的編寫併發程式,從設計之初Go語言就注重如何在程式語言層級上設計一個簡潔安全高效的抽象模型,讓程式設計師專注於分解問題和組合方案,而且不用被執行緒管理和訊號互斥這些繁瑣的操作分散精力。   上圖能清楚的說明了併發和並行的區別。  

二、協程(Goroutines)

go中使用Goroutines來實現併發。Goroutines是與其他函式或方法同時執行的函式或方法。Goroutines可以被認為是輕量級的執行緒。與執行緒相比,建立Goroutine的成本很小。因此,Go應用程式可以併發執行數千個Goroutines。

Goroutines線上程上的優勢。

  1. 與執行緒相比,Goroutines非常便宜。它們只是堆疊大小的幾個kb,堆疊可以根據應用程式的需要增長和收縮,而線上程的情況下,堆疊大小必須指定並且是固定的

  2. Goroutines被多路複用到較少的OS執行緒。在一個程式中可能只有一個執行緒與數千個Goroutines。如果執行緒中的任何Goroutine都表示等待使用者輸入,則會建立另一個OS執行緒,剩下的Goroutines被轉移到新的OS執行緒。所有這些都由執行時進行處理,我們作為程式設計師從這些複雜的細節中抽象出來,並得到了一個與併發工作相關的乾淨的API。

  3. 當使用Goroutines訪問共享記憶體時,通過設計的通道可以防止競態條件發生。通道可以被認為是Goroutines通訊的管道。

 

如何使用Goroutines

在函式或方法呼叫前面加上關鍵字go,您將會同時執行一個新的Goroutine。

例項程式碼:

package main

import (
    "fmt"
    "time"
)

func hello() {
    fmt.Println("Hello world goroutine")
}
func main() {
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}
執行結果: Hello world goroutine main function

 

如何啟動多個Goroutines

示例程式碼:

package main

import (
    "fmt"
    "time"
)

func numbers() {
    for i := 1; i <= 5; i++ {
        time.Sleep(250 * time.Millisecond)
        fmt.Printf("%d ", i)
    }
}
func alphabets() {
    for i := 'a'; i <= 'e'; i++ {
        time.Sleep(400 * time.Millisecond)
        fmt.Printf("%c ", i)
    }
}
func main() {
    go numbers()
    go alphabets()
    time.Sleep(3000 * time.Millisecond)
    fmt.Println("main terminated")
}
執行結果:

1 a 2 3 b 4 c 5 d e main terminated

 

Goroutine切換

下面通過素數計算的例子來說明goland是如何通過切換不同的goroutine實現併發的。

package main

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

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是在一個處理器上通過切換goroutine實現併發執行。

 

三、通道(channels)

通道可以被認為是Goroutines通訊的管道。類似於管道中的水從一端到另一端的流動,資料可以從一端傳送到另一端,通過通道接收。

 

宣告通道

每個通道都有與其相關的型別。該型別是通道允許傳輸的資料型別。(通道的零值為nil。nil通道沒有任何用處,因此通道必須使用類似於地圖和切片的方法來定義。)

示例程式碼:

package main

import "fmt"

func main() {
    var a chan int
    if a == nil {
        fmt.Println("channel a is nil, going to define it")
        a = make(chan int)
        fmt.Printf("Type of a is %T", a)
    }
}
執行結果:

channel a is nil, going to define it
Type of a is chan int

也可以簡短的宣告:

a := make(chan int)

傳送和接收

傳送和接收的語法:

data := <- a   // read from channel a
a <- data      // write to channel a

在通道上箭頭的方向指定資料是傳送還是接收。

 

一個通道傳送和接收資料,預設是阻塞的。當一個數據被髮送到通道時,在傳送語句中被阻塞,直到另一個Goroutine從該通道讀取資料。類似地,當從通道讀取資料時,讀取被阻塞,直到一個Goroutine將資料寫入該通道。

這些通道的特性是幫助Goroutines有效地進行通訊,而無需像使用其他程式語言中非常常見的顯式鎖或條件變數。

示例程式碼:

package main

import (
    "fmt"
    "time"
)

func hello(done chan bool) {
    fmt.Println("hello go routine is going to sleep")
    time.Sleep(4 * time.Second)
    fmt.Println("hello go routine awake and going to write to done")
    done <- true
}
func main() {
    done := make(chan bool)
    fmt.Println("Main going to call hello go goroutine")
    go hello(done)
    <-done
    fmt.Println("Main received data")
}

執行結果:

 Main going to call hello go goroutine
 hello go routine is going to sleep
 hello go routine awake and going to write to done
 Main received data

 

定向通道

之前我們學習的通道都是雙向通道,我們可以通過這些通道接收或者傳送資料。我們也可以建立單向通道,這些通道只能傳送或者接收資料。

建立僅能傳送資料的通道,示例程式碼:

package main

import "fmt"

func sendData(sendch chan<- int) {
    sendch <- 10
}

func main() {
    sendch := make(chan<- int)
    go sendData(sendch)
    fmt.Println(<-sendch)
}

報錯:

 # command-line-arguments
 .\main.go:12:14: invalid operation: <-sendch (receive from send-only type chan<- int)

 

示例程式碼:

package main

import "fmt"

func sendData(sendch chan<- int) {
    sendch <- 10
}

func main() {
    chnl := make(chan int)
    go sendData(chnl)
    fmt.Println(<-chnl)
}

執行結果:
10

 

死鎖

為什麼會死鎖?非緩衝通道上如果發生了流入無流出,或者流出無流入,也就導致了死鎖。或者這樣理解 Go啟動的所有goroutine裡的非緩衝通道一定要一個線裡存資料,一個線裡取資料,要成對才行 。

示例程式碼:

package main

func main() {
c, quit := make(chan int), make(chan int)

go func() {
c <- 1 // c通道的資料沒有被其他goroutine讀取走,堵塞當前goroutine
quit <- 0 // quit始終沒有辦法寫入資料
}()

<-quit // quit 等待資料的寫
}
報錯:
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
    /tmp/sandbox249677995/main.go:11 +0x80

 

關閉通道

關閉通道只是關閉了向通道寫入資料,但可以從通道讀取。

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)
    }
}

 

四、緩衝通道

之前學習的所有通道基本上都沒有緩衝。傳送和接收到一個未緩衝的通道是阻塞的。

可以用緩衝區建立一個通道。傳送到一個緩衝通道只有在緩衝區滿時才被阻塞。類似地,從緩衝通道接收的資訊只有在緩衝區為空時才會被阻塞。

可以通過將額外的容量引數傳遞給make函式來建立緩衝通道,該函式指定緩衝區的大小。

語法:

ch := make(chan type, capacity)

上述語法的容量應該大於0,以便通道具有緩衝區。預設情況下,無緩衝通道的容量為0,因此在之前建立通道時省略了容量引數。

示例程式碼:

func main() {
    done := make(chan int, 1) // 帶快取的管道

    go func(){
        fmt.Println("你好, 世界")
        done <- 1
    }()

    <-done
}

 

五、最後

以上,就把golang併發程式設計相關的內容介紹完了,希望能對大家有所幫助。

&n