1. 程式人生 > >Go學習(15):併發與包

Go學習(15):併發與包

併發性Concurrency

1.1 什麼是併發

Go是併發語言,而不是並行語言。在討論如何在Go中進行併發處理之前,我們首先必須瞭解什麼是併發,以及它與並行性有什麼不同。(Go is a concurrent language and not a parallel one. )

併發性Concurrency是同時處理許多事情的能力。

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

什麼是並行性parallelism,它與併發concurrency有什麼不同?
並行就是同時做很多事情。這聽起來可能與併發類似,但實際上是不同的。

讓我們用同樣的慢跑例子更好地理解它。在這種情況下,我們假設這個人正在慢跑,並且使用它的手機聽音樂。在這種情況下,一個人一邊慢跑一邊聽音樂,那就是他同時在做很多事情。這就是所謂的並行性(parallelism)。

併發性和並行性——一種技術上的觀點。
假設我們正在編寫一個web瀏覽器。web瀏覽器有各種元件。其中兩個是web頁面呈現區域和下載檔案從internet下載的下載器。假設我們以這樣的方式構建了瀏覽器的程式碼,這樣每個元件都可以獨立地執行(這是在Java和Go中使用執行緒來完成的,我們可以在稍後使用Goroutines來實現這一點)。當這個瀏覽器執行在單個核處理器中時,處理器將在瀏覽器的兩個元件之間進行上下文切換。它可能會下載一個檔案一段時間,然後它可能會切換到呈現使用者請求的網頁的html。這就是所謂的併發性。併發程序從不同的時間點開始,它們的執行週期重疊。在這種情況下,下載和呈現從不同的時間點開始,它們的執行重疊。假設同一瀏覽器執行在多核處理器上。在這種情況下,檔案下載元件和HTML呈現元件可能同時在不同的核心中執行。這就是所謂的並行性。

並行性Parallelism不會總是導致更快的執行時間。這是因為並行執行的元件可能需要相互通訊。例如,在我們的瀏覽器中,當檔案下載完成時,應該將其傳遞給使用者,比如使用彈出視窗。這種通訊發生在負責下載的元件和負責呈現使用者介面的元件之間。這種通訊開銷在併發concurrent 系統中很低。當元件在多個核心中並行concurrent 執行時,這種通訊開銷很高。因此,並行程式並不總是導致更快的執行時間!

程序(Process),執行緒(Thread),協程(Coroutine,也叫輕量級執行緒)

程序
程序是一個程式在一個數據集中的一次動態執行過程,可以簡單理解為“正在執行的程式”,它是CPU資源分配和排程的獨立單位。
程序一般由程式、資料集、程序控制塊三部分組成。我們編寫的程式用來描述程序要完成哪些功能以及如何完成;資料集則是程式在執行過程中所需要使用的資源;程序控制塊用來記錄程序的外部特徵,描述程序的執行變化過程,系統可以利用它來控制和管理程序,它是系統感知程序存在的唯一標誌。 程序的侷限是建立、撤銷和切換的開銷比較大。

執行緒
執行緒是在程序之後發展出來的概念。 執行緒也叫輕量級程序,它是一個基本的CPU執行單元,也是程式執行過程中的最小單元,由執行緒ID、程式計數器、暫存器集合和堆疊共同組成。一個程序可以包含多個執行緒。
執行緒的優點是減小了程式併發執行時的開銷,提高了作業系統的併發效能,缺點是執行緒沒有自己的系統資源,只擁有在執行時必不可少的資源,但同一程序的各執行緒可以共享程序所擁有的系統資源,如果把程序比作一個車間,那麼執行緒就好比是車間裡面的工人。不過對於某些獨佔性資源存在鎖機制,處理不當可能會產生“死鎖”。

協程
協程是一種使用者態的輕量級執行緒,又稱微執行緒,英文名Coroutine,協程的排程完全由使用者控制。人們通常將協程和子程式(函式)比較著理解。
子程式呼叫總是一個入口,一次返回,一旦退出即完成了子程式的執行。

與傳統的系統級執行緒和程序相比,協程的最大優勢在於其"輕量級",可以輕鬆建立上百萬個而不會導致系統資源衰竭,而執行緒和程序通常最多也不能超過1萬的。這也是協程也叫輕量級執行緒的原因。

協程的特點在於是一個執行緒執行,與多執行緒相比,其優勢體現在:協程的執行效率極高。因為子程式切換不是執行緒切換,而是由程式自身控制,因此,沒有執行緒切換的開銷,和多執行緒比,執行緒數量越多,協程的效能優勢就越明顯。

1.2 Goroutines

1.2.1 什麼是Goroutines

go中使用Goroutines來實現併發concurrently。Goroutines是與其他函式或方法同時執行的函式或方法。Goroutines可以被認為是輕量級的執行緒。與執行緒相比,建立Goroutine的成本很小,它就是一段程式碼,一個函式入口。以及在堆上為其分配的一個堆疊(初始大小為4K,會隨著程式的執行自動增長刪除)。因此它非常廉價,Go應用程式可以併發執行數千個Goroutines。

Goroutines線上程上的優勢。

  1. 與執行緒相比,Goroutines非常便宜。它們只是堆疊大小的幾個kb,堆疊可以根據應用程式的需要增長和收縮,而線上程的情況下,堆疊大小必須指定並且是固定的
  2. Goroutines被多路複用到較少的OS執行緒。在一個程式中可能只有一個執行緒與數千個Goroutines。如果執行緒中的任何Goroutine都表示等待使用者輸入,則會建立另一個OS執行緒,剩下的Goroutines被轉移到新的OS執行緒。所有這些都由執行時進行處理,我們作為程式設計師從這些複雜的細節中抽象出來,並得到了一個與併發工作相關的乾淨的API。
  3. 當使用Goroutines訪問共享記憶體時,通過設計的通道可以防止競態條件發生。通道可以被認為是Goroutines通訊的管道。

1.2.2 如何使用Goroutines

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

例項程式碼:

package main

import (  
    "fmt"
)

func hello() {  
    fmt.Println("Hello world goroutine")
}
func main() {  
    go hello()
    fmt.Println("main function")
}

執行結果:可能會值輸出“main function”。

main function

我們開始的Goroutine怎麼樣了?我們需要了解Goroutine的規則

  1. 當新的Goroutine開始時,Goroutine呼叫立即返回。與函式不同,go不等待Goroutine執行結束。當Goroutine呼叫,並且Goroutine的任何返回值被忽略之後,go立即執行到下一行程式碼。
  2. main的Goroutine應該為其他的Goroutines執行。如果main的Goroutine終止了,程式將被終止,而其他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

在上面的程式中,我們已經呼叫了時間包的Sleep方法,它會在執行過程中休眠。在這種情況下,main的goroutine被用來睡覺1秒。現在呼叫go hello()有足夠的時間在main Goroutine終止之前執行。這個程式首先列印Hello world goroutine,等待1秒,然後列印main函式。

1.2.3 啟動多個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  

時間軸分析:

在這裡插入圖片描述

1.3通道channels

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

1.3.1 宣告通道

每個通道都有與其相關的型別。該型別是通道允許傳輸的資料型別。(通道的零值為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) 

1.3.2 傳送和接收

傳送和接收的語法:

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

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

1.3.3 傳送和接收預設是阻塞的

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

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

示例程式碼:

package main

import (  
    "fmt"
)

func hello(done chan bool) {  
    fmt.Println("Hello world goroutine")
    done <- true
}
func main() {  
    done := make(chan bool)
    go hello(done)
    <-done // 接收資料,阻塞式
    fmt.Println("main function")
}

執行結果:

Hello world goroutine  
main function 

在上面的程式中,我們在第一行中建立了一個done bool通道。把它作為引數傳遞給hello Goroutine。第14行我們正在接收已完成頻道的資料。這一行程式碼是阻塞的,這意味著在某些Goroutine將資料寫入到已完成的通道之前,程式將不會執行到下一行程式碼。因此,這就消除了對時間的需求。睡眠在原來的程式中,以防止主要的Goroutine退出。

程式碼<-done接收來自done Goroutine的資料,但不使用或儲存任何變數中的資料。這是完全合法的。

現在,我們的main Goroutine阻塞等待已完成通道的資料。hello Goroutine接收這個通道作為引數,列印hello world Goroutine,然後寫入done通道。當此寫入完成時,main的Goroutine接收來自已完成通道的資料,它是未阻塞的,然後輸出文字主函式。

讓我們通過在hello Goroutine中引入睡眠來修改這個程式,以更好地理解這個阻塞的概念。

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

再一個例子,這個程式將列印一個數字的個位數的平方和。

package main

import (  
    "fmt"
)

func calcSquares(number int, squareop chan int) {  
    sum := 0
    for number != 0 {
        digit := number % 10
        sum += digit * digit
        number /= 10
    }
    squareop <- sum
}

func calcCubes(number int, cubeop chan int) {  
    sum := 0 
    for number != 0 {
        digit := number % 10
        sum += digit * digit * digit
        number /= 10
    }
    cubeop <- sum
} 
func main() {  
    number := 589
    sqrch := make(chan int)
    cubech := make(chan int)
    go calcSquares(number, sqrch)
    go calcCubes(number, cubech)
    squares, cubes := <-sqrch, <-cubech
    fmt.Println("Final output", squares + cubes)
}

執行結果:

Final output 1536

1.3.4 死鎖

使用通道時要考慮的一個重要因素是死鎖。如果Goroutine在一個通道上傳送資料,那麼預計其他的Goroutine應該接收資料。如果這種情況不發生,那麼程式將在執行時出現死鎖。

類似地,如果Goroutine正在等待從通道接收資料,那麼另一些Goroutine將會在該通道上寫入資料,否則程式將會死鎖。

示例程式碼:

package main

func main() {  
    ch := make(chan int)
    ch <- 5
}

報錯:

fatal error: all goroutines are asleep - deadlock!

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

1.3.5 定向通道

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

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

package main

import "fmt"

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

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

示例程式碼:

package main

import "fmt"

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

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

1.3.6 關閉通道和通道上的範圍迴圈

傳送者有可以通過關閉通道,來通知接收方不會有更多的資料被髮送到通道上。

接收者可以在接收來自通道的資料時使用額外的變數來檢查通道是否已經關閉。

語法結構:

v, ok := <- ch  

在上面的語句中,如果ok的值是true,表示成功的將value值傳送到一個通道。如果ok是false,這意味著我們正在從一個封閉的通道讀取資料。從閉通道讀取的值將是通道型別的零值。

例如,如果通道是一個int通道,那麼從封閉通道接收的值將為0。

示例程式碼:

package main

import (  
    "fmt"
)

func producer(chnl chan int) {  
    for i := 0; i < 10; i++ {
        chnl <- i
    }
    close(chnl)
}
func main() {  
    ch := make(chan int)
    go producer(ch)
    for {
        v, ok := <-ch
        if ok == false {
            break
        }
        fmt.Println("Received ", v, ok)
    }
}

執行結果

Received  0 true  
Received  1 true  
Received  2 true  
Received  3 true  
Received  4 true  
Received  5 true  
Received  6 true  
Received  7 true  
Received  8 true  
Received  9 true  

在上面的程式中,producer Goroutine將0到9寫入chnl通道,然後關閉通道。主函式裡有一個無限迴圈。它檢查通道是否在行號中使用變數ok關閉。如果ok是假的,則意味著通道關閉,因此迴圈結束。還可以列印接收到的值和ok的值。for迴圈的for range形式可用於從通道接收值,直到它關閉為止。

使用range迴圈,示例程式碼:

package main

import (  
    "fmt"
)

func producer(chnl chan int) {  
    for i := 0; i < 10; i++ {
        chnl <- i
    }
    close(chnl)
}
func main() {  
    ch := make(chan int)
    go producer(ch)
    for v := range ch {
        fmt.Println("Received ",v)
    }
}

1.3.7 緩衝通道

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

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

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

語法:

ch := make(chan type, capacity)  

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

示例程式碼:

package main

import (  
    "fmt"
)


func main() {  
    ch := make(chan string, 2)
    ch <- "naveen"
    ch <- "paul"
    fmt.Println(<- ch)
    fmt.Println(<- ch)
}

二、包(Package)

2.1 什麼是包?為什麼使用包?

到目前為止,我們已經看到了go程式,它只有一個檔案,它的主函式有幾個其他函式。在現實開發中,這種在單個檔案中編寫所有原始碼的方法是行不通的。這樣就不可能重用和維護程式碼。這時可以使用包。

包被用來組織go原始碼,以便更好地重用和可讀性。包提供了程式碼的劃分,因此很容易維護應用程式。

例如,我們正在建立一個go影象處理應用程式,它提供了影象裁剪、銳化、模糊和顏色增強等功能。組織此應用程式的一種方法是將與某個特性相關的所有程式碼分組到它自己的包中。例如,裁剪可以是一個單獨的包,銳化可以是另一個包。這樣做的好處是,顏色增強功能可能需要一些銳化功能。顏色增強程式碼可以簡單地匯入(我們將在一分鐘內討論匯入)這個銳化包並開始使用它的功能。這樣,程式碼就變得易於重用。

我們將逐步建立一個應用程式來計算矩形的面積和對角線。

我們將通過這個應用程式更好地理解包。

2.2 main函式和main包

每個可執行的應用程式必須包含一個主函式。這個函式是執行的入口點。主函式應該存在main包中。

指定特定原始檔屬於包的程式碼行是package packagename。這應該是每個go原始檔的第一行。

讓我們首先建立應用程式的主函式和主包。

在go工作區的src資料夾中建立一個資料夾,並將其命名為geometry。建立一個檔案geometry.go。

//geometry.go
package main  // 該行程式碼,主要用於指定這個go檔案 屬於main包。

import "fmt" // 用於匯入一個現有的包,現在匯入的是包含Println方法的fmt包

func main() { // 主函式,作為程式執行的入口 
    fmt.Println("Geometrical shape properties")
}

第一行程式碼package main ,主要用於指定這個go檔案 屬於main包。


原文地址:https://golangbot.com/goroutines/