如何優雅的關閉channel?

一、channel使用存在的不方便地方

1、在不改變channel自身狀態的情況下,無法獲知一個channnel是否關閉。

2、關閉一個已經關閉的channel,會導致panic。因此,如果關閉channel的一方在不知道channel是否關閉狀態時就去貿然關閉channel時件很危險的事。

3、向一個已經關閉的channel發送數據會導致panic。因此,如果向channel發送數據的一方不知道channel是否處於關閉狀態就貿然向channel發送數據是很危險的事情。

一個比較粗糙的檢查channel是否關閉的函數:

func IsClosed(ch <-chan interface{}) bool {
    select {
    case <-ch:
        return true
    default:
    }
    return false
}

func main() {
    c := make(chan interface{})
    fmt.Println(IsClosed(c))  // false
    close(c)
    fmt.Println(IsClosed(c)) // true
}

上面的代碼其實存在很多問題。

首先,IsClosed函數是一個有副作用的函數。每調用一次,都會讀出channel裏面的一個元素,改變了channel的狀態,這不是一個好函數,幹活就幹活,還順手牽羊?

其次,IsClosed函數返回的結果僅代表調用時候的那個瞬間,並不能保證調用之後不會有其他goroutine對這個channel進行了一些操作,改變了這個channel的狀態。比如:IsClosed函數返回true,但這時有另外一個goroutine關閉了這個channel,這時候我們就會拿着這個過時的"channel未關閉"信息,向其發送數據,就會導致panic的發生。

當然,一個channel不會被重複關閉兩次,如果IsClosed函數返回的結果是true,說明channel是真的關閉了。

有一句廣泛流傳的關閉channel的原則:

不要從一個 receiver 側關閉 channel,也不要在有多個 sender 時,關閉 channel。

向channel發送元素的就是sender,因此sender可以決定何時不發送數據,並且關閉channel。但是如果有多個sender,某個sender同樣沒法確定其他sender的情況。這時候也不能貿然的關閉channel。

有兩個不優雅關閉channel的方法:

1、使用defer-recover機制,放心大膽地關閉channel或者向channel發送數據,即使發生了panic,有defer-recover在兜底。

2、使用sync.Once來保證只關閉一次。

二、如何優雅關閉channel

根據sender和receiver的個數,可以分爲以下幾種情況:

1、一個sender,一個receiver

2、一個sender,M個receiver

3、N個sender,一個receiver

4、N個sender,M個receiver

對於1,2這兩種情況,只有一個sender,直接從sender段關閉就好。

第3種情形下,優雅關閉channel的方法是:唯一的receiver發出一個關閉channel的信號,senders監聽到關閉信號後,停止發送數據。

func main() {
    rand.Seed(time.Now().UnixNano())

    const Max = 10000
    const NumSenders = 1000

    dataChan := make(chan int, 100)
    stopChan := make(chan struct{})

    // senders
    for i := 0; i < NumSenders; i++ {
        go func() {
            for {
                select {
                // 監聽關閉channel的信號,退出
                case <-stopChan:
                    return
                // 給dataChan中發送數據
                case dataChan <- rand.Intn(Max):
                }
            }
        }()
    }

    // receiver
    go func() {
        // 從dataChan中遍歷數據
        for value := range dataChan {
            // 如果value的值爲9999,通知sender停止發送數據
            if value == Max-1 {
                fmt.Println("send stop signal to senders.")
                close(stopChan)
                return
            }
            fmt.Println(value)
        }
    }()

    select {
    // 阻塞一小時退出
    case <-time.After(time.Hour):
    }
}

這裏的stopCh就是信號channel,它本身只有一個sender,因此可以直接關閉它。senders收到了關閉信號後,select分支case<-stopCh被選中,退出函數,不再發送數據。

需要說明的是,上面的代碼並沒有明確關閉dataCh。在Go語言中,對於一個channel,如果最終沒有任何goroutine引用它,不管channel有沒有被關閉,最終都會被GC回收。所以在這種情形下,所謂的優雅地關閉channel就是不關閉channel,讓GC代勞。

最後一種情況,優雅關閉channel的方法是:any one of them says “let’s end the game” by notifying a moderator to close an additional signal channel。

和第三種情況不同,這裏有M個receiver,如果,採用第3種解決方案,由receiver直接關閉stopCh的話,就會重複關閉一個channel,導致panic。因此需要增加一箇中間人,M個receiver都向它發送關閉dataCh的"請求",中間人收到第一個請求後,就會直接下達關閉dataCh的指令(通過關閉stopCh,這時就不會發生重複關閉的情況,因爲stopCh的發送方只有中間人一個)。另外這裏N個sender也可以向中間人發送關閉dataCh的請求。

func main() {
    rand.Seed(time.Now().UnixNano())

    const Max = 10000
    const NumReceivers = 10
    const NumSenders = 1000

    dataCh := make(chan int, 100)
    stopCh := make(chan struct{})

    // 這裏必須是一個有緩衝的channel
    toStop := make(chan string, 1)

    var stoppedBy string

    // 中間人
    go func() {
        // 接收到了關閉channel的請求
        stoppedBy = <-toStop
        // 發送關閉dataCh的信號
        close(stopCh)
    }()

    // senders
    for i := 0; i < NumSenders; i++ {
        go func(id string) {
            for {
                value := rand.Intn(Max)
                // 如果value=0,則給中間人發送關閉channel的信號
                if value == 0 {
                    select {
                    case toStop <- "sender#" + id:
                    // 此處是爲了防止toStop這個channel阻塞
                    default:
                    }
                    return
                }

                select {
                // 監聽關閉channel的信號
                case <-stopCh:
                    return
                case dataCh <- value:
                }
            }
        }(strconv.Itoa(i))
    }

    // receivers
    for i := 0; i < NumReceivers; i++ {
        go func(id string) {
            for {
                select {
                // 監聽關閉信號,退出,停止接收數據
                case <-stopCh:
                    return
                // 接收數據
                case value := <-dataCh:
                    // 如果接收到了9999,則給中間人發送關閉channel的信號
                    if value == Max-1 {
                        select {
                        case toStop <- "receiver#" + id:
                        default:
                        }
                        return
                    }
                    fmt.Println(value)
                }
            }
        }(strconv.Itoa(i))
    }

    select {
    case <-time.After(time.Hour):
    }

}

代碼裏toStop就是中間人的角色,使用它來接收senders和receivers發送過來的關閉dataCh請求。

這裏將toStop聲明成了一個緩衝型的channel。假設toStop聲明的是一個非緩衝型的channel,那麼第一個發送的關閉dataCh請求可能會丟失。因爲無論是sender還是receiver都是通過select語句來發送請求,如果中間人所在的goroutine沒有準備好,那麼select語句就不會被選中,直接走default選項了,什麼都不做。這樣,第一個關閉dataCh的請求就會丟失。

如果把toStop的容量聲明成Num(senders) + Num(receivers),那麼發送dataCh請求的部分可以改寫成更簡潔的形式:

func main() {
	rand.Seed(time.Now().UnixNano())

	const Max = 10000
	const NumReceivers = 10
	const NumSenders = 1000

	dataCh := make(chan int, 100)
	stopCh := make(chan struct{})

	// 這裏必須是一個有緩衝的channel
	toStop := make(chan string, NumSenders+NumReceivers)

	var stoppedBy string

	// 中間人
	go func() {
		// 接收到了關閉channel的請求
		stoppedBy = <-toStop
		// 發送關閉dataCh的信號
		close(stopCh)
	}()

	// senders
	for i := 0; i < NumSenders; i++ {
		go func(id string) {
			for {
				value := rand.Intn(Max)
				// 如果value=0,則給中間人發送關閉channel的信號
				if value == 0 {
					toStop <- "sender#" + id
					return
				}

				select {
				// 監聽關閉channel的信號
				case <-stopCh:
					return
				case dataCh <- value:
				}
			}
		}(strconv.Itoa(i))
	}

	// receivers
	for i := 0; i < NumReceivers; i++ {
		go func(id string) {
			for {
				select {
				// 監聽關閉信號,退出,停止接收數據
				case <-stopCh:
					return
				// 接收數據
				case value := <-dataCh:
					// 如果接收到了9999,則給中間人發送關閉channel的信號
					if value == Max-1 {
						toStop <- "receiver#" + id
						return
					}
					fmt.Println(value)
				}
			}
		}(strconv.Itoa(i))
	}

	select {
	case <-time.After(time.Hour):
	}
}

直接向toStop發送請求,因爲toStop容量足夠大,所以不用擔心阻塞,自然也就不用select語句再加一個default case來避免阻塞。

可以看到,這裏同樣沒有真正關閉dataCh,原樣通第3種情況。

參考鏈接:

https://golang.design/go-questions/channel/graceful-close/

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