通道是将goroutine的粘合剂,select语句是通道的粘合剂。后者让我们能够在项目中组合通道以形成更大的抽象来解决实际中遇到的问题。凸显select语句在Go并发上的地位绝对不是言过其实。你可以在单个函数或类型定义中找到将本地通道绑定在一起的select语句,也可以在全局范围找到连接系统级别两个或多个组件的使用范例。除了连接组件外,在程序中的关键部分,select语句还可以帮助你安全地将通道与业务层面的概念(如取消,超时,等待和默认值)结合在一起。

既然select语句在Go中占有如此重要的地位——专门用于处理通道,那么你认为程序的组件应该如何协调? 我们将在第五章专门研究这个问题(提示:更喜欢使用频道)。

那么这些强大的select语句是什么? 我们如何使用它们,它们是如何工作的? 我们先从一个简单的示例开始:

var c1, c2 <-chan interface{}
var c3 chan<- interface{}
select {
case <-c1:
    // Do something
case <-c2:
    // Do something
case c3 <- struct{}{}:
    // Do something
}

看着跟switch有点像,是吧。跟switch相同的是,select代码块也包含一系列case分支。跟switch不同的是,case分支不会被顺序测试,如果没有任何分支的条件可供满足,select会一直等待直到某个case语句完成。

所有通道的读取和写入都被同时考虑,以查看它们中的任何一个是否准备好: 如果没有任何通道准备就绪,则整个select语句将会阻塞。当一个通道准备好时,该操作将继续,并执行相应的语句。 我们来看一个简单的例子:

start := time.Now()
c := make(chan interface{})
go func() {
    time.Sleep(5 * time.Second)
    close(c) // 1
}()

fmt.Println("Blocking on read...")
select {
case <-c: // 2
    fmt.Printf("Unblocked %v later.\n", time.Since(start))
}
  1. 这里我们在5秒后关闭通道。
  2. 在这里我们尝试读取通道。注意,尽管我们可以不使用select语句而直接使用<-c,但我们的目的是为了展示select语句。

这会输出:

Blocking on read...
Unblocked 5s later.

如你所见,在5秒的阻塞后我们进入select代码块。这是一种简单而有效的方式来实现阻塞等待,但如果反思一下,我们可以发现一些问题:

  • 当多个通道需要读取时会发生什么?
  • 如果所有通道都尚未初始化完成,该怎么办?
  • 如果我们想做点什么,但当前通道还没准备好呢?

第一个问题很有趣,让我们试试看会发生什么:

c1 := make(chan interface{})
close(c1)
c2 := make(chan interface{})
close(c2)

var c1Count, c2Count int
for i := 1000; i >= 0; i-- {
    select {
    case <-c1:
        c1Count++
    case <-c2:
        c2Count++
    }
}

fmt.Printf("c1Count: %d\nc2Count: %d\n", c1Count, c2Count)

这会输出:

c1Count: 505
c2Count: 496

c1和c2被关闭后,可以读到其承载的类型的零值。你可以看到,在1001次循环中,大约有一半的时间从c1读取,有一半是从c2读取的。这似乎很有趣,也许有点太巧合。 事实上,是由Go的运行时导致的。Go运行时对一组case语句执行伪随机统一选择。这意味着在同样的条件下,每个case被选中的机会几乎是一样的。

乍一看这似乎并不重要,但其背后的理由是令人难以置信的有意思。我们先陈述一个事实:Go运行时无法知道你的select语句的意图;也就是说,它不能推断出你的问题所在,或者你为什么将一组通道放在一个select语句中。正因为如此,Go运行时所能做的最好的事情就是在任何情况下运行良好。一个好的方法是在你的程序中中引入一个随机变量——以决定选择哪个case执行。通过加权平均使用每个通道的机会,使得所有使用select语句的Go程序表现良好。

第二个问题呢?如果所有通道都尚未初始化完成会发生什么?如果所有的通道都处在阻塞状态,你无法进行处理,但你又不能就这样持续阻塞下去,你可能希望程序能够执行超时。Go的time包提供了一个很好的方式来完成这个功能,这些功能完全符合选择语句的范式。 这里有一个例子:

var c <-chan int
select {
case <-c: //1
case <-time.After(1 * time.Second):
    fmt.Println("Timed out.")
}
  1. 这个case分支会永久阻塞,因为我们从一个nil通道读取。

这会输出:

Timed out.

time.After接收一个类型为time.After的参数,并返回一个通道,该通道将在你提供该通道的持续时间后发送当前时间。这提供了在选择语句中超时的简洁方法。我们将在第4章中重新讨论这种模式,并讨论针对此问题的更强大的解决方案。

我们还有最后的一个问题:如果我们想做点什么,但当前通道还没准备好呢?select语句中允许我们添加default条件,以便你在所有分支都不符合执行条件的时候执行。这里有个例子:

start := time.Now()
var c1, c2 <-chan int
select {
case <-c1:
case <-c2:
default:
    fmt.Printf("In default after %v\n\n", time.Since(start))
}

这会输出:

In default after 1.421µs

你可以看到它几乎是瞬间运行默认语句。这允许你在不阻塞的情况下退出选择块。 通常你会看到for-select循环结合使用。这使得goroutine可以在等待另一个goroutine报告结果的同时取得进展。 这是一个例子:

done := make(chan interface{})
go func() {
    time.Sleep(5 * time.Second)
    close(done)
}()

workCounter := 0
loop:
for {
    select {
    case <-done:
        break loop
    default:
    }

    // Simulate work
    workCounter++
    time.Sleep(1 * time.Second)
}

fmt.Printf("Achieved %v cycles of work before signalled to stop.\n", workCounter)

这会输出:

Achieved 5 cycles of work before signalled to stop.

在这个例子中,我们有一个循环正在做某种工作,偶尔检查它是否应该停止。

最后,空选择语句有一个特殊情况:select语句没有case子句。 这些看起来像这样:

select {}

这条语句将永久阻塞。

在第6章中,我们将深入研究select语句的工作原理。 从更高层面来看,它可以帮助你安全高效地将各种概念和子系统组合在一起。

最后编辑: kuteng  文档更新时间: 2021-01-02 17:30   作者:kuteng