goroutine的退出機制
大家都知道goroutine是Go語言併發的利器,通過goroutine我們可以很容易的編寫高併發的程序。但是goroutine設計的退出機制是由goroutine自己退出,不能在外部強制結束一個正在執行的goroutine(只有一種情況正在運行的goroutine會因爲其他goroutine的結束被終止,就是main函數退出或程序停止執行)。關於goroutine爲什麼要設計成這樣的退出機制,改天再po兩篇譯文上來(別人已經寫得很清楚了,我想我應該不需要做額外的總結了)。
但是最近遇到一個坑,就是我有很多可併發的一次性事務。對每一個事務我都起一個goroutine來執行。正常情況下事務執行完畢, goroutine就退出。沒有循環,沒有複雜的邏輯控制,順序執行就完事兒了。但是坑就坑在順序執行上了,如果順序執行過程中因爲某個原因block了,比如讀IO,獲取連接,或者就是一個很耗時的計算,我想爲這個goroutine設置一個超時退出,或者異常退出,卻因爲goroutine的這種機制我沒法爲這種類型的goroutine根據超時、異常執行強制退出的操作。
所以乘機整理了一下幾種能夠讓一個goroutine退出的機制。
main 退出
這個沒有什麼好具體說的,main是Go程序的主入口,main函數退出基本意味着你代碼執行的結束。進程都退出了,所有它佔有的資源都會還給操作系統,所以還結束的goroutines也沒什麼好玩兒的了。
通過channel通知退出
這個最主要的goroutine退出方式。goroutine雖然不能強制結束另外一個goroutine,但是它是它可以通過channel通知另外一個goroutine你的表演該結束了。常用的方法到處都可以看到,這裏也不詳細說明了,直接上一個示例:
下面的示例中起了一個goroutine執行cancelByChannel
,但是在起它之前還通過time.After
返回了一個time.Time
類型的channel,該channel上在定時超時時會發送一個當前時間數據。`cancelByChannel每隔1s會檢查這個channel上是否有數據接收,如果有數據則退出goroutine,如果沒有信號接收就在連接上發送一條數據。所以下面這段代碼在運行10s發送10條消息後將退出。
程序起起來後,另開一個終端執行nc localhost:8000
(Linux上)或nc localhost 8000
(mac 上)可以看到程序執行情況。
package main
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
)
func cancelByChannel(c net.Conn, quit <-chan time.Time, wg *sync.WaitGroup) {
defer c.Close()
defer wg.Done()
for {
select {
case <-quit:
fmt.Println("cancel goroutine by channel!")
return
default:
_, err := io.WriteString(c, "hello cancelByChannel")
if err != nil {
return
}
time.Sleep(1 * time.Second)
}
}
}
func main() {
listener, err := net.Listen("tcp", "localhost:8000")
if err != nil {
fmt.Println(err)
return
}
conn, err := listener.Accept()
if err != nil {
fmt.Println(err)
return
}
wg := sync.WaitGroup{}
wg.Add(1)
quit := time.After(time.Second * 10)
go cancelByChannel(conn, quit, &wg)
wg.Wait()
}
通過context通知goroutine退出
通過channel通知goroutine退出還有一個更好的方法就是使用context。關於Context的詳細信息可以參考前面的文章Go併發模式: Context。它本質還是接收一個channel數據,只是是通過ctx.Done()獲取。將上面的示例稍作修改就可以用起來了。
func cancelByContext(ctx context.Context, c net.Conn, wg *sync.WaitGroup) {
defer c.Close()
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Println("cancel goroutine by context:", ctx.Err())
return
default:
_, err := io.WriteString(c, "hello cancelByContext")
if err != nil {
return
}
time.Sleep(1 * time.Second)
}
}
}
main函數中將這兩行代碼:
quit := time.After(time.Second * 10)
go cancelByChannel(conn, quit, &wg)
使用下面幾行替換:
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
go cancelByContext(ctx, conn, &wg)
panic 退出
這種方法有點走邪門歪道了,這個需要依賴一些標準庫和第三方庫的設計機制,沒有處理好可能會出現一些你完全意料不到的結果,慎用。這種方法涉及到的知識點包括,defer, panic, recover
,詳細可參見前面的譯文defer, panic, recover。
這種方式能解決部分我在本文開頭提到的問題。比如如果因爲某次IO block了(比如一次數據庫inert事務)等。這一類事務比如數據庫操作,文件操作的步驟通常是:建立連接(或打開文件),然後執行讀寫操作,讀寫完成後關閉連接(或文件)。假如因爲某種原因我們在讀寫操作那一步block了。我們又不希望這個goroutine一直block在那兒,或者我們需要這個goroutine在指定時間內完成。這時我們期望goroutine自己結束並退出可能就有點不現實了。
這個時候加入我們關閉文件或鏈接會怎麼樣?我也不知道,這樣看你網絡操作,文件操作所使用的標準庫或者第三方庫的實現了。但是,通常情況下鏈接或文件關閉後,你的讀寫操作要麼會立即拋出一個panic,要麼就是立即返回一個錯誤了。注意,這裏說的是通常情況,不是所有情況,還有具體是拋出panic還是error,這些都需要根據你自己的實際情況具體分析。
所以這裏主要針對的是panic和error的退出方式,看下面的模擬示例。(作者遇到的坑是block在一次mongo的寫操作上,由於問題不太好表現,這裏沒有使用該示例,而是基於前面的示例做了一些修改。)這裏由於斷開net.Conn的連接,通過io.Writer往連接上只是返回了一個error,並不能成功模擬recover panic的方式。所以示例只能作爲這種實現方式的參考,另外也說明了這種方式的不確定性。* 所以再強調一遍,一定要慎用。*
func cancelByPanic(c net.Conn, wg *sync.WaitGroup) {
defer func() {
if err := recover(); err != nil {
fmt.Println("cancel goroutine by context:", err)
}
}()
defer wg.Done()
for {
_, err := io.WriteString(c, "hello cancelByPanic")
if err != nil {
fmt.Println(err)
return
}
time.Sleep(1 * time.Second)
}
}
上面函數中defer函數中使用了recover來捕獲panic error並從panic中拿回控制權,確保程序不會再panic展開到goroutine調用棧頂部後崩潰。
main函數也要做相應的更改,還需要起一個額外的goroutine來根據相應的退出機制關閉連接。示例中設置的是超時。超時後連接關閉,io.WriteString()
將返回一個錯誤,然後退出goroutine.
go func(ctx context.Context, conn net.Conn, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Println("---close the connection outside!")
conn.Close()
return
default:
}
}
}(ctx, conn, &wg)
wg.Add(1)
go cancelByPanic(conn, &wg)
等它自己退出:)
最後,還有一種情況也可能是大家經常遇到的,就是本文開頭提到的你的goroutine可能只是執行一個計算,但是這個計算執行的時間有點長。對於這種方式,貌似如果你不打算改你的設計換一種方式執行程序的話,就只有等它自己結束了。
下面也是一個示例,這個示例只是根據一個初始值計算進行累減數求和。本例中使用簡單的遞歸求和的方式,隨着初始值的變大,計算過程會越來越慢。
func slowCal(fac int) int {
if fac < 2 {
return fac
}
return slowCal(fac-1) + slowCal(fac-2)
}
func cancelByWait(wg *sync.WaitGroup) {
defer wg.Done()
start := time.Now()
result := slowCal(50)
dur := time.Since(start)
fmt.Println("slow goroutine done:", result, dur)
}
main 函數中直接執行go cancelByWait
即可。
這種方式的改進
這個示例還有很大的改進空間,這裏也不深入展開了。只簡單的提兩點,讀者可以自己下去嘗試下。當然這個例子也很簡單,也不用花時間去寫代碼,想想應該就可以了:)。
可以通過優化算法,以及修改併發方式提高計算速度。
這個示例也是可以引入context或channel來通知計算超時退出的,如果你不想要計算結果的話。
總結
由於Goroutine被設計爲只能自己退出,而不能強制退出。在實際使用中,我們可能會因爲某些原因被block在Goroutines裏面,或由於設計缺陷導致一些Goroutines執行很長的時間。只是基於一些其他語言的經驗,我們可能會期望有一種外部機制能夠強制結束一個Goroutines。但是這就是Go和Goroutine,它的目的就是要提供一種輕量的,簡單的併發方式。保證它這個特性的基礎也決定了我們不能用外部方式強制關閉一個Goroutines(額外post譯文或博文說明這個問題,此文不深入展開)。所以當你遇到這種情況的時候,你可能需要考慮你的設計是不是足夠的Go style,或者你對一些外部依賴是否足夠了解了。