几天前,我写了一篇解释go通道原则。那篇文章在redditHN上得到了很多支持。但是go通道设计细节得到了许多批评。

我总结批评内容如下:

  • 不修改channel状态的情况下,没有统一和简单的方式去判断channel是否关闭。
  • 关闭已经关闭的channel会panic,所以关闭channel是非常危险的如果不知道channel是否关闭。
  • 向关闭的channel发送值会panic,所以发送值到channel是非常危险的如果不知道channel是否关闭。

这些评论看起来是合理的(实际上不然),是的,并没有内置的函数去判断channel是否关闭。

如果你能确定不再(将来)往channel发送值,确实有简单的方法去判断channel是否关闭,为了方便理解,请看下面的列子:

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
}

就像上面提到的,没有统一的方式去检查channel是否关闭。

实际上,即使有简单的内置方法closed 去判断channel是否关闭,就像内置函数len 判断channel 元素个数,价值有限。原因是被检查的channel可能会在函数调用并返回后状态已经改变,所以返回的值并不能反映最新的channel状态。不过如果调用closed(ch)返回true可以停止向channel发送值,但是如果调用closed(ch),返回false,则关闭通道或继续向通道发送值是不安全的。

channel的关闭原则

最基本的原则是不要要接受者端关闭channel,也不要在有多个并发发送者的情况下关闭channel。换句话说,我们只能在发送端关闭channel,并且是唯一的发送者。

(下面我们将称上面的原则为关闭原则)

当然,并没有统一的原则去关闭channel。统一的原则便是不要关闭(或者发送值)已经关闭的channel。如果我们能保证没有协程发送或者关闭一个没有关闭且不为nil的channel,那我们可以安全关闭这个channel。然而,对于接受者或或者多个发送者中作出这样的保证往往需要很多努力,而且经常使代码更为复杂。相反,遵从channel关闭原则相对比较简单。

粗暴的关闭channel的解决方案

如果你无论如也想从接收端或者多个发送者中一个关闭channel,你可以使用错误恢复机制阻止恐慌的可能来避免程序宕机。下面是一个示例(假设channel 的类型是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
}

这种解决方案,显然违反了关闭原则。

同样的问题是向潜在的已关闭的channel发送值。

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
}

不仅打破了关闭原则,在运行过程中可能发生数据竞争。

礼貌的关闭channel

许多人喜欢用async.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)
    })
}

当然,你也可以用async.Mutex 避免关闭channel多次。

ype 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运行机制并不能保证关闭channel和向channel发送值同时执行不会发生数据竞争。如果对同一channel执行通道发送操作的同时调用SafeClose函数,则可能会发生数据竞争(尽管这种数据竞争一般无害的)。

优雅的关闭channel

上述SafeSend函数的缺点是,在select块中case关键字分枝上不能调用作为发送操作;另一个缺点是很多人包括我认为在上面的SafeSend和SafeClose函数中使用panic/recover` 和async 包是不优雅的。接下来,针对各种解情况介绍一些不使用包的纯channel解决方案。

(在下面的示例中,sync.WaitGroup 完全用于示例,实际中可能并不使用)

多个接收者,一个发送者,发送者关闭channel表示‘没有值可以发送’

这是一个非常简单的情况,仅仅是让发送者在不想发送数据时候关闭channel。

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()
}
一个接收者,多个发送者,唯一的接收者通过关闭额外的channel通道表示‘请停止发送值到channel’

这是一个比上面较复杂的情况。我们不能为阻止数据传输让接收者关闭数据channel,这样违反了channel关闭的原则,但是我们可以通过关闭额外的信号channel去通知发送者停止发送值。

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()
}

正如注释中说的,信号channel的发送者是数据接收者channel。信号channel遵从channel关闭原则,只能被他的发送者关闭。

在上面的示例中,dataCh不会被关闭。是的,Channel不是必须被关闭的。一个channel无论是否关闭,当没有一个协程引用的时候,最终就会被GC回收。所以这里优雅的关闭channel就是不关闭channel。

M个接收者,N个发送者,任何一个通过中间人关闭信号channel表示‘让我们结束游戏吧’

这是最复杂的情况。我们不能让任何一个发送者和接收者关闭数据channel。我们也不能让任何一个接收者关闭信号channel通知所有的接收者和发送者结束游戏。其中任何一种方式都打破了关闭原则。然而,我们可以引入一个中间角色去关闭信号channel。在下面例子中有一个技巧是如何使用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)
}

在这个例子中,依然守住了channel关闭的原则。

请注意,toStop的缓冲大小是1。这是为了避免当中间人准备从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
                }
...
"多个接收者,单个发送者"变体的情形:关闭请求是通过第三方

有时候,关闭信号是由第三方发出。在这种情况下,我们可以用额外的信号去通知发送者关闭channel。例如:

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()
}
'多个发送者'情况的变体:关闭channel必须告诉所有的接收者已经不再发送数据

在上面N发送者的情况,为了坚守channel关闭原则,我们避免关闭channel。然而,有时候,我们必须关闭channel来告诉所有接收者不再发送数据。在这种情况下,我们可以通过引入中间channel,将N-sender情形转化为One-sender情形。中间channel只有一个发送者,所以我们可以通过关闭这个channel来代替关闭原始数据channel。

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)
}
更多情形?

应该还会有更多上面情形的变体,但是上面展示了最普通和最常用的情况。通过巧妙地使用channel(和其他并发编程技术),对于每种情况变化,都可以找到一个保持通道关闭原则的解决方案。

结论

没有情形逼迫你打破channel关闭的原则,如果你遇到这种情况,请重新思考你的设计和重构你的代码。

阅读原文


道之
10 声望1 粉丝