1. 程式人生 > >Go語言併發程式設計(二)

Go語言併發程式設計(二)

通道(channel)

單純地將函式併發執行是沒有意義的。函式與函式間需要交換資料才能體現併發執行函式的意義。雖然可以使用共享記憶體進行資料交換,但是共享記憶體在不同的goroutine中容易發生競態問題。為了保證資料交換的正確性,必須使用互斥量對記憶體進行加鎖,這種做法勢必造成效能問題。

Go語言提倡使用通訊的方法代替共享記憶體,這裡通訊的方法就是使用通道(channel),如圖1-1所示所示。

圖1-1   goroutine與channel的通訊

通道的特性

Go 語言中的通道(channel)是一種特殊的型別。在任何時候,同時只能有一個 goroutine 訪問通道進行傳送和獲取資料。goroutine 間通過通道就可以通訊。通道像一個傳送帶或者佇列,總是遵循先入先出(First In First Out)的規則,保證收發資料的順序。

宣告通道型別

通道本身需要一個型別進行修飾,就像切片型別需要標識元素型別。通道的元素型別就是在其內部傳輸的資料型別,宣告如下:

var 通道變數 chan 通道型別

  

  • 通道型別:通道內的資料型別。
  • 通道變數:儲存通道的變數。

chan 型別的空值是 nil,聲明後需要配合 make 後才能使用。

建立通道

通道是引用型別,需要使用 make 進行建立,格式如下:

通道例項 := make(chan 資料型別)

  

  • 資料型別:通道內傳輸的元素型別。
  • 通道例項:通過make建立的通道控制代碼。

例如:

ch1 := make(chan int)                 // 建立一個整型型別的通道
ch2 := make(chan interface{})         // 建立一個空介面型別的通道, 可以存放任意格式

type Equip struct{ /* 一些欄位 */ }
ch2 := make(chan *Equip)             // 建立Equip指標型別的通道, 可以存放*Equip

  

使用通道傳送資料

通道建立後,就可以使用通道進行傳送和接收操作。

1.通道傳送資料的格式
通道的傳送使用特殊的操作符“<-”,將資料通過通道傳送的格式為:通道變數 <- 值。

  • 通道變數:通過make建立好的通道例項。
  • 值:可以是變數、常量、表示式或者函式返回值等。值的型別必須與ch通道的元素型別一致。

2.通過通道傳送資料的例子

使用 make 建立一個通道後,就可以使用<-向通道傳送資料,程式碼如下:

// 建立一個空介面通道
ch := make(chan interface{})
// 將0放入通道中
ch <- 0
// 將hello字串放入通道中
ch <- "hello"

  

3.傳送將持續阻塞直到資料被接收

把資料往通道中傳送時,如果接收方一直都沒有接收,那麼傳送操作將持續阻塞。Go 程式執行時能智慧地發現一些永遠無法傳送成功的語句並做出提示,程式碼如下:

package main

func main() {
    // 建立一個整型通道
    ch := make(chan int)

    // 嘗試將0通過通道傳送
    ch <- 0
}

  

執行程式碼,報錯:

fatal error: all goroutines are asleep - deadlock!

  

報錯的意思是:執行時發現所有的goroutine(包括main)都處於等待goroutine。也就是說所goroutine中的channel並沒有形成傳送和接收對應的程式碼。

使用通道接收資料

通道接收同樣使用<-操作符,通道接收有如下特性:

  •  通道的收發操作在不同的兩個 goroutine 間進行。由於通道的資料在沒有接收方處理時,資料傳送方會持續阻塞,因此通道的接收必定在另外一個 goroutine 中進行。
  • 接收將持續阻塞直到傳送方傳送資料。如果接收方接收時,通道中沒有傳送方傳送資料,接收方也會發生阻塞,直到傳送方傳送資料為止。
  • 每次接收一個元素。通道一次只能接收一個數據元素。

通道的資料接收一共有以下 4 種寫法。

1.阻塞接收資料

阻塞模式接收資料時,將接收變數作為<-操作符的左值,格式如下:

data := <-ch

  

執行該語句時將會阻塞,直到接收到資料並賦值給 data 變數。

2.非阻塞接收資料

使用非阻塞方式從通道接收資料時,語句不會發生阻塞,格式如下:

data, ok := <-ch

  

data:表示接收到的資料。未接收到資料時,data 為通道型別的零值。
ok:表示是否接收到資料。

非阻塞的通道接收方法可能造成高的 CPU 佔用,因此使用非常少。如果需要實現接收超時檢測,可以配合 select 和計時器 channel 進行,後面還會再介紹。

3.接收任意資料,忽略接收的資料

阻塞接收資料後,忽略從通道返回的資料,格式如下:

<-ch

  

執行該語句時將會發生阻塞,直到接收到資料,但接收到的資料會被忽略。這個方式實際上只是通過通道在 goroutine 間阻塞收發實現併發同步。

使用通道做併發同步的寫法,可以參考下面的例子:

package main

import (
	"fmt"
)

func main() {

	// 構建一個通道
	ch := make(chan int)

	// 開啟一個併發匿名函式
	go func() {

		fmt.Println("start goroutine")

		// 通過通道通知main的goroutine
		ch <- 0

		fmt.Println("exit goroutine")

	}()

	fmt.Println("wait goroutine")

	// 等待匿名goroutine
	<-ch

	fmt.Println("all done")

}

  

程式碼說明如下:

  • 第10行,構建一個同步用的通道。
  • 第13行,開啟一個匿名函式的併發。
  • 第18行,匿名goroutine即將結束時,通過通道通知main的goroutine,這一句會一直阻塞直到main的goroutine接收為止。
  • 第27行,開啟goroutine後,馬上通過通道等待匿名goroutine結束。

執行程式碼,輸出如下:

wait goroutine
start goroutine
exit goroutine
all done

  

4.迴圈接收
通道的資料接收可以借用for range語句進行多個元素的接收操作,格式如下:

for data := range ch {

}

  

通道ch 是可以進行遍歷的,遍歷的結果就是接收到的資料。資料型別就是通道的資料型別。通過for遍歷獲得的變數只有一個,即上面例子中的data。

遍歷通道資料的例子請參考下面的程式碼。

使用 for 從通道中接收資料:

package main

import (
	"fmt"

	"time"
)

func main() {

	// 構建一個通道
	ch := make(chan int)

	// 開啟一個併發匿名函式
	go func() {

		// 從3迴圈到0
		for i := 3; i >= 0; i-- {

			// 傳送3到0之間的數值
			ch <- i

			// 每次傳送完時等待
			time.Sleep(time.Second)
		}

	}()

	// 遍歷接收通道資料
	for data := range ch {

		// 列印通道資料
		fmt.Println(data)

		// 當遇到資料0時, 退出接收迴圈
		if data == 0 {
			break
		}
	}

}

  

程式碼說明如下:

  • 第12行,通過make生成一個整型元素的通道。
  • 第15行,將匿名函式併發執行。
  • 第18行,用迴圈生成3到0之間的數值。
  • 第21行,將3到0之間的數值依次傳送到通道ch中。
  • 第24行,每次傳送後暫停1秒。
  • 第30行,使用for從通道中接收資料。
  • 第33行,將接收到的資料打印出來。
  • 第36行,當接收到數值0時,停止接收。如果繼續傳送,由於接收goroutine已經退出,沒有goroutine傳送到通道,因此執行時將會觸發宕機報錯。

執行程式碼,輸出如下:

3
2
1
0

  

併發列印

上面的例子建立的都是無緩衝通道。使用無緩衝通道往裡面裝入資料時,裝入方將被阻塞,直到另外通道在另外一個goroutine中被取出。同樣,如果通道中沒有放入任何資料,接收方試圖從通道中獲取資料時,同樣也是阻塞。傳送和接收的操作是同步完成的。

下面通過一個併發列印的例子,將goroutine和channel放在一起展示它們的用法。

package main

import (
	"fmt"
)

func printer(c chan int) {

	// 開始無限迴圈等待資料
	for {

		// 從channel中獲取一個數據
		data := <-c

		// 將0視為資料結束
		if data == 0 {
			break
		}

		// 列印資料
		fmt.Println(data)
	}

	// 通知main已經結束迴圈(我搞定了!)
	c <- 0

}

func main() {

	// 建立一個channel
	c := make(chan int)

	// 併發執行printer, 傳入channel
	go printer(c)

	for i := 1; i <= 10; i++ {

		// 將資料通過channel投送給printer
		c <- i
	}

	// 通知併發的printer結束迴圈(沒資料啦!)
	c <- 0

	// 等待printer結束(搞定喊我!)
	<-c

}

  

程式碼說明如下:

  • 第10行,建立一個無限迴圈,只有當第16行獲取到的資料為0時才會退出迴圈。
  • 第13行,從函式引數傳入的通道中獲取一個整型數值。
  • 第21行,列印整型數值。
  • 第25行,在退出迴圈時,通過通道通知main()函式已經完成工作。
  • 第32行,建立一個整型通道進行跨goroutine的通訊。
  • 第35行,建立一個goroutine,併發執行printer()函式。
  • 第37行,構建一個數值迴圈,將1~10的數通過通道傳送給printer構造出的goroutine。
  • 第44行,給通道傳入一個0,表示將前面的資料處理完成後,退出迴圈。
  • 第47行,在資料傳送過去後,因為併發和排程的原因,任務會併發執行。這裡需要等待printer的第25行返回資料後,才可以退出main()。

程式碼說明如下:

1
2
3
4
5
6
7
8
9
10

  

本例的設計模式就是典型的生產者和消費者。生產者是第37行的迴圈,而消費者是printer()函式。整個例子使用了兩個goroutine,一個是main(),一個是通過第35行printer()函式建立的goroutine。兩個goroutine通過第32行建立的通道進行通訊。這個通道有下面兩重功能。

資料傳送:第40行中傳送資料和第13行接收資料。

控制指令:類似於訊號量的功能。同步goroutine的操作。功能簡單描述為:

  • 第44行:“沒資料啦!”
  • 第25行:“我搞定了!”
  • 第47行:“搞定喊我!”

單向通道

Go的通道可以在宣告時約束其操作方向,如只發送或只接收。這種被約束方向的通道被稱作單向通道。

1.單向通道的宣告格式

只能傳送的通道型別為chan<-,只能接收的通道型別為<-chan,格式如下:

var 通道例項 chan<- 元素型別     // 只能傳送通道
var 通道例項 <-chan 元素型別     // 只能接收通道

  

  • 元素型別:通道包含的元素型別。
  • 通道例項:宣告的通道變數。

2.單向通道的使用例子

示例程式碼如下:

ch := make(chan int)
// 宣告一個只能傳送的通道型別, 並賦值為ch
var chSendOnly chan<- int = ch
//宣告一個只能接收的通道型別, 並賦值為ch
var chRecvOnly <-chan int = ch

  

上面的例子中,chSendOnly只能傳送資料,如果嘗試接收資料,將會出現如下報錯:

invalid operation: <-chSendOnly (receive from send-only type chan<- int)

  

同理,chRecvOnly也是不能傳送的。當然,使用make建立通道時,也可以建立一個只發送或只讀取的通道:

ch := make(<-chan int)

var chReadOnly <-chan int = ch
<-chReadOnly

  

上面程式碼編譯正常,執行也是正確的。但是,一個不能填充資料(傳送)只能讀取的通道是毫無意義的。

time包中的單向通道

time包中的計時器會返回一個timer例項,程式碼如下:

timer := time.NewTimer(time.Second)

  

timer的Timer型別定義如下:

type Timer struct {
    C <-chan Time
    r runtimeTimer
}

  

第2行中C通道的型別就是一種只能接收的單向通道。如果此處不進行通道方向約束,一旦外部向通道傳送資料,將會造成其他使用到計時器的地方邏輯產生混亂。因此,單向通道有利於程式碼介面的嚴謹性。

Go語言帶緩衝的通道

在無緩衝通道的基礎上,為通道增加一個有限大小的儲存空間形成帶緩衝通道。帶緩衝通道在傳送時無需等待接收方接收即可完成傳送過程,並且不會發生阻塞,只有當儲存空間滿時才會發生阻塞。同理,如果緩衝通道中有資料,接收時將不會發生阻塞,直到通道中沒有資料可讀時,通道將會再度阻塞。

無緩衝通道保證收發過程同步。無緩衝收發過程類似於快遞員給你電話讓你下樓取快遞,整個遞交快遞的過程是同步發生的,你和快遞員不見不散。但這樣做快遞員就必須等待所有人下樓完成操作後才能完成所有投遞工作。如果快遞員將快遞放入快遞櫃中,並通知使用者來取,快遞員和使用者就成了非同步收發過程,效率可以有明顯的提升。帶緩衝的通道就是這樣的一個“快遞櫃”。

1.建立帶緩衝通道

如何建立帶緩衝的通道呢?參見如下程式碼:

通道例項 := make(chan 通道型別, 緩衝大小)

  

  • 通道型別:和無緩衝通道用法一致,影響通道傳送和接收的資料型別。
  • 緩衝大小:決定通道最多可以儲存的元素數量。
  • 通道例項:被創建出的通道例項。

下面通過一個例子中來理解帶緩衝通道的用法,參見下面的程式碼:

package main

import "fmt"

func main() {

	// 建立一個3個元素緩衝大小的整型通道
	ch := make(chan int, 3)

	// 檢視當前通道的大小
	fmt.Println(len(ch))

	// 傳送3個整型元素到通道
	ch <- 1
	ch <- 2
	ch <- 3

	// 檢視當前通道的大小
	fmt.Println(len(ch))
}

  

程式碼說明如下:

  • 第8行,建立一個帶有3個元素緩衝大小的整型型別的通道。
  • 第11行,檢視當前通道的大小。帶緩衝的通道在建立完成時,內部的元素是空的,因此使用len()獲取到的返回值為0。
  • 第14~16行,傳送3個整型元素到通道。因為使用了緩衝通道。即便沒有goroutine接收,傳送者也不會發生阻塞。
  • 第19行,由於填充了3個通道,此時的通道長度變為3。

程式碼輸出如下:

0
3

  

2.阻塞條件

帶緩衝通道在很多特性上和無緩衝通道是類似的。無緩衝通道可以看作是長度永遠為0的帶緩衝通道。因此根據這個特性,帶緩衝通道在下面列舉的情況下依然會發生阻塞:

  1. 帶緩衝通道被填滿時,嘗試再次傳送資料時發生阻塞。
  2. 帶緩衝通道為空時,嘗試接收資料時發生阻塞。

為什麼Go語言對通道要限制長度而不提供無限長度的通道?
我們知道通道(channel)是在兩個goroutine間通訊的橋樑。使用goroutine的程式碼必然有一方提供資料,一方消費資料。當提供資料一方的資料供給速度大於消費方的資料處理速度時,如果通道不限制長度,那麼記憶體將不斷膨脹直到應用崩潰。因此,限制通道的長度有利於約束資料提供方的供給速度,供給資料量必須在消費方處理量+通道長度的範圍內,才能正常地處理資料。

Go語言通道的多路複用

多路複用是通訊和網路中的一個專業術語。多路複用通常表示在一個通道上傳輸多路訊號或資料流的過程和技術。

提示:報話機同一時刻只能有一邊進行收或者發的單邊通訊,報話機需要遵守的通訊流程如下:

  • 說話方在完成時需要補上一句“完畢”,隨後放開通話按鈕,從傳送切換到接收狀態,收聽對方說話。
  • 收聽方在聽到對方說“完畢”時,按下通話按鈕,從接收切換到傳送狀態,開始說話。

電話可以在說話的同時聽到對方說話,所以電話是一種多路複用的裝置,一條通訊線路上可以同時接收或者傳送資料。同樣的,網線、光纖也都是基於多路複用模式來設計的,網線、光纖不僅可支援同時收發資料,還支援多個人同時收發資料。

在使用通道時,想同時接收多個通道的資料是一件困難的事情。通道在接收資料時,如果沒有資料可以接收將會發生阻塞。雖然可以使用如下模式進行遍歷,但執行效能會非常差。

for{
    // 嘗試接收ch1通道
    data, ok := <-ch1
    // 嘗試接收ch2通道
    data, ok := <-ch2
    // 接收後續通道
    …
}

  

Go語言中提供了select關鍵字,可以同時響應多個通道的操作。select的每個case都會對應一個通道的收發過程。當收發完成時,就會觸發case中響應的語句。多個操作在每次select中挑選一個進行響應。格式如下:

select{
    case 操作1:
        響應操作1
    case 操作2:
        響應操作2
    …
    default:
        沒有操作情況
}

  

操作1、操作2:包含通道收發語句,請參考表1-1:

表1-1   select多路複用中可以接收的樣式
操作 語句示例
接收任意資料 case <-ch;
接收變數 case d :=<-ch;
傳送資料 case ch <-100;

響應操作1、響應操作2:當操作發生時,會執行對應 case 的響應操作。default:當沒有任何操作時,預設執行 default 中的語句。

Go語言RPC

伺服器開發中會使用RPC(Remote Procedure Call,遠端過程呼叫)簡化程序間通訊的過程。RPC 能有效地封裝通訊過程,讓遠端的資料收發通訊過程看起來就像本地的函式呼叫一樣。

本例中,使用通道代替socket實現RPC的過程。客戶端與伺服器執行在同一個程序,伺服器和客戶端在兩個goroutine中執行。

1.客戶端請求和接收封裝

下面的程式碼封裝了向伺服器請求資料,等待伺服器返回資料,如果請求方超時,該函式還會處理超時邏輯。

// 模擬RPC客戶端的請求和接收訊息封裝
func RPCClient(ch chan string, req string) (string, error) {

	// 向伺服器傳送請求
	ch <- req

	// 等待伺服器返回
	select {
	case ack := <-ch: // 接收到伺服器返回資料
		return ack, nil
	case <-time.After(time.Second): // 超時
		return "", errors.New("Time out")
	}
}

  

程式碼說明如下:

  • 第5行,模擬socket向伺服器傳送一個字串資訊。伺服器接收後,結束阻塞執行下一行。
  • 第8行,使用select開始做多路複用。注意,select雖然在寫法上和switch一樣,都可以擁有case和default。但是select關鍵字後面不接任何語句,而是將要複用的多個通道語句寫在每一個case上,如第9行和第11行所示。
  • 第11行,使用了time包提供的函式After(),從字面意思看就是多少時間之後,其引數是time包的一個常量,time.Second表示1秒。time.After返回一個通道,這個通道在指定時間後,通過通道返回當前時間。
  • 第12行,在超時時,返回超時錯誤。

RPCClient()函式中,執行到select語句時,第9行和第11行的通道操作會同時開啟。如果第9行的通道先返回,則執行第10行邏輯,表示正常接收到伺服器資料;如果第11行的通道先返回,則執行第12行的邏輯,表示請求超時,返回錯誤。

2.伺服器接收和反饋資料
伺服器接收到客戶端的任意資料後,先列印再通過通道返回給客戶端一個固定字串,表示伺服器已經收到請求。

// 模擬RPC伺服器端接收客戶端請求和迴應
func RPCServer(ch chan string) {
    for {
        // 接收客戶端請求
        data := <-ch

        // 列印接收到的資料
        fmt.Println("server received:", data)

        //向客戶端反饋已收到
        ch <- "roger"
    }
}

  

程式碼說明如下:

  • 第3行,構造出一個無限迴圈。伺服器處理完客戶端請求後,通過無限迴圈繼續處理下一個客戶端請求。
  • 第5行,通過字串通道接收一個客戶端的請求。
  • 第8行,將接收到的資料打印出來。
  • 第11行,給客戶端反饋一個字串。

執行整個程式,客戶端可以正確收到伺服器返回的資料,客戶端RPCClient()函式的程式碼按下面程式碼中第三行分支執行。

// 等待伺服器返回
select {
case ack := <-ch:  // 接收到伺服器返回資料
    return ack, nil
case <-time.After(time.Second):  // 超時
    return "", errors.New("Time out")
}

  

程式輸出如下:

server received: hi
client received roger

  

3.模擬超時

上面的例子雖然有客戶端超時處理,但是永遠不會觸發,因為伺服器的處理速度很快,也沒有真正的網路延時或者“伺服器宕機”的情況。因此,為了展示select中超時的處理,在伺服器邏輯中增加一條語句,故意讓伺服器延時處理一段時間,造成客戶端請求超時,程式碼如下:

// 模擬RPC伺服器端接收客戶端請求和迴應
func RPCServer(ch chan string) {
    for {
        // 接收客戶端請求
        data := <-ch

        // 列印接收到的資料
        fmt.Println("server received:", data)

        // 通過睡眠函式讓程式執行阻塞2秒的任務
        time.Sleep(time.Second * 2)

        // 反饋給客戶端收到
        ch <- "roger"
    }
}

  

第11行中,time.Sleep()函式會讓goroutine執行暫停2秒。使用這種方法模擬伺服器延時,造成客戶端超時。客戶端處理超時1秒時通道就會返回:

// 等待伺服器返回
select {
case ack := <-ch:  // 接收到伺服器返回資料
    return ack, nil
case <-time.After(time.Second):  // 超時
    return "", errors.New("Time out")
}

  

4.主流程

主流程中會建立一個無緩衝的字串格式通道。將通道傳給伺服器的RPCServer()函式,這個函式併發執行。使用RPCClient()函式通過ch對伺服器發出RPC請求,同時接收伺服器反饋資料或者等待超時。參考下面程式碼:

func main() {

    // 建立一個無緩衝字串通道
    ch := make(chan string)

    // 併發執行伺服器邏輯
    go RPCServer(ch)

    // 客戶端請求資料和接收資料
    recv, err := RPCClient(ch, "hi")
    if err != nil {
            // 發生錯誤列印
        fmt.Println(err)
    } else {
            // 正常接收到資料
        fmt.Println("client received", recv)
    }

}

  

程式碼說明如下:

  • 第4行,建立無緩衝的字串通道,這個通道用於模擬網路和socket概念,既可以從通道接收資料,也可以傳送。
  • 第7行,併發執行伺服器邏輯。伺服器一般都是獨立程序的,這裡使用併發將伺服器和客戶端邏輯同時在一個程序內執行。
  • 第10行,使用RPCClient()函式,傳送“hi”給伺服器,同步等待伺服器返回。
  • 第13行,如果通訊過程發生錯誤,列印錯誤。
  • 第16行,正常接收時,列印收到的資料。

完成程式碼:

package main

import (
	"errors"
	"fmt"
	"time"
)

// 模擬RPC客戶端的請求和接收訊息封裝
func RPCClient(ch chan string, req string) (string, error) {

	// 向伺服器傳送請求
	ch <- req

	// 等待伺服器返回
	select {
	case ack := <-ch: // 接收到伺服器返回資料
		return ack, nil
	case <-time.After(time.Second): // 超時
		return "", errors.New("Time out")
	}
}

// 模擬RPC伺服器端接收客戶端請求和迴應
func RPCServer(ch chan string) {
	for {
		// 接收客戶端請求
		data := <-ch

		// 列印接收到的資料
		fmt.Println("server received:", data)

		// 反饋給客戶端收到
		ch <- "roger"
	}
}

func main() {

	// 建立一個無緩衝字串通道
	ch := make(chan string)

	// 併發執行伺服器邏輯
	go RPCServer(ch)

	// 客戶端請求資料和接收資料
	recv, err := RPCClient(ch, "hi")
	if err != nil {
		// 發生錯誤列印
		fmt.Println(err)
	} else {
		// 正常接收到資料
		fmt.Println("client received", recv)
	}

}

  

使用通道響應計時器的事件

Go語言中的time包提供了計時器的封裝。由於Go語言中的通道和goroutine的設計,定時任務可以在goroutine中通過同步的方式完成,也可以通過在goroutine中非同步回撥完成。這裡將分兩種用法進行例子展示。

1.一段時間之後(time.After)

package main

import (
    "fmt"
    "time"
)

func main() {
    // 宣告一個退出用的通道
    exit := make(chan int)

    // 列印開始
    fmt.Println("start")

    // 過1秒後, 呼叫匿名函式
    time.AfterFunc(time.Second, func() {

        // 1秒後, 列印結果
        fmt.Println("one second after")

        // 通知main()的goroutine已經結束
        exit <- 0
    })

    // 等待結束
    <-exit
}

  

程式碼說明如下:

  • 第10行,宣告一個退出用的通道,往這個通道里寫資料表示退出。
  • 第16行,呼叫time.AfterFunc()函式,傳入等待的時間和一個回撥。回撥使用一個匿名函式,在時間到達後,匿名函式會在另外一個goroutine中被呼叫。
  • 第22行,任務完成後,往退出通道中寫入數值表示需要退出。
  • 第26行,執行到此處時持續阻塞,直到1秒後第22行被執行後結束阻塞。

time.AfterFunc()函式是在time.After基礎上增加了到時的回撥,方便使用。而time.After()函式又是在time.NewTimer()函式上進行的封裝,下面的例子展示如何使用timer.NewTimer()和time.NewTicker()。

2.定點計時

計時器(Timer)的原理和倒計時鬧鐘類似,都是給定多少時間後觸發。打點器(Ticker)的原理和鐘錶類似,鐘錶每到整點就會觸發。這兩種方法建立後會返回time.Ticker物件和time.Timer物件,裡面通過一個C成員,型別是隻能接收的時間通道(<-chanTime),使用這個通道就可以獲得時間觸發的通知。

下面程式碼建立一個打點器,每500毫秒觸發一起;建立一個計時器,2秒後觸發,只觸發一次。

package main

import (
    "fmt"
    "time"
)

func main() {

    // 建立一個打點器, 每500毫秒觸發一次
    ticker := time.NewTicker(time.Millisecond * 500)

    // 建立一個計時器, 2秒後觸發
    stopper := time.NewTimer(time.Second * 2)

    // 宣告計數變數
    var i int

    // 不斷地檢查通道情況
    for {

        // 多路複用通道
        select {
        case <-stopper.C:  // 計時器到時了

            fmt.Println("stop")

            // 跳出迴圈
            goto StopHere

        case <-ticker.C:  // 打點器觸發了
            // 記錄觸發了多少次
            i++
            fmt.Println("tick", i)
        }
    }

// 退出的標籤, 使用goto跳轉
StopHere:
    fmt.Println("done")

}

  

程式碼說明如下:

  • 第11行,建立一個打點器,500毫秒觸發一次,返回*time.Ticker型別變數。
  • 第14行,建立一個計時器,2秒後返回,返回*time.Timer型別變數。
  • 第17行,宣告一個變數,用於累計打點器觸發次數。
  • 第20行,每次觸發後,select會結束,需要使用迴圈再次從打點器返回的通道中獲取觸發通知。
  • 第23行,同時等待多路計時器訊號。
  • 第24行,計時器訊號到了。
  • 第29行,通過goto跳出迴圈。
  • 第31行,打點器訊號到了,通過i自加記錄觸發次數並列印。

關閉通道後繼續使用通道

通道是一個引用物件,和map類似。map在沒有任何外部引用時,Go程式在執行時(runtime)會自動對記憶體進行垃圾回收(GarbageCollection,GC)。類似的,通道也可以被垃圾回收,但是通道也可以被主動關閉。

1.格式

使用 close() 來關閉一個通道:

close(ch)

  

關閉的通道依然可以被訪問,訪問被關閉的通道將會發生一些問題。

2.給被關閉通道傳送資料將會觸發panic

被關閉的通道不會被置為 nil。如果嘗試對已經關閉的通道進行傳送,將會觸發宕機,程式碼如下:

package main

import "fmt"

func main() {
    // 建立一個整型的通道
    ch := make(chan int)

    // 關閉通道
    close(ch)

    // 列印通道的指標, 容量和長度
    fmt.Printf("ptr:%p cap:%d len:%d\n", ch, cap(ch), len(ch))

    // 給關閉的通道傳送資料
    ch <- 1
}

  

程式碼說明如下:

  • 第7行,建立一個整型通道。
  • 第10行,關閉通道,注意ch不會被close設定為nil,依然可以被訪問。
  • 第13行,列印已經關閉通道的指標、容量和長度。
  • 第16行,嘗試給已經關閉的通道傳送資料。

程式碼執行後觸發宕機:

ptr:0xc042052060 cap:0 len:0
panic: send on closed channel

  

提示觸發宕機的原因是給一個已經關閉的通道傳送資料。

3.從已關閉的通道接收資料時將不會發生阻塞

從已經關閉的通道接收資料或者正在接收資料時,將會接收到通道型別的零值,然後停止阻塞並返回。

操作關閉後的通道:

package main

import "fmt"

func main() {
    // 建立一個整型帶兩個緩衝的通道
    ch := make(chan int, 2)
   
    // 給通道放入兩個資料
    ch <- 0
    ch <- 1
   
    // 關閉緩衝
    close(ch)

    // 遍歷緩衝所有資料, 且多遍歷1個
    for i := 0; i < cap(ch)+1; i++ {
   
        // 從通道中取出資料
        v, ok := <-ch
       
        // 列印取出資料的狀態
        fmt.Println(v, ok)
    }
}

  

程式碼說明如下:

  • 第7行,建立一個能儲存兩個元素的帶緩衝的通道,型別為整型。
  • 第10行和第11行,給這個帶緩衝的通道放入兩個資料。這時,通道裝滿了。
  • 第14行,關閉通道。此時,帶緩衝通道的資料不會被釋放,通道也沒有消失。
  • 第17行,cap()函式可以獲取一個物件的容量,這裡獲取的是帶緩衝通道的容量,也就是這個通道在make時的大小。雖然此時這個通道的元素個數和容量都是相同的,但是cap取出的並不是元素個數。這裡多遍歷一個元素,故意造成這個通道的超界訪問。
  • 第20行,從已關閉的通道中獲取資料,取出的資料放在v變數中,型別為int。ok變數的結果表示資料是否獲取成功。
  • 第23行,將v和ok變數打印出來。

程式碼執行結果如下:

0 true
1 true
0 false

  

執行結果前兩行正確輸出帶緩衝通道的資料,表明緩衝通道在關閉後依然可以訪問內部的資料。

執行結果第三行的“0false”表示通道在關閉狀態下取出的值。0表示這個通道的預設值,false表示沒有獲取成功,因為此時通道已經空了。我們發現,在通道關閉後,即便通道沒有資料,在獲取時也不會發生阻塞,但此時取出資料會失敗。