如何优雅地关闭 Go Channels(译文) | Go主题月

1,579 阅读9分钟

几天前,我写了一篇文章,阐述了 Go 通道规范。那篇文章在 redditHN 上获得了很多赞同,但在 Go 通道的设计细节上也有一些批评。

我收集了一些关于 Go 通道的设计和规则的批评:

  • 在不修改通道状态的情况下,没有简单通用的方法来检查通道是否关闭。
  • 关闭已关闭的通道会引起宕机,因此如果关闭者不知道通道是否关闭,关闭通道是危险的。
  • 将值发送到一个关闭的通道会导致宕机,因此如果发送者不知道通道是否关闭,则将值发送到通道是危险的。

这些批评看起来是合理的(事实上并非如此)。是的,实际上没有一个内置函数来检查通道是否已关闭。

如果您可以确保没有值被(或将被)发送到通道,那么确实有一种简单的方法来检查通道是否关闭。该方法已在上一篇文章中给出。这里,为了更好的连贯性,下面的例子再次列出了该方法。

package main

import "fmt"

type T int

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

	return false
}

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

如上所述,这不是检查通道是否关闭的通用方法。

事实上,即使有一个简单的内置的 closed 函数来检查通道是否关闭,它的用处也会非常有限,就像内置的 len 函数用于检查存储在通道的值缓冲区中的当前值的数量一样。原因是,在调用此类函数返回后,检查通道的状态可能已更改,因此返回的值已经无法反映刚刚检查的通道的最新状态。虽然如果调用 closed(ch) 返回 true,停止向通道 ch 发送值是可以的,但是如果调用 closed(ch) 返回 false,关闭通道或继续向通道发送值是不安全的。

通道关闭原则

使用 Go 通道的一个通用原则是不要关闭接收端的通道,如果通道有多个并发发送方,则不要关闭通道。换句话说,如果发送方是通道的唯一发送方,我们应该只关闭发送方 goroutine 中的通道。

(下面,我们将上述原则称为通道关闭原则。)

当然,这不是封闭渠道的普遍原则。通用原则是不要关闭(或发送值到)关闭的通道。如果我们能保证不再有 goroutine 关闭并向一个非关闭的非 nil 通道发送值,那么 goroutine 就可以安全地关闭通道。然而,由一个信道的接收者或多个发送者中的一个做出这样的保证通常需要很大的努力,并且常常使代码变得复杂。相反,要掌握上述的 通道关闭原则 要容易得多。

粗暴关闭通道的示例

如果您要在自接收器端关闭通道或者要关闭在多个发送者中关闭一个通道,那么您可以使用恢复机制来防止程序崩溃。下面是一个示例(假设通道元素类型为 T )。

func SafeClose(ch chan T) (justClosed bool) {
	defer func() {
		if recover() != nil {
			// The return result can be altered
			// in a defer function call.
			justClosed = false
		}
	}()

	// assume ch != nil here.
	close(ch)   // panic if ch is closed
	return true // <=> justClosed = true; return
}

这种解决方案显然打破了 通道关闭原则

同样的想法也可以用于向潜在的封闭通道发送值。

func SafeSend(ch chan T, value T) (closed bool) {
	defer func() {
		if recover() != nil {
			closed = true
		}
	}()

	ch <- value  // panic if ch is closed
	return false // <=> closed = false; return
}

粗暴的解决方案不仅打破了 通道关闭原则 ,而且在这个过程中还可能发生数据竞争。

有礼貌的关闭通道的示例

很多人喜欢用 sync.Once 来关闭通道:

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()
	defer mc.mutex.Unlock()
	if !mc.closed {
		close(mc.C)
		mc.closed = true
	}
}

func (mc *MyChannel) IsClosed() bool {
	mc.mutex.Lock()
	defer mc.mutex.Unlock()
	return mc.closed
}

这些方法可能是有礼貌的,但它们可能无法避免数据竞争。目前,Go 规范不能保证在同时执行通道关闭和通道发送操作时不会发生数据争用。如果 SafeClose 函数与对同一通道的通道发送操作同时调用,则可能会发生数据争用(尽管这样的数据争用通常不会造成任何伤害)。

优雅关闭渠道的示例

上述 SafeSend 函数的一个缺点是,它的调用不能用作在 select 块中跟随 case 关键字的 send 操作。

上述 SafeSendSafeClose 函数的另一个缺点是,包括我在内的许多人都会认为使用 panic/recoversync 包不太合适。接下来,将介绍一些不使用 panic/recoversync 包,只用通道解决的方案,适用于各种情况。

(在以下示例中,sync.WaitGroup 用于使示例完整。在实际操作中使用它可能不太重要。)

  1. M 个接收者,1 个发送者,发送者通过关闭数据通道说“不再发送”

这是最简单的情况,只需让发送者在不想发送更多数据时关闭数据通道。

package main

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

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)

	// ...
	const Max = 100000
	const NumReceivers = 100

	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(NumReceivers)

	// ...
	dataCh := make(chan int)

	// the sender
	go func() {
		for {
			if value := rand.Intn(Max); value == 0 {
				// The only sender can close the
				// channel at any time 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 becomes empty.
			for value := range dataCh {
				log.Println(value)
			}
		}()
	}

	wgReceivers.Wait()
}
  1. 1 个接收者,N 个发送者,唯一的接收者通过关闭一个额外的信号通道说“请停止发送更多”

这种情况比上述情况要复杂一点。我们不能让接收者关闭数据通道来停止数据传输,因为这样做会破坏通道关闭原则。但我们可以让接收者关闭一个额外的信号通道,通知发送者停止发送值。

package main

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

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)

	// ...
	const Max = 100000
	const NumSenders = 1000

	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(1)

	// ...
	dataCh := make(chan int)
	stopCh := make(chan struct{})
		// stopCh is an additional signal channel.
		// Its sender is the receiver of channel
		// dataCh, and its receivers are the
		// senders of channel dataCh.

	// senders
	for i := 0; i < NumSenders; i++ {
		go func() {
			for {
				// The try-receive operation is to try
				// to exit the goroutine as early as
				// possible. For this specified example,
				// it is not essential.
				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 for this
				// example, so the first select block
				// above can be omitted.
				select {
				case <- stopCh:
					return
				case dataCh <- rand.Intn(Max):
				}
			}
		}()
	}

	// the receiver
	go func() {
		defer wgReceivers.Done()

		for value := range dataCh {
			if value == Max-1 {
				// The receiver of channel dataCh is
				// also the sender of stopCh. It is
				// safe to close the stop channel here.
				close(stopCh)
				return
			}

			log.Println(value)
		}
	}()

	// ...
	wgReceivers.Wait()
}

如注释中所述,对于附加信号信道,其发送者是数据信道的接收方。附加信号通道由其唯一的发送者关闭,这保持了通道关闭原则。

在本例中,通道 dataCh 从不关闭。是的,通道不必关闭。如果不再有 goroutine 引用一个通道,不管它是否关闭,它最终都会被垃圾回收。所以在这里关闭一个通道的优雅之处不是关闭通道。

  1. M 个接收者,N 个发送者,其中任何一个都说“让我们结束游戏”通知调解者关闭一个额外的信号通道

这是一个最复杂的情况。我们不能让任何接收者和发送者关闭数据通道。我们不能让任何一个接收者关闭额外的信号通道,通知所有发送者和接收者退出游戏。做任何一个都会打破通道关闭的原则。但是,我们可以引入一个调节器角色来关闭附加的信号通道。下面示例中的一个技巧是如何使用 try send 操作通知调解者关闭附加信号通道。

package main

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

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)

	// ...
	const Max = 100000
	const NumReceivers = 10
	const NumSenders = 1000

	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(NumReceivers)

	// ...
	dataCh := make(chan int)
	stopCh := make(chan struct{})
		// stopCh is an additional signal channel.
		// Its sender is the moderator goroutine shown
		// below, and its receivers 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, and its receiver is
		// the moderator goroutine shown below.
		// It must be a buffered channel.

	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(Max)
				if value == 0 {
					// Here, the try-send operation is
					// to notify the moderator to close
					// the additional signal channel.
					select {
					case toStop <- "sender#" + id:
					default:
					}
					return
				}

				// The try-receive operation here is to
				// try to exit the sender goroutine as
				// early as possible. Try-receive and
				// try-send select blocks are specially
				// optimized by the standard Go
				// compiler, so they are very efficient.
				select {
				case <- stopCh:
					return
				default:
				}

				// Even if stopCh is closed, the first
				// branch in this select block might be
				// still not selected for some loops
				// (and for ever in theory) if the send
				// to dataCh is also non-blocking. If
				// this is unacceptable, then the above
				// try-receive operation is essential.
				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
				// try-receive operation here is to
				// try to exit the receiver goroutine
				// as early as possible.
				select {
				case <- stopCh:
					return
				default:
				}

				// Even if stopCh is closed, the first
				// branch in this select block might be
				// still not selected for some loops
				// (and forever in theory) if the receive
				// from dataCh is also non-blocking. If
				// this is not acceptable, then the above
				// try-receive operation is essential.
				select {
				case <- stopCh:
					return
				case value := <-dataCh:
					if value == Max-1 {
						// Here, 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)
}

在这个例子中,通道关闭原则仍然保持不变。

请注意,通道 toStop 的缓冲区大小(容量)为 1。这是为了避免在版主 goroutine 准备好从 toStop 接收通知之前发送的第一个通知丢失。

我们还可以将 toStop 通道的容量设置为发送方和接收方的总数,这样就不需要 try-send select 块来通知调解人。

...
toStop := make(chan string, NumReceivers + NumSenders)
...
			value := rand.Intn(Max)
			if value == 0 {
				toStop <- "sender#" + id
				return
			}
...
				if value == Max-1 {
					toStop <- "receiver#" + id
					return
				}
...
  1. “M 个 接收者,1 个发送者”情形的一个变体:关闭请求由第三方 goroutine 发出

有时,必须由第三方 goroutine 发出关闭信号。对于这种情况,我们可以使用一个额外的信号通道来通知发送方关闭数据通道。例如,

package main

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

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)

	// ...
	const Max = 100000
	const NumReceivers = 100
	const NumThirdParties = 15

	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(NumReceivers)

	// ...
	dataCh := make(chan int)
	closing := make(chan struct{}) // signal channel
	closed := make(chan struct{})
	
	// The stop function can be called
	// multiple times safely.
	stop := func() {
		select {
		case closing<-struct{}{}:
			<-closed
		case <-closed:
		}
	}
	
	// some third-party goroutines
	for i := 0; i < NumThirdParties; i++ {
		go func() {
			r := 1 + rand.Intn(3)
			time.Sleep(time.Duration(r) * time.Second)
			stop()
		}()
	}

	// the sender
	go func() {
		defer func() {
			close(closed)
			close(dataCh)
		}()

		for {
			select{
			case <-closing: return
			default:
			}

			select{
			case <-closing: return
			case dataCh <- rand.Intn(Max):
			}
		}
	}()

	// receivers
	for i := 0; i < NumReceivers; i++ {
		go func() {
			defer wgReceivers.Done()

			for value := range dataCh {
				log.Println(value)
			}
		}()
	}

	wgReceivers.Wait()
}

stop 函数中使用的思想是从 Roger Peppe 的评论中学习到的。

  1. “N 个发送方”情况的一种变体:数据通道必须关闭,以告诉接收方数据发送已结束

在解决上述 N 个发送方的情况时,为了保持信道关闭原则,我们避免关闭数据信道。然而,有时要求数据通道必须在最后关闭,以便让接收器知道数据发送已经结束。对于这种情况,我们可以使用中间信道将 N 个发送方的情况转换为 1 个发送方的情况。中间通道只有 1 个发送方,因此我们可以关闭它而不是关闭原始数据通道。

package main

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

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)

	// ...
	const Max = 1000000
	const NumReceivers = 10
	const NumSenders = 1000
	const NumThirdParties = 15

	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(NumReceivers)

	// ...
	dataCh := make(chan int)     // will be closed
	middleCh := make(chan int)   // will never be closed
	closing := make(chan string) // signal channel
	closed := make(chan struct{})

	var stoppedBy string

	// The stop function can be called
	// multiple times safely.
	stop := func(by string) {
		select {
		case closing <- by:
			<-closed
		case <-closed:
		}
	}
	
	// the middle layer
	go func() {
		exit := func(v int, needSend bool) {
			close(closed)
			if needSend {
				dataCh <- v
			}
			close(dataCh)
		}

		for {
			select {
			case stoppedBy = <-closing:
				exit(0, false)
				return
			case v := <- middleCh:
				select {
				case stoppedBy = <-closing:
					exit(v, true)
					return
				case dataCh <- v:
				}
			}
		}
	}()
	
	// some third-party goroutines
	for i := 0; i < NumThirdParties; i++ {
		go func(id string) {
			r := 1 + rand.Intn(3)
			time.Sleep(time.Duration(r) * time.Second)
			stop("3rd-party#" + id)
		}(strconv.Itoa(i))
	}

	// senders
	for i := 0; i < NumSenders; i++ {
		go func(id string) {
			for {
				value := rand.Intn(Max)
				if value == 0 {
					stop("sender#" + id)
					return
				}

				select {
				case <- closed:
					return
				default:
				}

				select {
				case <- closed:
					return
				case middleCh <- value:
				}
			}
		}(strconv.Itoa(i))
	}

	// receivers
	for range [NumReceivers]struct{}{} {
		go func() {
			defer wgReceivers.Done()

			for value := range dataCh {
				log.Println(value)
			}
		}()
	}

	// ...
	wgReceivers.Wait()
	log.Println("stopped by", stoppedBy)
}

更多情况?

应该有更多的情况变量,但上面显示的是最常见和最基本的。通过巧妙地使用通道(和其他并发编程技术),我相信对于每种情况变量都可以找到一个保持通道关闭原则的解决方案。

结论

没有任何情况会迫使你打破通道关闭原则。如果你遇到这样的情况,请重新考虑你的设计和重写你的代码。

用 Go channels 编程就像制作艺术。

原文链接:go101.org/article/cha…