goroutine退出方式的總結

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即可。

這種方式的改進

這個示例還有很大的改進空間,這裏也不深入展開了。只簡單的提兩點,讀者可以自己下去嘗試下。當然這個例子也很簡單,也不用花時間去寫代碼,想想應該就可以了:)。

  1. 可以通過優化算法,以及修改併發方式提高計算速度。

  2. 這個示例也是可以引入context或channel來通知計算超時退出的,如果你不想要計算結果的話。

總結

由於Goroutine被設計爲只能自己退出,而不能強制退出。在實際使用中,我們可能會因爲某些原因被block在Goroutines裏面,或由於設計缺陷導致一些Goroutines執行很長的時間。只是基於一些其他語言的經驗,我們可能會期望有一種外部機制能夠強制結束一個Goroutines。但是這就是Go和Goroutine,它的目的就是要提供一種輕量的,簡單的併發方式。保證它這個特性的基礎也決定了我們不能用外部方式強制關閉一個Goroutines(額外post譯文或博文說明這個問題,此文不深入展開)。所以當你遇到這種情況的時候,你可能需要考慮你的設計是不是足夠的Go style,或者你對一些外部依賴是否足夠了解了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章