如何優雅的關閉Go Channel「譯」

老錢發表於2018-04-08

如何優雅的關閉Go Channel「譯」

Channel關閉原則

不要在消費端關閉channel,不要在有多個並行的生產者時對channel執行關閉操作。

也就是說應該只在[唯一的或者最後唯一剩下]的生產者協程中關閉channel,來通知消費者已經沒有值可以繼續讀了。只要堅持這個原則,就可以確保向一個已經關閉的channel傳送資料的情況不可能發生。

暴力關閉channel的正確方法

如果想要在消費端關閉channel,或者在多個生產者端關閉channel,可以使用recover機制來上個保險,避免程式因為panic而崩潰。

func SafeClose(ch chan T) (justClosed bool) {
	defer func() {
		if recover() != nil {
			justClosed = false
		}
	}()
	
	// assume ch != nil here.
	close(ch) // panic if ch is closed
	return true // <=> justClosed = true; return
}
複製程式碼

使用這種方法明顯違背了上面的channel關閉原則,然後效能還可以,畢竟在每個協程只會呼叫一次SafeClose,效能損失很小。

同樣也可以在生產訊息的時候使用recover方法。

func SafeSend(ch chan T, value T) (closed bool) {
	defer func() {
		if recover() != nil {
			// The return result can be altered 
			// in a defer function call.
			closed = true
		}
	}()
	
	ch <- value // panic if ch is closed
	return false // <=> closed = false; return
}
複製程式碼

禮貌地關閉channel方法

還有不少人經常使用用sync.Once來關閉channel,這樣可以確保只會關閉一次

type MyChannel struct {
	C    chan T
	once sync.Once
}

func NewMyChannel() *MyChannel {
	return &MyChannel{C: make(chan T)}
}

func (mc *MyChannel) SafeClose() {
	mc.once.Do(func() {
		close(mc.C)
	})
}
複製程式碼

同樣我們也可以使用sync.Mutex達到同樣的目的。

type MyChannel struct {
	C      chan T
	closed bool
	mutex  sync.Mutex
}

func NewMyChannel() *MyChannel {
	return &MyChannel{C: make(chan T)}
}

func (mc *MyChannel) SafeClose() {
	mc.mutex.Lock()
	if !mc.closed {
		close(mc.C)
		mc.closed = true
	}
	mc.mutex.Unlock()
}

func (mc *MyChannel) IsClosed() bool {
	mc.mutex.Lock()
	defer mc.mutex.Unlock()
	return mc.closed
}
複製程式碼

要知道golang的設計者不提供SafeClose或者SafeSend方法是有原因的,他們本來就不推薦在消費端或者在併發的多個生產端關閉channel,比如關閉只讀channel在語法上就徹底被禁止使用了。

優雅地關閉channel的方法

上文的SafeSend方法一個很大的劣勢在於它不能用在select塊的case語句中。而另一個很重要的劣勢在於像我這樣對程式碼有潔癖的人來說,使用panic/recover和sync/mutex來搞定不是那麼的優雅。下面我們引入在不同的場景下可以使用的純粹的優雅的解決方法。

多個消費者,單個生產者。這種情況最簡單,直接讓生產者關閉channel好了。

package main

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

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)
	
	// ...
	const MaxRandomNumber = 100000
	const NumReceivers = 100
	
	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(NumReceivers)
	
	// ...
	dataCh := make(chan int, 100)
	
	// the sender
	go func() {
		for {
			if value := rand.Intn(MaxRandomNumber); value == 0 {
				// The only sender can close the channel safely.
				close(dataCh)
				return
			} else {			
				dataCh <- value
			}
		}
	}()
	
	// receivers
	for i := 0; i < NumReceivers; i++ {
		go func() {
			defer wgReceivers.Done()
			
			// Receive values until dataCh is closed and
			// the value buffer queue of dataCh is empty.
			for value := range dataCh {
				log.Println(value)
			}
		}()
	}
	
	wgReceivers.Wait()
}
複製程式碼

多個生產者,單個消費者。這種情況要比上面的複雜一點。我們不能在消費端關閉channel,因為這違背了channel關閉原則。但是我們可以讓消費端關閉一個附加的訊號來通知傳送端停止生產資料。

package main

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

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)
	
	// ...
	const MaxRandomNumber = 100000
	const NumSenders = 1000
	
	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(1)
	
	// ...
	dataCh := make(chan int, 100)
	stopCh := make(chan struct{})
	// stopCh is an additional signal channel.
	// Its sender is the receiver of channel dataCh.
	// Its reveivers are the senders of channel dataCh.
	
	// senders
	for i := 0; i < NumSenders; i++ {
		go func() {
			for {
				// The first select here is to try to exit the goroutine
				// as early as possible. In fact, it is not essential
				// for this example, so it can be omitted.
				select {
				case <- stopCh:
					return
				default:
				}
				
				// Even if stopCh is closed, the first branch in the
				// second select may be still not selected for some
				// loops if the send to dataCh is also unblocked.
				// But this is acceptable, so the first select
				// can be omitted.
				select {
				case <- stopCh:
					return
				case dataCh <- rand.Intn(MaxRandomNumber):
				}
			}
		}()
	}
	
	// the receiver
	go func() {
		defer wgReceivers.Done()
		
		for value := range dataCh {
			if value == MaxRandomNumber-1 {
				// The receiver of the dataCh channel is
				// also the sender of the stopCh cahnnel.
				// It is safe to close the stop channel here.
				close(stopCh)
				return
			}
			
			log.Println(value)
		}
	}()
	
	// ...
	wgReceivers.Wait()
}
複製程式碼

就上面這個例子,生產者同時也是退出訊號channel的接受者,退出訊號channel仍然是由它的生產端關閉的,所以這仍然沒有違背channel關閉原則。值得注意的是,這個例子中生產端和接受端都沒有關閉訊息資料的channel,channel在沒有任何goroutine引用的時候會自行關閉,而不需要顯示進行關閉。

多個生產者,多個消費者

這是最複雜的一種情況,我們既不能讓接受端也不能讓傳送端關閉channel。我們甚至都不能讓接受者關閉一個退出訊號來通知生產者停止生產。因為我們不能違反channel關閉原則。但是我們可以引入一個額外的協調者來關閉附加的退出訊號channel。

package main

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

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)
	
	// ...
	const MaxRandomNumber = 100000
	const NumReceivers = 10
	const NumSenders = 1000
	
	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(NumReceivers)
	
	// ...
	dataCh := make(chan int, 100)
	stopCh := make(chan struct{})
		// stopCh is an additional signal channel.
		// Its sender is the moderator goroutine shown below.
		// Its reveivers are all senders and receivers of dataCh.
	toStop := make(chan string, 1)
		// The channel toStop is used to notify the moderator
		// to close the additional signal channel (stopCh).
		// Its senders are any senders and receivers of dataCh.
		// Its reveiver is the moderator goroutine shown below.
	
	var stoppedBy string
	
	// moderator
	go func() {
		stoppedBy = <- toStop
		close(stopCh)
	}()
	
	// senders
	for i := 0; i < NumSenders; i++ {
		go func(id string) {
			for {
				value := rand.Intn(MaxRandomNumber)
				if value == 0 {
					// Here, a trick is used to notify the moderator
					// to close the additional signal channel.
					select {
					case toStop <- "sender#" + id:
					default:
					}
					return
				}
				
				// The first select here is to try to exit the goroutine
				// as early as possible. This select blocks with one
				// receive operation case and one default branches will
				// be optimized as a try-receive operation by the
				// official Go compiler.
				select {
				case <- stopCh:
					return
				default:
				}
				
				// Even if stopCh is closed, the first branch in the
				// second select may be still not selected for some
				// loops (and for ever in theory) if the send to
				// dataCh is also unblocked.
				// This is why the first select block is needed.
				select {
				case <- stopCh:
					return
				case dataCh <- value:
				}
			}
		}(strconv.Itoa(i))
	}
	
	// receivers
	for i := 0; i < NumReceivers; i++ {
		go func(id string) {
			defer wgReceivers.Done()
			
			for {
				// Same as the sender goroutine, the first select here
				// is to try to exit the goroutine as early as possible.
				select {
				case <- stopCh:
					return
				default:
				}
				
				// Even if stopCh is closed, the first branch in the
				// second select may be still not selected for some
				// loops (and for ever in theory) if the receive from
				// dataCh is also unblocked.
				// This is why the first select block is needed.
				select {
				case <- stopCh:
					return
				case value := <-dataCh:
					if value == MaxRandomNumber-1 {
						// The same trick is used to notify
						// the moderator to close the
						// additional signal channel.
						select {
						case toStop <- "receiver#" + id:
						default:
						}
						return
					}
					
					log.Println(value)
				}
			}
		}(strconv.Itoa(i))
	}
	
	// ...
	wgReceivers.Wait()
	log.Println("stopped by", stoppedBy)
}
複製程式碼

以上三種場景不能涵蓋全部,但是它們是最常見最通用的三種場景,基本上所有的場景都可以劃分為以上三類。

結論

沒有任何場景值得你去打破channel關閉原則,如果你遇到這樣的一種特殊場景,還是建議你好好思考一下自己設計,是不是該重構一下了。

閱讀相關文章,關注公眾號「碼洞」

相關文章