Go語言 協(xié)程、延遲函數(shù)調用、以及恐慌和恢復

2023-02-16 17:36 更新

此篇文章將介紹協(xié)程和延遲函數(shù)調用。協(xié)程和延遲函數(shù)調用是Go中比較獨特的兩個特性。 恐慌和恢復也將在此篇文章中得到簡單介紹。本文并非全面地對這些特性進行介紹,后面的其它文章會陸續(xù)補全本文的未介紹的內容。

協(xié)程(goroutine)

現(xiàn)代CPU一般含有多個核,并且一個核可能支持多線程。換句話說,現(xiàn)代CPU可以同時執(zhí)行多條指令流水線。 為了將CPU的能力發(fā)揮到極致,我們常常需要使我們的程序支持并發(fā)(concurrent)計算。

并發(fā)計算是指若干計算可能在某些時間片段內同時運行的情形。 下面這兩張圖描繪了兩種并發(fā)計算的場景。在此圖中,A和B表示兩個計算。 在第一種情形中,兩個計算只在某些時間片段同時運行。 第二種情形稱為并行(parallel)計算。在并行計算中,多個計算在任何時間點都在同時運行。并行計算屬于特殊的并發(fā)計算。

并發(fā)和并行

并發(fā)計算可能發(fā)生在同一個程序中、同一臺電腦上、或者同一個網絡中。 在《Go語言101》中,我們只談及發(fā)生在同一個程序中的并發(fā)計算。 在Go編程中,協(xié)程是創(chuàng)建計算的唯一途徑。

協(xié)程有時也被稱為綠色線程。綠色線程是由程序的運行時(runtime)維護的線程。一個綠色線程的內存開銷和情景轉換(context switching)時耗比一個系統(tǒng)線程常常小得多。 只要內存充足,一個程序可以輕松支持上萬個并發(fā)協(xié)程。

Go不支持創(chuàng)建系統(tǒng)線程,所以協(xié)程是一個Go程序內部唯一的并發(fā)實現(xiàn)方式。

每個Go程序啟動的時候只有一個對用戶可見的協(xié)程,我們稱之為主協(xié)程。 一個協(xié)程可以開啟更多其它新的協(xié)程。在Go中,開啟一個新的協(xié)程是非常簡單的。 我們只需在一個函數(shù)調用之前使用一個go關鍵字,即可讓此函數(shù)調用運行在一個新的協(xié)程之中。 當此函數(shù)調用退出后,這個新的協(xié)程也隨之結束了。我們可以稱此函數(shù)調用為一個協(xié)程調用(或者為此協(xié)程的啟動調用)。 一個協(xié)程調用的所有返回值(如果存在的話)必須被全部舍棄。

在下面的例子程序中,主協(xié)程創(chuàng)建了兩個新的協(xié)程。在此例中,time.Duration是一個在time標準庫包中定義的類型。 此類型的底層類型為內置類型int64。 底層類型這個概念將在下一篇文章中介紹。

package main

import (
	"log"
	"math/rand"
	"time"
)

func SayGreetings(greeting string, times int) {
	for i := 0; i < times; i++ {
		log.Println(greeting)
		d := time.Second * time.Duration(rand.Intn(5)) / 2
		time.Sleep(d) // 睡眠片刻(隨機0到2.5秒)
	}
}

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)
	go SayGreetings("hi!", 10)
	go SayGreetings("hello!", 10)
	time.Sleep(2 * time.Second)
}

非常簡單!我們編寫了一個并發(fā)程序! 此程序在運行的時候在某一時刻將很可能會有三個協(xié)程并存。 運行之,可能會得到如下的結果(也可能是其它結果):

hi!
hello!
hello!
hello!
hello!
hi!

當一個程序的主協(xié)程退出后,此程序也就退出了,即使還有一些其它協(xié)程在運行。

和前面的幾篇文章不同,上面的例子程序使用了?log?標準庫而不是?fmt?標準庫中的?Println?函數(shù)。 原因是?log?標準庫中的打印函數(shù)是經過了同步處理的(下一節(jié)將解釋什么是并發(fā)同步),而?fmt?標準庫中的打印函數(shù)卻沒有被同步。 如果我們在上例中使用?fmt?標準庫中的?Println?函數(shù),則不同協(xié)程的打印可能會交織在一起。(雖然對此例來說,交織的概率很低。)

并發(fā)同步(concurrency synchronization)

不同的并發(fā)計算可能共享一些資源,其中共享內存資源最為常見。 在一個并發(fā)程序中,常常會發(fā)生下面的情形:

  • 在一個計算向一段內存寫數(shù)據(jù)的時候,另一個計算從此內存段讀數(shù)據(jù),結果導致讀出的數(shù)據(jù)的完整性得不到保證。
  • 在一個計算向一段內存寫數(shù)據(jù)的時候,另一個計算也向此段內存寫數(shù)據(jù),結果導致被寫入的數(shù)據(jù)的完整性得不到保證。

這些情形被稱為數(shù)據(jù)競爭(data race)。并發(fā)編程的一大任務就是要調度不同計算,控制它們對資源的訪問時段,以使數(shù)據(jù)競爭的情況不會發(fā)生。 此任務常稱為并發(fā)同步(或者數(shù)據(jù)同步)。Go支持幾種并發(fā)同步技術,這些并發(fā)同步技術將在后面的章節(jié)中逐一介紹。

并發(fā)編程中的其它任務包括:

  • 決定需要開啟多少計算;
  • 決定何時開啟、阻塞、解除阻塞和結束哪些計算;
  • 決定如何在不同的計算中分擔工作負載。

上一節(jié)中這個并發(fā)程序是有缺陷的。我們本期望每個新創(chuàng)建的協(xié)程打印出10條問候語,但是主協(xié)程(和程序)在這20條問候語還未都打印出來的時候就退出了。 如何確保主協(xié)程在這20條問候語都打印完畢之后才退出呢?我們必須使用某種并發(fā)同步技術來達成這一目標。

Go支持幾種并發(fā)同步技術。 其中, 通道是最獨特和最常用的。 但是,為了簡單起見,這里我們將使用?sync?標準庫包中的?WaitGroup?來同步上面這個程序中的主協(xié)程和兩個新創(chuàng)建的協(xié)程。

?WaitGroup?類型有三個方法(特殊的函數(shù),將在以后的文章中詳解):?Add?、?Done?和?Wait?。此類型將在后面的某篇文章中詳細解釋,目前我們可以簡單地認為:

  • ?Add?方法用來注冊新的需要完成的任務數(shù)。
  • ?Done?方法用來通知某個任務已經完成了。
  • 一個?Wait?方法調用將阻塞(等待)到所有任務都已經完成之后才繼續(xù)執(zhí)行其后的語句。

示例:

package main

import (
	"log"
	"math/rand"
	"time"
	"sync"
)

var wg sync.WaitGroup

func SayGreetings(greeting string, times int) {
	for i := 0; i < times; i++ {
		log.Println(greeting)
		d := time.Second * time.Duration(rand.Intn(5)) / 2
		time.Sleep(d)
	}
	wg.Done() // 通知當前任務已經完成。
}

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)
	wg.Add(2) // 注冊兩個新任務。
	go SayGreetings("hi!", 10)
	go SayGreetings("hello!", 10)
	wg.Wait() // 阻塞在這里,直到所有任務都已完成。
}

運行這個修改后的程序,我們將會發(fā)現(xiàn)所有的20條問候語都將在程序退出之前打印出來。

協(xié)程的狀態(tài)

從上面這個的例子,我們可以看到一個活動中的協(xié)程可以處于兩個狀態(tài):運行狀態(tài)阻塞狀態(tài)。一個協(xié)程可以在這兩個狀態(tài)之間切換。 比如上例中的主協(xié)程在調用?wg.Wait?方法的時候,將從運行狀態(tài)切換到阻塞狀態(tài);當兩個新協(xié)程完成各自的任務后,主協(xié)程將從阻塞狀態(tài)切換回運行狀態(tài)。

下面的圖片顯示了一個協(xié)程的生命周期。

協(xié)程狀態(tài)

注意,一個處于睡眠中的(通過調用?time.Sleep?)或者在等待系統(tǒng)調用返回的協(xié)程被認為是處于運行狀態(tài),而不是阻塞狀態(tài)。

當一個新協(xié)程被創(chuàng)建的時候,它將自動進入運行狀態(tài),一個協(xié)程只能從運行狀態(tài)而不能從阻塞狀態(tài)退出。 如果因為某種原因而導致某個協(xié)程一直處于阻塞狀態(tài),則此協(xié)程將永遠不會退出。 除了極個別的應用場景,在編程時我們應該盡量避免出現(xiàn)這樣的情形。

一個處于阻塞狀態(tài)的協(xié)程不會自發(fā)結束阻塞狀態(tài),它必須被另外一個協(xié)程通過某種并發(fā)同步方法來被動地結束阻塞狀態(tài)。 如果一個運行中的程序當前所有的協(xié)程都出于阻塞狀態(tài),則這些協(xié)程將永遠阻塞下去,程序將被視為死鎖了。 當一個程序死鎖后,官方標準編譯器的處理是讓這個程序崩潰。

比如下面這個程序將在運行兩秒鐘后崩潰。

package main

import (
	"sync"
	"time"
)

var wg sync.WaitGroup

func main() {
	wg.Add(1)
	go func() {
		time.Sleep(time.Second * 2)
		wg.Wait() // 阻塞在此
	}()
	wg.Wait() // 阻塞在此
}

它的輸出:

fatal error: all goroutines are asleep - deadlock!

...

以后我們將學習到更多可以讓一個協(xié)程進入到阻塞狀態(tài)的操作。

協(xié)程的調度

并非所有處于運行狀態(tài)的協(xié)程都在執(zhí)行。在任一時刻,只能最多有和邏輯CPU數(shù)目一樣多的協(xié)程在同時執(zhí)行。 我們可以調用runtime.NumCPU函數(shù)來查詢當前程序可利用的邏輯CPU數(shù)目。 每個邏輯CPU在同一時刻只能最多執(zhí)行一個協(xié)程。Go運行時(runtime)必須讓邏輯CPU頻繁地在不同的處于運行狀態(tài)的協(xié)程之間切換,從而每個處于運行狀態(tài)的協(xié)程都有機會得到執(zhí)行。 這和操作系統(tǒng)執(zhí)行系統(tǒng)線程的原理是一樣的。

下面這張圖顯示了一個協(xié)程的更詳細的生命周期。在此圖中,運行狀態(tài)被細分成了多個子狀態(tài)。 一個處于排隊子狀態(tài)的協(xié)程等待著進入執(zhí)行子狀態(tài)。一個處于執(zhí)行子狀態(tài)的協(xié)程在被執(zhí)行一會兒(非常短的時間片)之后將進入排隊子狀態(tài)。

協(xié)程詳細狀態(tài)

請注意,為了解釋的簡單性,在以后其它的《Go語言101》文章中,上圖中所示的子狀態(tài)將不會再提及。 重申一下,睡眠和等待系統(tǒng)調用返回子狀態(tài)被認為是運行狀態(tài),而不是阻塞狀態(tài)。

標準編譯器采納了一種被稱為M-P-G模型的算法來實現(xiàn)協(xié)程調度。 其中,M表示系統(tǒng)線程,P表示邏輯處理器(并非上述的邏輯CPU),G表示協(xié)程。 大多數(shù)的調度工作是通過邏輯處理器(P)來完成的。 邏輯處理器像一個監(jiān)工一樣通過將不同的處于運行狀態(tài)協(xié)程(G)交給不同的系統(tǒng)線程(M)來執(zhí)行。 一個協(xié)程在同一時刻只能在一個系統(tǒng)線程中執(zhí)行。一個執(zhí)行中的協(xié)程運行片刻后將自發(fā)地脫離讓出一個系統(tǒng)線程,從而使得其它處于等待子狀態(tài)的協(xié)程得到執(zhí)行機會。

在運行時刻,我們可以調用runtime.GOMAXPROCS函數(shù)來獲取和設置邏輯處理器的數(shù)量。 對于官方標準編譯器,在Go 1.5之前,默認初始邏輯處理器的數(shù)量為1;自從Go 1.5之后,默認初始邏輯處理器的數(shù)量和邏輯CPU的數(shù)量一致。 此新的默認設置在大多數(shù)情況下是最佳選擇。但是對于某些文件操作十分頻繁的程序,設置一個大于?runtime.NumCPU()?的?GOMAXPROCS?值可能是有好處的。

我們也可以通過設置?GOMAXPROCS?環(huán)境變量來設置一個Go程序的初始邏輯處理器數(shù)量。

延遲函數(shù)調用(deferred function call)

在Go中,一個函數(shù)調用可以跟在一個?defer?關鍵字后面,成為一個延遲函數(shù)調用。 此?defer?關鍵字和此延遲函數(shù)調用一起形成一個延遲調用語句。 和協(xié)程調用類似,被延遲的函數(shù)調用的所有返回值(如果存在)必須全部被舍棄。

當一個延遲調用語句被執(zhí)行時,其中的延遲函數(shù)調用不會立即被執(zhí)行,而是被推入由當前協(xié)程維護的一個延遲調用隊列(一個后進先出隊列)。 當一個函數(shù)調用返回(此時可能尚未完全退出)并進入它的退出階段后,所有在執(zhí)行此函數(shù)調用的過程中已經被推入延遲調用隊列的調用將被按照它們被推入的順序逆序被彈出隊列并執(zhí)行。 當所有這些延遲調用執(zhí)行完畢后,此函數(shù)調用也就完全退出了。

下面這個例子展示了如何使用延遲調用函數(shù)。

package main

import "fmt"

func main() {
	defer fmt.Println("The third line.")
	defer fmt.Println("The second line.")
	fmt.Println("The first line.")
}

輸出結果:

The first line.
The second line.
The third line.

下面是另一個略微復雜一點的使用了延遲調用的例子程序。此程序將按照自然數(shù)的順序打印出0到9十個數(shù)字。

package main

import "fmt"

func main() {
	defer fmt.Println("9")
	fmt.Println("0")
	defer fmt.Println("8")
	fmt.Println("1")
	if false {
		defer fmt.Println("not reachable")
	}
	defer func() {
		defer fmt.Println("7")
		fmt.Println("3")
		defer func() {
			fmt.Println("5")
			fmt.Println("6")
		}()
		fmt.Println("4")
	}()
	fmt.Println("2")
	return
	defer fmt.Println("not reachable")
}

一個延遲調用可以修改包含此延遲調用的最內層函數(shù)的返回值

一個例子:

package main

import "fmt"

func Triple(n int) (r int) {
	defer func() {
		r += n // 修改返回值
	}()

	return n + n // <=> r = n + n; return
}

func main() {
	fmt.Println(Triple(5)) // 15
}

延遲函數(shù)調用的必要性和好處

事實上,上面的幾個使用了延遲函數(shù)調用的例子中的延遲函數(shù)調用并非絕對必要。 但是延遲調用對于下面將要介紹的恐慌/恢復特性是必要的。

另外延遲函數(shù)調用可以幫助我們寫出更整潔和更魯棒的代碼。我們可以在后面的更多關于延遲調用一文中讀到這樣的例子。

協(xié)程和延遲調用的實參的估值時刻

一個延遲調用的實參是在此調用對應的延遲調用語句被執(zhí)行時被估值的。 或者說,它們是在此延遲調用被推入延遲調用隊列時被估值的。 這些被估值的結果將在以后此延遲調用被執(zhí)行的時候使用。

一個匿名函數(shù)體內的表達式是在此函數(shù)被執(zhí)行的時候才會被逐漸估值的,不管此函數(shù)是被普通調用還是延遲/協(xié)程調用。

一個例子:

package main

import "fmt"

func main() {
	func() {
		for i := 0; i < 3; i++ {
			defer fmt.Println("a:", i)
		}
	}()
	fmt.Println()
	func() {
		for i := 0; i < 3; i++ {
			defer func() {
				fmt.Println("b:", i)
			}()
		}
	}()
}

運行之,將得到如下結果:

a: 2
a: 1
a: 0

b: 3
b: 3
b: 3

第一個匿名函數(shù)中的循環(huán)打印出?2?、?1?和?0?這個序列,但是第二個匿名函數(shù)中的循環(huán)打印出三個?3?。 因為第一個循環(huán)中的?i?是在?fmt.Println?函數(shù)調用被推入延遲調用隊列的時候估的值,而第二個循環(huán)中的?i?是在第二個匿名函數(shù)調用的退出階段估的值(此時循環(huán)變量?i?的值已經變?yōu)?3?)。

我們可以對第二個循環(huán)略加修改(使用兩種方法),使得它和第一個循環(huán)打印出相同的結果。

		for i := 0; i < 3; i++ {
			defer func(i int) {
				// 此i為形參i,非實參循環(huán)變量i。
				fmt.Println("b:", i)
			}(i)
		}

或者

		for i := 0; i < 3; i++ {
			i := i // 在下面的調用中,左i遮擋了右i。
			       // <=> var i = i
			defer func() {
				// 此i為上面的左i,非循環(huán)變量i。
				fmt.Println("b:", i)
			}()
		}

同樣的估值時刻規(guī)則也適用于協(xié)程調用。下面這個例子程序將打印出123 789。

package main

import "fmt"
import "time"

func main() {
	var a = 123
	go func(x int) {
		time.Sleep(time.Second)
		fmt.Println(x, a) // 123 789
	}(a)

	a = 789

	time.Sleep(2 * time.Second)
}

順便說一句,使用?time.Sleep?調用來做并發(fā)同步不是一個好的方法。 如果上面這個程序運行在一個滿負荷運行的電腦上,此程序可能在新啟動的協(xié)程可能還未得到執(zhí)行機會的時候就已經退出了。 在正式的項目中,我們應該使用并發(fā)同步技術一文中列出的方法來實現(xiàn)并發(fā)同步。

恐慌(panic)和恢復(recover)

Go不支持異常拋出和捕獲,而是推薦使用返回值顯式返回錯誤。 不過,Go支持一套和異常拋出/捕獲類似的機制。此機制稱為恐慌/恢復(panic/recover)機制。

我們可以調用內置函數(shù)?panic?來產生一個恐慌以使當前協(xié)程進入恐慌狀況。

進入恐慌狀況是另一種使當前函數(shù)調用開始返回的途徑。 一旦一個函數(shù)調用產生一個恐慌,此函數(shù)調用將立即進入它的退出階段。

通過在一個延遲函數(shù)調用之中調用內置函數(shù)?recover?,當前協(xié)程中的一個恐慌可以被消除,從而使得當前協(xié)程重新進入正常狀況。

如果一個協(xié)程在恐慌狀況下退出,它將使整個程序崩潰。

內置函數(shù)panicrecover的聲明原型如下:

func panic(v interface{})
func recover() interface{}

接口(interface)類型和接口值將在以后的文章接口中詳解。 目前,我們可以暫時將空接口類型?interface{}?視為很多其它語言中的?any?或者?Object?類型。 換句話說,在一個?panic?函數(shù)調用中,我們可以傳任何實參值。

一個?recover?函數(shù)的返回值為其所恢復的恐慌在產生時被一個?panic?函數(shù)調用所消費的參數(shù)。

下面這個例子展示了如何產生一個恐慌和如何消除一個恐慌。

package main

import "fmt"

func main() {
	defer func() {
		fmt.Println("正常退出")
	}()
	fmt.Println("嗨!")
	defer func() {
		v := recover()
		fmt.Println("恐慌被恢復了:", v)
	}()
	panic("拜拜!") // 產生一個恐慌
	fmt.Println("執(zhí)行不到這里")
}

它的輸出結果:

嗨!
恐慌被恢復了: 拜拜!
正常退出

下面的例子在一個新協(xié)程里面產生了一個恐慌,并且此協(xié)程在恐慌狀況下退出,所以整個程序崩潰了。

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("hi!")

	go func() {
		time.Sleep(time.Second)
		panic(123)
	}()

	for {
		time.Sleep(time.Second)
	}
}

運行之,輸出如下:

hi!
panic: 123

goroutine 5 [running]:
...

Go運行時(runtime)會在若干情形下產生恐慌,比如一個整數(shù)被0除的時候。下面這個程序將崩潰退出。

package main

func main() {
	a, b := 1, 0
	_ = a/b
}

它的輸出:

panic: runtime error: integer divide by zero

goroutine 1 [running]:
...

一般說來,恐慌用來表示正常情況下不應該發(fā)生的邏輯錯誤。 如果這樣的一個錯誤在運行時刻發(fā)生了,則它肯定是由于某個bug引起的。 另一方面,非邏輯錯誤是現(xiàn)實中難以避免的錯誤,它們不應該導致恐慌。 我們必須正確地對待和處理非邏輯錯誤。

更多可能由Go運行時產生的恐慌將在以后其它文章中提及。

以后,我們可以了解一些恐慌/恢復用例更多關于恐慌/恢復機制的細節(jié)。

一些致命性錯誤不屬于恐慌

對于官方標準編譯器來說,很多致命性錯誤(比如棧溢出和內存不足)不能被恢復。它們一旦產生,程序將崩潰。


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號