这篇文章重点介绍了通道(Channel)在 Go 中的工作方式,以及如何在代码中使用它们。
在 Go 中,Channel 是一种编程结构,它允许我们在代码的不同部分之间传输数据,通常来自不同的 goroutine。
创建通道
我们可以通过使用 chan
关键字和数据类型来声明一个新的通道类型:
var c chan int
在这里,c
是 chan int
类型——这意味着它是一个发送 int
类型的通道。 channel 的默认值为 nil
,所以我们需要在使用前先赋值。
或者,我们可以使用 make
函数来声明和初始化通道:
c := make(chan int)
发送和接收数据
现在让我们看一些使用通道获取乘法结果的代码(示例1):
package main
import (
"fmt"
)
func main() {
n := 3
// 我们在这里“make”了一个channel
// 可以用来传输 `int` 数据类型
out := make(chan int)
// 我们将这个函数作为一个 goroutine 来运行,
// 使用上面的 channel 作为参数
go multiplyByTwo(n, out)
// 一旦在该通道上接收到任何输出,将其打印到控制台并继续
fmt.Println(<-out)
}
// 这个函数现在接受一个通道作为它的第二个参数
func multiplyByTwo(num int, out chan<- int) {
result := num * 2
// 将结果输出到Channel中
out <- result
}
你可以在这里执行这段代码
Channel 为我们提供了一种“连接”我们程序的不同并发部分的方法。它可以被认为是连接我们代码的不同并发部分的“管道”或“动脉”。
定向通道
Channel 可以是定向的——这意味着您可以将通道限制为仅发送或仅接收数据。这由 channel 声明附带的箭头 (<-
) 指定
例如,看一下 multiplyByTwo
函数的 out
参数的类型定义:
out chan<- int
chan<-
声明告诉我们您只能将数据发送到通道中,但不能从中接收数据。int
声明告诉我们 channel 将只接受int
数据类型。
虽然它们看起来像独立的部分,但 chan<- int
可以被认为是一种数据类型,它描述了一个**“仅发送”的整数通道** 。
同样,“仅接收”通道 声明的示例如下所示:
out <-chan int
您还可以在不提供方向性的情况下声明一个通道,这意味着它可以发送或接收数据:
out chan int
当我们在 main
函数中创建 out
通道时可以看到这一点:
out := make(chan int)
然后可以根据您要在代码中其他地方施加的限制,将此通道转换为定向通道。
阻塞条件
从通道发送或接收值的语句会在它们自己的 goroutine 中进行阻塞。也就是说:
- 从 channel 接收数据的语句将会阻塞,直到接收到一些数据
- 向 channel 发送数据的语句将等待,直到发送的数据被接收
例如,在 main
函数中,当我们尝试打印接收到的值时:
fmt.Println(<-out)
<-out
语句将阻塞代码,直到在 out
通道上接收到数据。
注意:从 nil channel 发送或接收数据将永远阻塞。
创建 Worker
示例1可以用另一种方式实现,使用 2 个通道:一个用于向 goroutine 发送数据,另一个用于接收结果(运行此代码)。
func main() {
n := 3
in := make(chan int)
out := make(chan int)
// 我们现在为 `multiplyByTwo` 函数提供 2 个通道
// 一个用于发送数据,一个用于接收
go multiplyByTwo(in, out)
// 然后我们通过通道向它发送数据并等待结果
in <- n
fmt.Println(<-out)
}
func multiplyByTwo(in <-chan int, out chan<- int) {
fmt.Println("Initializing goroutine...")
// 在“in”通道上接收到数据之前,goroutine 不会继续,会阻塞在此处
num := <-in
// 其余逻辑和之前的一样
result := num * 2
out <- result
}
将工作分配给 goroutines 的一种常见模式是生成持久 Worker 并通过通道发送和接收信息。
func main() {
out := make(chan int)
in := make(chan int)
// 创建 3 个 `multiplyByTwo` worker
go multiplyByTwo(in, out)
go multiplyByTwo(in, out)
go multiplyByTwo(in, out)
// 到目前为止,创建的 goroutines 都没有真正做任何事,
// 因为它们都在等待 `in` 通道接收数据
// 现在我们在另一个 goroutine 中发送数据到 in 通道
go func() {
in <- 1
in <- 2
in <- 3
in <- 4
}()
// 现在我们等待每个结果进入out通道并打印
fmt.Println(<-out)
fmt.Println(<-out)
fmt.Println(<-out)
fmt.Println(<-out)
}
func multiplyByTwo(in <-chan int, out chan<- int) {
fmt.Println("Initializing goroutine...")
for {
num := <-in
result := num * 2
out <- result
}
}
产生的 worker 数对应于您想要的并发数。
在上面的例子中,我们产生了三个 worker,有四个任务。前三个任务会立即分配一个 worker,但第四个任务必须等其中一个worker完成后才能被执行。
“Select”声明
当有多个通道等待接收信息,并且希望在其中任何一个通道完成时执行某个操作,可以使用 select
语句。
select {
case res := <-channel1:
// do something
case channel2 <- someData:
// do something else
case <- channel3:
// do another thing
}
在这里,执行的操作取决于哪个情况先完成-其他情况将被忽略。
让我们看一个例子,我们有一个快速的 worker 和一个慢速的 worker 来执行乘法:
// `fast` 和 `slow` 函数做同样的事情
// 但 `slow` 需要更多时间完成
func fast(num int, out chan<- int) {
result := num * 2
time.Sleep(5 * time.Millisecond)
out <- result
}
func slow(num int, out chan<- int) {
result := num * 2
time.Sleep(15 * time.Millisecond)
out <- result
}
func main() {
out1 := make(chan int)
out2 := make(chan int)
// 我们使用不同的goroutine 和不同的 channel 来执行 fast 和 slow
go fast(2, out1)
go slow(3, out2)
// 执行哪个操作是看哪个 channel 先接收到信息
select {
case res := <-out1:
fmt.Println("fast finished first, result:", res)
case res := <-out2:
fmt.Println("slow finished first, result:", res)
}
}
如果我们运行这段代码,我们将得到输出:
fast finished first, result: 4
select
语句由 out1
触发并忽略 out2
中指定的操作。
select
语句的一个常见用例是结合 context 检测何时需要取消某个操作——如果我们正在执行一个时间敏感的操作,我们理想情况下希望有一个执行的截止时间,在操作停滞或花费太长时间时可以快速失败。
缓冲通道
在前面的几个例子中,我们看到通道语句阻塞,直到数据被发送到通道或从通道接收。
发生这种情况是因为通道没有任何地方可以“存储”进入它的数据,因此需要等待语句来接收数据。
缓冲通道是一种内部具有存储容量的通道。要创建缓冲通道,我们向 make
语句添加第二个参数以指定容量:
out := make(chan int, 3)
现在 out
是一个容量为 3 个整数变量的缓冲通道。这意味着它在阻塞之前最多可以接受 3 个值:
package main
import "fmt"
func main() {
out := make(chan int, 3)
out <- 1
out <- 2
out <- 3
// 这个语句会阻塞
out <- 4
}
您可以将缓冲通道视为普通通道加上存储(或缓冲区)。
缓冲通道用于在没有可用接收者时,我们不希望通道语句被阻塞的情况。添加缓冲区可以让我们等待一些接收者被释放,而不会阻塞发送代码。
为什么我们需要 Channel?
通道允许我们在并发运行的代码的不同部分之间进行惯用通信。
由于通道语句在发送或接收数据时会阻塞,因此它使我们的代码不易出错,因为许多错误都是由我们在写入值之前读取值的数据竞争引起的。
Channel 使 Go 中的并发编程变得更加容易,并使您的代码在某些情况下更具可读性。
并不是所有的地方都需要使用 channel。有时,使用指针和 waitgroup 来传递信息可能更加容易。
与所有并发编程一样,避免竞争条件很重要,因为这会产生难以预测的错误。