Channels In Go

本文翻译自Channels In Go

Channel是Go中一个重要的内置功能。这是让Go独一无二的功能之一,除了另一个独特的功能,goroutine。这两个独特的功能使Go中的并发编程变得非常方便和有趣,这两个特性降低了并发编程的难度。

本文将列出所有与channel相关的概念,语法和准则。为了更好地理解channel,还简单描述了channel的内部结构和标准Go编译器/运行时的一些实现细节。

对于新的gopher而言,本文的内容可能过于密集。某些部分可能需要阅读多次才能消化。

并发编程和并发同步

现代CPU通常具有多个内核,而某些CPU内核支持超线程。换句话说,现代CPU可以同时处理多个指令流水线。为了充分利用现代CPU的强大功能,我们需要在编写程序时进行并发编程。

并发计算是一种计算形式,其在重叠的时钟周期内同时执行若干计算。下图描绘了两个并发计算案例。在图中,A和B代表两个单独的计算。第二种情况也成为并行计算,它是特殊的并发计算。在第一种情况下,A和B仅在一小段时间内并行。


image.png

并发计算可以在程序,计算机和网络中发生。在这里,我们只讨论程序范围内的并发计算。Goroutine是创建并发计算的Go方法。

并发计算可以共享资源,通常是内存资源。在并发计算中可能会发生某些情况:

  • 在同一时段中,一条指令会将数据写入内存段中,而另一条指令会从相同的内存段中读取数据。此时,数据读取的完整性将无法得到保证。
  • 在同一时段,两条不同的指令会向相同的内存段中写入数据。此时,存储在内存段中的数据完整性将无法得到保证。

这些情况称为数据竞争。并发编程的一个职责是控制并发计算的资源共享,这样数据竞争就不会发生。实现此任务的方法称为并发同步或数据同步。Go支持多种数据同步技术,下面将介绍其中之一:Channel。

并发编程的其他职责包括:

  • 确定需要多少计算量
  • 确定何时开始,阻塞,取消阻塞和结束计算
  • 确定如何在并发的计算间分配工作负载

Go中的大多数操作都没有进行同步。换句话说,它们不是并发安全(concurrency-safe)的。这些操作包括赋值,参数传递和容器元素操作等。只有少数几个操作进行了同步,包括下面几个要引入的通道操作。

在Go中,每次计算都基于一个goroutine。因此,稍后我们使用goroutines来表示计算过程。

Channel 概述

对于并发编程的一个建议(由Rob Pike提出)不是(让计算)通过共享内存进行通信,而是让它们通过通信(channel)共享内存。

通过通信共享内存和通过共享内存进行通信是并发编程中的两种编程方式。当goroutines通过共享内存进行通信时,我们需要使用一些传统的并发同步技术(如互斥锁)来保护共享内存以防止数据争用。我们可以使用channels来实现通过通信共享内存。

Go提供了一种独特的并发同步技术,channel。Channels使得goroutine通过通信共享内存。我们可以将channel视为程序中的内部FIFO数据队列。一些goroutine将值发送到队列(channel),而其他一些goroutine则从队列中接收值。

除了传递值,一些值的所有权也可以在goroutine之间传递。当goroutine向channel发送一个值时,我们可以看到goroutine释放了某些值的所有权。当goroutine从channel接收到一个值时,我们可以看到goroutine获取了某些值的所有权。实际的数据通常被转移的值所引用。当然,也可能没有任何所有权随通信channel一起转移。

请注意,在这里,当我们讨论所有权时,我们指的是逻辑试图中的所有权。与Rust语言不同,Go不确保语法级别的值所有权。Go channel可以帮助程序员轻松地编写防止数据竞争的代码,但Go channel无法阻止程序员编写错误的并发代码。

虽然Go还支持传统的并发同步技术。只有channel才是Go的一级公民。Channel是Go中的一种类型,因此我们可以在不导入任何包的情况下使用channel。另一方面,在syncsync/atomic包中提供了那些传统的并发同步技术。

老实说,每种并发同步技术都有自己的最佳使用场景。但channel拥有更广泛的使用范围。channel的一个问题是,使用channel编程的体验是如此愉快和有趣,以至于程序员甚至更喜欢将channel用于一些不适合的场景。

Channel 的类型和值

像数据,切片和map一样,每个channel都有一个元素类型。channel只能传输该类型的值。

channel可以是双向或单向的。假设T是任意类型:

  • chan T 表示双向信道类型。编译器允许从双向channel中接收值和向双向channel发送值。
  • chan<- T 表示仅发送信道类型。编译器不允许从仅发送channel接收值
  • <-chan T 表示仅接收信道类型。编译器不允许向仅接收channel发送值

双向信道类型chan T的值可以隐式地转换为仅发送类型chan<- T和仅接收类型<-chan T,但反之则不然。仅发送类型chan<- T的值不能转换为仅接收类型<-chan T,反之亦然。请注意,channel类型文字中的<-符号是修饰符。

每个channel都有一个容量,将在下一节中介绍。具有零容量的channel称为无缓冲channel,具有非零容量的channel称为缓冲channel。

channel类型的零值用标识符nil表示。必须使用内置的make函数创建非零channel值。例如,make(chan int, 10)将创建一个元素类型为int的channel。make函数的第二个参数指定新创建的channel的容量。第二个参数是可选的,其默认值为零。

Channel的赋值和比较

所有的channel类型都是可比较类型。

非零的channel值是多部分(multi-part values)值。在将一个channel值赋值给另一个channel值之后,这两个channel共享相同的基础部分。换句话说,两个channel代表相同的内部channel对象。比较它们的结果是true

Channel 操作

有五个channel特定的操作。假设ch是一个channel类型的变量,这里列出了这些操作的语法和函数调用。

  1. 关闭通道
close(ch)

close是一个内置函数。close函数的参数必须是channel类型的变量,而且ch不能是只接收类型的通道。

  1. 发送一个值v到通道中
ch <- v

其中v必须是可分配给通道ch的元素类型的值,并且通道ch不能是仅接收类型的通道。请注意,此处 <- 是一个channel发送操作符。

  1. 从通道中接收一个值
<-ch

channel接收操作始终返回至少一个结果,该结果是通道的元素类型的值,并且通道ch不能是仅发送通道。请注意,此处 <- 是channel接收操作符。是的,它的表示与channel发送操作符相同。

对于大多数情况,channel接收操作被视为单值表达式。但是,当channel操作符用于赋值中唯一的源值表达式时,它可以生成第二个可选的布尔值,并成为多值表达式。布尔值表示在关闭channel之前是否发送了相应的值。(下面我们将了解到我们可以从一个关闭的channel中接收到无限数量的值)

v = <-ch
v, sentBeforeClosed = <-ch
  1. 查询channel的容量
cap(ch)

cap是一个内置函数,它曾在Go的容器中引入。cap函数调用的返回结果是int

  1. 查询channel的缓存中当前存储了多少元素
len(ch)

len是一个内置函数。len函数调用的返回值是int值。函数返回的结果表示已经成功发送但尚未接收的元素数。

所有这些操作都是已同步的,因此无需进一步同步即可安全地执行这些操作。但是,与Go中的大多数其他操作一样,channel的赋值不是同步操作,类似的,尽管任何channel的接收操作是同步的,但是将接收到的值分配给其他值也不是同步的。

如果查询的channel是nil,那么caplen函数都返回0。这两个查询操作非常简单,以后不再进一步解释。实际上,这两种操作在实践中很少使用。

Channel 操作的细节说明

为了使channel操作的解释简单明了,在本文的其余部分,channel将分为三类进行讨论:
1. nil channel.
2. non-nil but closed channel.
3. not-closed non-nil channel.

下表简要归纳了各种channel的操作行为。

操作 A Nil Channel A Closed Channel A Not-Closed Non-Nil Channel
Close panic panic succeed to close(C)
Send block for ever panic block or succeed to send(B)
Receive block for ever never block(D) block or succeed to receive(A)
  • 关闭一个nil或已关闭的channel将导致panic
  • 向一个已关闭的channel中发送值也将导致panic
  • 向一个nil channel中发送值或从一个nil channel中接收值都将导致永久阻塞

为了更好地理解channel并使一些解释看起来更容易理解,学习内部通道对象的粗略内部结构非常有用。

我们可以认为每个通道内部维护了三个队列(所有队列都可以看作FIFO队列):

  1. 接收goroutine队列。该队列是没有大小限制的链表结构。此队列中的goroutine都处于阻塞状态并等待从该通道中接收值
  2. 发送goroutine队列。该队列也是没有大小限制的链表。此队列中goroutine都处于阻塞状态并等待向该通道中发送值。每个goroutine尝试发送的值(或值的地址,取决于编译器实现)也与该goroutine一起存储在队列中
  3. 值缓冲队列。这是一个循环队列。它的大小等于通道的容量。存储在此缓冲区队列中的值的类型是该通道的所有元素类型。如果存储在通道的值缓冲队列中的当前值的数量达到通道的容量,则通道状态为满状态。如果当前通道的值缓冲区队列中没有存储任何值,则通道状态为空状态。对于零容量(无缓冲)通道,它始终处于满或空状态。

每个通道内部都有一个互斥锁,用于避免各种操作中的数据争用。

操作A:当goroutine Gr 试图从未关闭的非零通道接收值时,goroutine Gr 将首先获取与通道关联的锁,然后执行以下步骤直到满足一个条件。

  1. 如果通道的缓冲区队列不为空,在这种情况下,通道的接收goroutine队列必为空,goroutine Gr 将从缓冲区队列接收一个值。如果通道的发送goroutine队列也不为空,则一个发送goroutine将会从发送goroutine队列中推出,并再次恢复为运行状态。刚刚推出的发送goroutine尝试发送的值将被推送到通道的值缓冲区队列中。接收goroutine Gr 继续运行。对于此场景,通道接收操作称为非阻塞操作。
  2. 否则(通道的值缓冲区队列为空),如果通道的发送goroutine队列不为空,在这种情况下通道必然是无缓冲通道,接收goroutine Gr 将从发送goroutine队列中推出一个发送goroutine,并接收刚刚推出的发送goroutine尝试发送的值。刚刚推出的发送goroutine将会解除阻塞并恢复运行状态。对于此场景,通道接收操作称为非阻塞操作。
  3. 如果值缓冲队列和通道的发送goroutine队列都为空,则goroutine Gr 将被推入通道的接收goroutine队列并进入(并保持)阻塞状态。当另一个goroutine稍后向该通道发送值时,它可以恢复到运行状态。对于此场景,通道接收操作称为阻塞操作。

操作B:当goroutine Gs 尝试向非关闭非零通道发送值时,goroutine Gs 将首先获取与通道关联的锁,然后执行以下步骤直到满足一个条件。

  1. 如果通道的接收goroutine队列不为空,在这种情况下,通道的值缓冲队列必然为空,发送goroutine Gs 将从通道的接收goroutine队列中推出接收goroutine并将值发送到刚刚推出的接收goroutine中。刚刚推出的goroutine将被解除阻塞并恢复到运行状态。发送goroutine Gs 继续运行。对于此场景,通道发送操作称为非阻塞操作。
  2. 否则(接收goroutine队列为空),如果通道的值缓冲区队列未满,在这种情况下,发送goroutine队列也必为空,发送goroutine Gs 尝试发送的值将被推入值缓冲区队列,发送goroutine Gr 继续运行。对于此场景,通道发送操作称为非阻塞操作。
  3. 如果接收goroutine队列为空并且通道的值缓冲区队列已满,则发送goroutine Gs 将被推入通道的发送goroutine队列并进入(并保持)阻塞状态。当另一个goroutine稍后从通道接收值时,它可以恢复到运行状态。对于此场景,通道发送操作称为阻塞操作。

上面提到过,一旦非零通道关闭,向通道发送值将在当前goroutine中产生panic。请注意,将数据发送到已关闭的通道将被视为非阻塞操作。

操作C:当goroutine尝试关闭未关闭的非零通道时,一旦goroutine获得了通道的锁,以下两个步骤将按顺序执行。

  1. 如果通道的接收goroutine队列不为空,在这种情况下,通道的缓冲区必为空,通道的接收goroutine队列中的所有goroutine将逐一取消,每个goroutine将接收到通道元素类型到零值,并恢复到运行状态。
  2. 如果通道的发送goroutine队列不为空,则通道的发送goroutine队列中的所有goroutine将被逐一取消,并且每个goroutine会因为向已关闭的channel中发送值而发生panic。已经被推入通道的值缓冲区的值仍然存在。

操作D:在非零通道关闭后,通道上的接收操作将永不阻塞。仍然可以接收通道的值缓冲区中的值。一旦取出了所有值后,通道的元素类型的无限零值将被通道上的任何后续接收操作接收。如上所述,通道接收操作的可选第二个返回结果是布尔值,其指示在通道关闭之前是否发送了第一个返回结果(接收值)。如果第二个返回结果为false,则第一个返回结果必然是通道元素类型的零值。

了解什么是阻塞和非阻塞通道发送或接收操作对于理解select控制流程块的机制非常重要,这将在后面的部分介绍。

根据上面列出的解释,我们可以得到一些关于通道内部队列的事实。

  • 如果通道关闭,则其发送goroutine队列和接收goroutine队列都必须为空,但其值缓冲区队列可能不为空
  • 在任何时候,如果值缓冲区队列不为空,则其接收goroutine队列必为空
  • 在任何时候,如果值缓冲区未满,则其发送goroutine队列必为空
  • 如果通道是缓冲的,那么在任何时候,其发送goroutine队列和接收goroutine队列之一必为空
  • 如果通道是非缓冲的,那么在任何时候,通常其发送goroutine队列和接收goroutine队列中的一个必为空,但是在执行select控制流时会存在例外。

Channel 使用样例

一个简单的请求/响应示例。这个例子中的两个goroutine通过一个无缓冲的通道互相交谈。

package main

import (
    "fmt"
    "time"
)

func main() {
    c := make(chan int) // an unbuffered channel
    go func(ch chan<- int, x int) {
        time.Sleep(time.Second)
        // <-ch    // this operation fails to compile.
        ch <- x*x  // blocking here until the result is received
    }(c, 3)
    done := make(chan struct{})
    go func(ch <-chan int) {
        n := <-ch      // blocking here until 9 is sent
        fmt.Println(n) // 9
        // ch <- 123   // this operation fails to compile
        time.Sleep(time.Second)
        done <- struct{}{}
    }(c)
    <-done // blocking here until a value is sent to channel "done"
    fmt.Println("bye")
}

使用缓冲通道的演示。该程序不是并发的,它只是为了展示如何使用缓冲通道。

package main

import "fmt"

func main() {
    c := make(chan int, 2) // a buffered channel
    c <- 3
    c <- 5
    close(c)
    fmt.Println(len(c), cap(c)) // 2 2
    x, ok := <-c
    fmt.Println(x, ok) // 3 true
    fmt.Println(len(c), cap(c)) // 1 2
    x, ok = <-c
    fmt.Println(x, ok) // 5 true
    fmt.Println(len(c), cap(c)) // 0 2
    x, ok = <-c
    fmt.Println(x, ok) // 0 false
    x, ok = <-c
    fmt.Println(x, ok) // 0 false
    fmt.Println(len(c), cap(c)) // 0 2
    close(c) // panic!
    c <- 7   // also panic if the above close call is removed.
}

一个永不结束的足球游戏

package main

import (
    "fmt"
    "time"
)

func main() {
    var ball = make(chan string)
    kickBall := func(playerName string) {
        for {
            fmt.Println(<-ball, "kicked the ball.")
            time.Sleep(time.Second)
            ball <- playerName
        }
    }
    go kickBall("John")
    go kickBall("Alice")
    go kickBall("Bob")
    go kickBall("Emily")
    ball <- "referee" // kick off
    var c chan bool   // nil
    <-c               // blocking here for ever
}

Channel 元素值按值传递

当值从一个goroutine传递到另一个goroutine时,该值将至少复制一次。如果传输的值保留在通道的值缓冲区中,则在传输过程中将产生两个副本。当值从发送方goroutine复制到值缓冲区时发生一个副本,另一个发生在将值从值缓冲区复制到接收方goroutine时。

对于标准Go编译器,通道元素类型的大小必须小于65536。但是,通常,我们不应该创建具有大尺寸元素类型的通道,以避免在goroutine之间传递值的过程中过大的复制成本。因此,如果传递的值大小太大,最好使用指针类型,以避免大的复制成本。

关于Channel和Goroutine的垃圾收集

注意,通道的发送或接收goroutine队列中的所有goroutine都引用了该通道,因此,如果通道的两个队列都不为空,则通道不会被垃圾回收。另一方面,如果goroutine被阻塞并且停留在通道的发送或接收队列中,则goroutine也将不会被垃圾收集,即使该通道仅由该goroutine引用。实际上,goroutine只能在已经退出后进行垃圾回收。

Channel 发送和接收操作都是简单语句

通道发送操作和接收操作都是简单语句。通道接收操作可以始终用作单值表达式。简单语句和表达式语句可用于基本流程控制块的某些部分。

一个简单示例,其中通道发送和接收操作在控制流程块中显示为两个简单语句

package main

import (
    "fmt"
    "time"
)

func main() {
    fibonacci := func() chan uint64 {
        c := make(chan uint64)
        go func() {
            var x, y uint64 = 0, 1
            for ; y < (1 << 63); c <- y { // here
                x, y = y, x+y
            }
            close(c)
        }()
        return c
    }
    c := fibonacci()
    for x, ok := <-c; ok; x, ok = <-c { // here
        time.Sleep(time.Second)
        fmt.Println(x)
    }
}

for-range On Channel

for-range 控制流适用于通道。循环将尝试迭代地接收发送到通道的值,直到通道关闭且其缓冲区队列变为空。与数组,切片和map上的for-range语法不同,用于存储接收值的单个迭代变量允许存在于通道上的for-range语法中。

for v = range aChannel {
    // use v
}

等同于

for {
    v, ok = <-aChannel
    if !ok {
        break
    }
    // use v
}

当然,此处aChannel的值不能是仅发送通道。如果它是一个nil通道,那么循环将永远阻塞。

select-case 控制流

有一个特殊的select-case代码块语法,专门为通道设计。语法很像switch-case语法。例如,在select代码块中可以由多个case分支和至多一个default分支。但它们之间也存在一些明显但差异。

  • 不允许任何表达式或语句跟随在select关键字之后(在{之前)
  • case分支中不允许存在fallthrough语句
  • 紧接在case关键字后的每个语句必须是通道接收操作或通道发送操作。通道接收操作可以以简单赋值语句的形式出现。
  • 如果存在一些非阻塞操作,Go运行时将随机选择其中一个分支执行
  • 如果所有case分支中的操作都是阻塞操作,则如果default分支存在,则将选择default分支执行。如果缺少default分支,则当前goroutine将被推入每一个case分支相关通道中的发送goroutine队列或接收goroutine队列,然后进入阻塞状态。

根据规则,没有任何分支的select-case代码块select{}将使当前goroutine永远保持阻塞状态。

下面的程序将进入default分支

package main

import "fmt"

func main() {
    var c chan struct{} // nil
    select {
    case <-c:             // blocking operation
    case c <- struct{}{}: // blocking operation
    default:
        fmt.Println("Go here.")
    }
}

一个样例,演示如何使用try-send和try-receive:

package main

import "fmt"

func main() {
    c := make(chan string, 2)
    trySend := func(v string) {
        select {
        case c <- v:
        default: // go here if c is full.
        }
    }
    tryReceive := func() string {
        select {
        case v := <-c: return v
        default: return "-" // go here if c is empty.
        }
    }
    trySend("Hello!")
    trySend("Hi!")
    trySend("Bye!") // fail to send, but will not blocked.
    fmt.Println(tryReceive()) // Hello!
    fmt.Println(tryReceive()) // Hi!
    fmt.Println(tryReceive()) // -
}

以下这个样例有50%的机会panic。在这个例子中,两个case操作都是非阻塞的

package main

func main() {
    c := make(chan struct{})
    close(c)
    select {
    case c <- struct{}{}: // panic if this case is selected.
    case <-c:
    }
}

推荐阅读更多精彩内容