Go 语言 Channel 简明教程

channnel 中文译作通道。通道的核心是通信(signaling)。一个通道允许一个 goroutine 向另一个 goroutine 发送关于特定事件的信号。把通道看作一种信号机制(signaling mechanism),将帮助你写出更好的代码,和更精确的行为。 为了理解信号是如何工作的,我们必须理解三个特性:

  • 投递可靠性保障(Guarantee Of Delivery)
  • 状态(State)
  • 是否携带数据(With or Without Data)

这三个特性设计了一种信号机制。

投递可靠性保障

投递可靠性保障基于一个问题:是否有必要保障一个特定 goroutine 发出的信号被接收?

换句话说,就像 listing 1 的例子:

Listing 1

01 go func() {
02     p := <-ch // Receive
03 }()
04
05 ch <- "paper" // Send

02行的发送 goroutine 是否需要保障05行的通道接收到数据之后才继续执行呢?
基于这个问题的答案,你将知道两种类型的通道:Unbuffered 和 Buffered。两种通道提供的投递可靠性保障不同。
保障非常重要。当写并发程序时,你要对是否需要保障有一个清晰的认识。

图 1 : 投递可靠性保障 投递可靠性保障

状态

通道的状态直接影响其行为。通道的state有 nil, open 和 closed 三种。

Listing 2 演示了如何声明或设置通道的状态。

Listing 2

// ** nil channel

// A channel is in a nil state when it is declared to its zero value
var ch chan string

// A channel can be placed in a nil state by explicitly setting it to nil.
ch = nil


// ** open channel

// A channel is in a open state when it’s made using the built-in function make.
ch := make(chan string)    


// ** closed channel

// A channel is in a closed state when it’s closed using the built-in function close.
close(ch)

状态决定了发送和接收动作是怎样的行为。

信号通过通道发送和接收(sent/received)。不要说read/write 因为channel没有 I/O 操作。

图 2 : 状态 状态 当一个通道处于 nil 状态时,任何发送或接收都会被阻塞。
当一个通道处于 open 状态时,信号可以被发送或接收。
当一个通道处于 closed 状态时,不能发送,但仍然可以接收信号。
这些状态提供了不同场景下的行为。将状态和投递可靠性保障结合起来,当你遇到 性能/收益 的权衡时,就能更好地分析。

是否携带数据

最后一个需要考虑的信号属性是,你是否需要携带/不携带数据的信号。
给一个通道发送数据的代码如下:

Listing 3

01 ch <- "paper"

使用携带数据的信号,通常情况是因为:

  • 一个 goroutine 用来启动一个新任务。
  • 一个 goroutine 将结果回报。 通过关闭通道,你可以不需要数据来进行通信。

Listing 4

01 close(ch)

当你不需要数据进行通信时,通常情况是因为:

  • 一个 goroutine 被告知需要停止正在进行的工作。
  • 一个 goroutine 回报它已经完成指令,但是没有执行结果。
  • 一个 goroutine 报告它已经完成处理并关闭。

这些规则也有例外,但这些是主要用例,也是本文重点关注的内容。我觉得这些规则的例外是原始的 代码异味

不携带数据进行通信的一个优点是单个 goroutine 能够立刻和多个 goroutine 进行通信。携带数据进行通信,通常是 goroutine 之间的一对一交换。

携带数据进行通信

当你需要携带数据进行通信时,你有三种类型的 channel 配置选项可供选择,取决于你需要的投递保障类型。

图 3 : 携带数据进行通信 携带数据进行通信 这三种通道选项分别是无缓冲(Unbuffered), 缓冲区大于 1(Buffered >1) 和缓冲区等于 1(Buffered =1)。

  • 有保障
    • 一个无缓冲的通道确保已接收到正在发送的信号。
      • 因为接收信号的动作,发生在发送信息的动作结束之前。
  • 无保障
    • 一个缓冲区大于 1 的通道不会确保已接收到正在发送的信号。
      • 因为发送信号的动作,发生再接收信号的动作结束之前。
  • 延迟保障
    • 一个缓冲区为 1 的通道提供延迟的保障。它能确保前一个已经发送的信号被接收到。
      • 因为第一个信号的接收,发生在第二个信号发送完成之前。
  • 缓冲区的大小不得为随机数,通常根据明确定义的约束条件计算得出。计算机的运算能力是有限的,所以无论时间和空间,都需要仔细进行衡量。*/

不携带数据进行通信

不携带数据的通信,通常用于取消操作。它允许一个 goroutine 通知另一个 goroutine 取消它正在进行的工作。取消操作可以用无缓冲和有缓冲两种类型的通道来实现,但是使用有缓冲而无数据的通道,是一个差的实践。

图 4 : 不携带数据进行通信 不携带数据进行通信

内置函数 close 被用来不携带数据进行通信。如「状态」一节的解释,一个通道被关闭后,仍然可以接收信号。 已关闭通道上的任何接收动作,都不会被阻塞,接收操作总会返回。

很多时候你需要标准库 context 包来实现不携带数据通信。context 包在底层使用无缓冲通道和内置函数 close来进行不携带数据的通信。

如果你选择使用自己的通知,而不是 context 包,来进行取消操作,那么你定义的通道应该是 type chan struct{} 类型。 它是一种零空间占用、惯用的方式,用来指示通道仅用于通信。

使用场景

了解以上的知识之后,最好的深入理解的方法是通过不同使用场景的例子进行实践。

携带数据通信 - 保障 - 无缓冲通道

当你需要知道一个发送中的信号,是否被接收,这时就有两个场景: 等待任务( Wait For Task)和等待场景(Wait For Result)。

场景 1 - 等待任务

假设你是一个经理,雇了一名新员工。在这个场景中,你需要员工完成一项任务,但他需要你交给他一沓文件后,他才能开始工作。

Listing 5 https://play.golang.org/p/AqqhbjDYDV5

1  package main
2  
3  import (
4   "math/rand"
5   "time"
6   "fmt"
7  )
8  
9  func main() {
10    ch := make(chan string,1)
11  
12    go func() {
13      p := <-ch
14  
15      // Employee performs work here.
16      fmt.Println("The employee starts to handle "+p)
17  
18      // Employee is done and free to go.
19    }()
20  
21    time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
22    fmt.Println("Manager give paper")
23    ch <- "paper"
24    //time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
25    fmt.Println("Manager do other works")
26  }

第 10 行,创建了一个无缓冲通道,字符串类型的数据将通过信号被发送;
第 12 行,员工被雇用;
第 13 行,员工需要等待经理的信号,才能继续工作;员工在等待经理递交文件的过程中处于阻塞状态。一旦文件被接收,员工开始工作;完成工作之后,员工就可以玩耍了。
你作为经理,与你的新员工同步工作。所以你在第 12 行雇用新员工后,你需要做的事是解锁和通知员工(第 21 行)。注意,你整理需要发送的文件的时间是不可预知的。

终于你准备好通知雇员了。 在第 23 行,你携带数据进行通信,数据就是文件。因为使用了无缓冲通道,所以可以确保当你的发送操作完成时,员工已经接收了文件。接收发生在发送结束之前。

从技术上来讲,你所知道的是在你的通道发送操作完成时,员工已经收到了文件。两个通道操作之后,调度器(scheduler)可以选择任意的语句来执行。下一行被执行的代码是你还是员工,是不确定的。这意味着 print 语句的执行顺序可能不如你的预期那样。

场景 2 - 等待结果

在这个场景下,次序是颠倒的。这次你的员工被雇用后立刻就会投入工作,你需要等待他们工作的结果。你需要等待,因为你需要员工递交的文件。

Listing 6 https://play.golang.org/p/l8JW8AP5-KO

1  package main
2  
3  import (
4   "math/rand"
5   "time"
6   "fmt"
7  )
8  
9  func main() {
10    ch := make(chan string)
11  
12    go func() {
13      time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
14  
15      ch <- "paper"
16  
17      // Employee is done and free to go.
18    }()
19  
20    p := <-ch
21    fmt.Println(p)
22  }

第 10 行,创建了一个无缓冲通道,字符串类型的数据将通过信号被发送;
第 12 行,员工被雇用并立即投入工作;
第 20 行,你等待文件的报告。

第 13 行,员工的工作完成后,在第 15 行将结果同步给你。因为通道是无缓冲通道,接收发生在发送完成时,员工能够保证你能接收到结果。一旦员工完成保障,他们的工作就完成了。在这个场景下,你不知道员工完成任务需要花费的时间。

代价/收益

无缓冲通道确保了发送中的信号一定会被接收。这个特性很棒,但是有代价的。可靠性保障的代价的未知的延迟时间。在「等待任务」场景里,员工在文件交给自己之前,不知道等待多长时间;在「等待结果」场景里,你不知道员工花费多长时间才将结果返回。

这两个场景里,为了保障投递的可靠性,我们必须要忍受未知的延迟时间。 没有这种可靠性保障,逻辑就无法正常工作。

携带数据进行通信 - 无保障 - 缓冲区大于 1 的通道

当你不需要知道发送中的信号是否被接收,这时有两种场景:扇出(Fan Out)和丢弃(Drop)。

带缓冲区的通道拥有定义明确的空间,来存储发送中的数据。所以你如何确定需要多少空间呢?可以尝试回答以下问题:

  • 我有定义明确的工作需要完成吗?
    • 哪里有多少工作?
  • 如果员工不能赶上进度,我能丢弃新工作吗?
    • 有多少未完成的工作使我满负荷工作?
  • 如果我的程序异常结束,我能接受哪种级别的风险?
    • 所有缓冲区的数据将会丢失。

如果以上问题对你正在建模的行为没有任何意义,那么不建议使用缓冲区大于 1 的通道。

场景 1 - 扇出

扇出模式允许在遇到一定问题时,安排一定数量的员工同时工作。因为每个任务由一个员工负责,因此你准确地知道你将接收到多少报告。你可以确定盒子里有足够的空间来接收所有这些报告。这样的好处是你的员工无需等待即可提交他们的报告。但是,当他们同时或几乎同时到达盒子时,则需要轮流将报告放入箱子中。

假设你再次成为经理,但是这次你雇用了一组员工。你需要每一个员工去执行一个任务。每个员工完成各自的任务后,他们需要把报告文件放置到你办公桌前的盒子里。

Listing 7 https://play.golang.org/p/8HIt2sabs_

01 func fanOut() {
02     emps := 20
03     ch := make(chan string, emps)
04
05     for e := 0; e < emps; e++ {
06         go func() {
07             time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
08             ch <- "paper"
09         }()
10     }
11
12     for emps > 0 {
13         p := <-ch
14         fmt.Println(p)
15         emps--
16     }
17 }

第 3 行创建了一个字符串类型的、有缓冲区的通道。由于第 2 行员工能够的数量为 20,所以通道的缓冲区大小也设置为 20。

第 5 至第 10 行,20 个员工被雇用并立即开始工作。你不知道在第 7 行员工花费多长时间。第 8 行,员工们提交他们的报告文件,但是这次发送操作不会阻塞等待接收操作。 因为每一个员工在盒子里都有空间,因此通道上的发送操作只有在他们的发送时间同时或接近时才会竞争。

第 12 至 16 行都是你的工作。在这里,你需要等待所有的 20 个员工完成并发送他们的报告。第 12 行,你在一个循环中;第 13 行你被阻塞,然后等待接收报告。当有报告被接收,报告在第 14 行被打印,同时计数器减 1 表明一个员工已经完成任务。

场景 2 - 丢弃

丢弃模式允许你在员工满负荷时丢弃工作。这样的好处是可以继续接受客户端的请求,并且在接受工作时不会增加后端的压力或延迟。关键是要知道什么时候达到满负载,这样才不会过少或过多地接受工作。通常需要通过集成测试或性能测试来帮助你识别这个数字。

再次想象你是那个经理。你雇用 1 个员工来完成工作。你需要你的员工完成 1 项工作。当员工完成他们的工作时,你不必关注他们已经完成了。真正重要的是你是否可以在盒子里放置新任务。如果你不能执行发送操作,你就知道盒子是满的,员工已经满负荷工作了。这时新任务会被丢弃以保证事情向前推进。

Listing 8 https://play.golang.org/p/PhFUN5itiv

01 func selectDrop() {
02     const cap = 5
03     ch := make(chan string, cap)
04
05     go func() {
06         for p := range ch {
07             fmt.Println("employee : received :", p)
08         }
09     }()
10
11     const work = 20
12     for w := 0; w < work; w++ {
13         select {
14             case ch <- "paper":
15                 fmt.Println("manager : send ack")
16             default:
17                 fmt.Println("manager : drop")
18         }
19     }
20
21     close(ch)
22 }

第 3 行创建了一个有缓冲区的通道。这次在第 2 行定义了通道有 5 个缓冲区。
第 5 至 9 行,只有一个员工来处理工作。 for range 用来处理通道的接收工作。每一份文件被接收后,都在第 7 行进行了处理。
第 11 至 19 行,你尝试发送 20 份文件给你的员工。这时在第 14 行使用 select 语句的第 1 个 case 条件来执行发送动作。因为第 16 行使用了默认的条件:当发送动作由于缓冲区无空间而阻塞时,在第 17 行发送动作被放弃执行。

第 21 行调用了内置函数 close。当员工们完成分配的工作后就可以自由离开。

代价/收益

缓冲区大于 1 的通道不确保发出的信号被接收。其中一个收益是减少或消除 goroutine 之间通信的延迟。在“扇出”场景下,每一个即将发送报告的员工都有一个缓冲区。在“丢弃”场景下,缓冲区进行过容量测试。如果达到容量限制,任务将会被丢弃以保证事情顺利进行。

这两种场景下,我们必须要忍受可靠性保证的缺失,因为延迟的减少更重要。 零延迟或低延迟不会对整个系统造成影响。

携带数据进行通信 - 延迟保障 - 缓冲区等于 1 的通道

如果发送新信号时需要知道前一个发出的信号是否被接收,就需要「等待任务」(Wait For Tasks)场景。

场景 1 - 等待任务

在这个场景下,你有 1 个新员工,但他们不止做 1 个工作。你需要他们一个接一个地做很多工作。但是他们只有在完成 1 个任务后,才能开始新工作。由于他们一次只能专注于一件工作,因此工作切换之间可能存在延迟问题。在员工执行下一个任务,不损失可靠性的前提下,减少延迟是非常有帮助的。

这是缓冲区为 1 的通道的优点。 如果一切都按照你和员工所期待的速度进行,那么你们两个都不需要等待对方。你每次发送一个报告时,缓冲区都是空的。你的员工每次需要更多工作时,缓冲区都已满。 这是工作流程的完美对称。

以上是最好的部分。如果你每次试图发送一个报告时,都因为缓冲区已满而无法发送,你知道你的员工遇到了问题,而你也需要停下来。这就是造成延迟保障的原因。当缓冲区为空,你执行了发送动作,你知道你的员工已经完成了你之前发送的工作。如果你将要发送但还不能发送,说明员工尚未完成之前的工作。

Listing 9 https://play.golang.org/p/4pcuKCcAK3

01 func waitForTasks() {
02     ch := make(chan string, 1)
03
04     go func() {
05         for p := range ch {
06             fmt.Println("employee : working :", p)
07         }
08     }()
09
10     const work = 10
11     for w := 0; w < work; w++ {
12         ch <- "paper"
13     }
14
15     close(ch)
16 }

第 2 行定义了一个缓冲区为 1 的通道。 第 4 至 8 行,你雇用了 1 个员工来完成工作。 for range 用来处理通道的接收工作。每一份文件被接收后,都在第 6 行进行了处理。

第 10 至 13 行,你开始把任务发送给员工。 如果你的员工处理任务的速度和你发送的速度一样快,那么你们之间的延迟会减少。但是每次你成功发送,你可以相信上次提交的任务正在被处理。

第 15 行,调用了内置函数 close。当员工们完成分配的工作后就可以自由离开。但是,最后 1 个工作在 for range 结束之前会被接收。

不携带数据进行通信 - Context

最后一个场景下,你会看到如何使用 context 包的 Context 来取消一个正在运行的 goroutine。这是利用关闭无缓冲区的通道来进行不携带数据通信来实现的。

你是经理,雇用了 1 名员工。这次你不希望等待未知的时间。你有个慎重考虑过的截止日期。如果员工届时无法完成,那么你就不等了。

Listing 10 https://play.golang.org/p/6GQbN5Z7vC

01 func withTimeout() {
02     duration := 50 * time.Millisecond
03
04     ctx, cancel := context.WithTimeout(context.Background(), duration)
05     defer cancel()
06
07     ch := make(chan string, 1)
08
09     go func() {
10         time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
11         ch <- "paper"
12     }()
13
14     select {
15     case p := <-ch:
16         fmt.Println("work complete", p)
17
18     case <-ctx.Done():
19         fmt.Println("moving on")
20     }
21 }

第 2 行定义了一个 duration 值,用来声明员工完成任务需要花费的时间。
第 4 行创建了一个超时时间为 50ms 的 context.Context 值。context 包的 WithTimeout 函数返回一个 Context 值和 cancellation (取消)函数。

context 包创建了一个 goroutine,它的作用是当 duration 值满足时,关闭与Context 关联的无缓冲区的通道。无论结果如何,你都有责任调用 cancel 函数。 这将清除为 Context 创建的内容。cancel 函数被多次调用是允许的。

第 5 行,cancel 函数被延迟到 withTimeout 函数结束之前执行。
第 7 行创建了一个缓冲区等于 1 的通道,将被用于员工给你发送工作成果。
第 9 至 12 行,员工被雇用并立即投入工作。你不知道员工花费多长时间来完成。
第 14 至 20 行,你使用 select 语句来接收两个通道的信息。
第 15 行的接收动作,你等待员工发送他们的结果。
第 18 行的接收动作,你等待 50ms 达到时发出的信号。
哪个信号先到,哪个优先处理。

该算法的一个重要方面是使用了缓冲区为 1 的通道。如果员工没有及时完成,你不用给员工任何通知就可以继续。从员工的角度,他们在第 11 行发送报告,并且不知道你是否接收。如果你使用无缓冲区通道,当你继续工作时,员工会永远阻塞。这将产生 goroutine 泄漏。所以使用缓冲区为 1 的通道来阻止这种情况发生。

结论

通信的可靠性保障、通道状态和发送等特性,对于我们使用或理解通道(并发)非常重要。它将帮助我们在写并发程序和算法时实现最佳行为,也帮助我们发现错误。

在这篇博文里,我分享了一些样例程序来演示不同场景下的通信工作的属性。每个规则都有例外,但这些模式是一个不错的基础。

以下是一些总结:

语言技巧

  • 使用通道来编排和协调 goroutine
    • 关注通信属性而不是数据分享
    • 携带数据进行通信,或者不携带数据进行通信
    • 质疑同步访问共享状态的用途
      • 在某些情况下,通道可能更简单
  • 无缓冲区通道:
    • 接收动作发生在发送动作之前
    • 收益:100% 保证信号被接收
    • 代价: 信号被接收的时间延迟为未知
  • 缓冲区通道:
    • 发送动作发生在接收动作之前
    • 收益:减少通信之间的阻塞延时
    • 代价:无法保证信号何时被接收。
      • 缓冲区越大,保证越弱。
      • 缓冲区为 1 可以提供延迟保障
  • 关闭通道:
    • 关闭发生在接收动作之前(类似缓冲区)
    • 不携带数据进行通信
    • 适合取消和截止时间的通信
  • nil 通道:
    • 发送和接收都会阻塞
    • 关闭通信
    • 非常适合速率限制或暂时停工

设计理念

  • 如果一个通道上的任意发送会导致发送 goroutine 阻塞:
    • 不要使用缓冲区大于 1 的通道
      • 使用缓冲区大于 1 的通道需要合理的理由和测量
    • 必须知道发送 goroutine 阻塞时会发生什么
  • 如果一个通道上的任意发送不会造成发送 goroutine 阻塞:
    • 每次发送你有确切的缓冲区的数目
      • 扇出模式
    • 你有缓冲区的最大容量
      • 丢弃模式
  • 缓冲区宁少勿多
    • 使用缓冲区时,不要考虑性能
    • 缓冲区可以帮助减少通信之间的阻塞延时
      • 将阻塞延时减少到零并不意味着更高的吞吐量
      • 如果缓冲区为 1 已经给你足够的吞吐量,那么保持就好了
      • 质疑大于 1 的缓冲区。如果有必要采用大于 1 的缓冲区,需要通过测试来确定缓冲区的大小
      • 在保证足够吞吐量的同时,缓冲区越小越好

译文原文

Behavior Channels

Published: June 23 2020

blog comments powered by Disqus