这篇文章重点介绍Channels(通道)在 Go 中的工作方式,以及如何在代码中使用它们。

Go (Golang) 中的 Channels 简介

在 Go 中,Channels是一种编程结构,它允许我们在代码的不同部分之间移动数据,通常来自不同的 goroutine。

为什么我们需要Channels?

要理解通道,我们必须首先知道如何可视化 goroutine。

让我们从一个简单的 goroutine 开始,它接受一个数字,将它乘以 2,然后打印它的值:

package mainnnimport (nt"fmt"nt"time"n)nnfunc main() {ntn := 3nnt// We want to run a goroutine to multiply n by 2ntgo multiplyByTwo(n)nnt// We pause the program so that the `multiplyByTwo` goroutinent// can finish and print the output before the code exitsnttime.Sleep(time.Second)n}nnfunc multiplyByTwo(num int) int {ntresult := num * 2ntfmt.Println(result)ntreturn resultn}

我们可以将这个程序可视化为一组两个块:一个是main主函数,另一个是multiplyByTwo goroutine。

Go (Golang) 中的 Channels 简介

这个实现的问题是我们代码的这两个部分是相当不连贯的。作为结果 :

  • 我们的主函数(main)无法访问函数multiplyByTwo中的结果。
  • 我们无法知道multiplyByTwogoroutine 何时完成。因此,我们必须main通过调用来暂停函数time.Sleep,这种实现方式是不太好的。
创建Channels

我们可以通过使用关键字 chan 和数据类型来声明新的通道类型:

var c chan int

在这里,c 是发送类型chan int的通道。int通道的默认值为nil,所以我们需要赋值

在 Goroutine 之间添加Channels

现在让我们看一些使用通道来获取乘法结果的代码:

package mainnnimport (nt"fmt"n)nnfunc main() {ntn := 3nnt// This is where we "make" the channel, which can be usednt// to move the `int` datatypentout := make(chan int)nnt// We still run this function as a goroutine, but this time,nt// the channel that we made is also providedntgo multiplyByTwo(n, out)nnt// Once any output is received on this channel, print it to the console and proceedntfmt.Println(<-out)n}nn// This function now accepts a channel as its second argument...nfunc multiplyByTwo(num int, out chan<- int) {ntresult := num * 2nnt//... and pipes the result into itntout <- resultn}

通道为我们提供了一种“连接”程序不同并发部分的方法。在这种情况下,我们可以直观地表示两个并发代码块之间的这种联系:

Go (Golang) 中的 Channels 简介

此处的绿色箭头表示数据通过通道

通道可以被认为是连接我们代码的不同并发部分的“管道”或“动脉”。

定向通道(Directional Channels)

通道可以是定向的 - 这意味着您可以将通道限制为发送或接收数据。这由<-带有通道声明的箭头指定

例如,看一下函数multiplyByTwo中out参数的类型定义:

out chan<- int

  • chan<-声明告诉我们,您只能将数据发送到通道,但不能从通道接收数据。
  • int声明告诉我们通道将只接受数据int类型。

尽管它们看起来像单独的部分,chan<- int但可以被认为是一种数据类型,它描述了整数的“仅发送”通道。

同样,“仅接收”通道声明的示例如下所示:

out <-chan int

您还可以声明一个通道而不给出方向性,这意味着它可以发送或接收数据:

out chan int

就像我们在main函数中创建out通道:

out := make(chan int)

然后可以根据您要在代码中其他地方施加的限制将此通道转换为定向通道。

阻塞语句

从通道发送或接收值的语句在它们自己的 goroutine 中是阻塞的。这表示:

  • 从通道接收数据的语句将阻塞,直到接收到一些数据
  • 向通道发送数据的语句将等待,直到接收到发送的数据

例如,当我们尝试打印接收到的值时(在main函数中):

fmt.Println(<-out)

该<-out语句将阻塞代码,直到out在通道上接收到一些数据。然后通过将块分成两部分来帮助可视化这一点main:运行直到等待通道接收数据的部分,以及之后运行的部分。

Go (Golang) 中的 Channels 简介

这里添加的虚线箭头是为了表明它是启动goroutine的main函数。multiplyByTwo

的第二部分main只能在通过通道接收到数据后运行(由绿色箭头描绘)

注意:从nil 通道发送或接收数据也将永远阻塞。

通道工作者(Channel Workers)

Example #1 可以用另一种方式实现,使用 2 个通道:一个用于向 goroutine 发送数据,另一个用于接收结果。

将工作分配给 goroutine 的一种常见模式是产生Worker并通过通道发送和接收信息。

func main() {ntout := make(chan int)ntin := make(chan int)nnt// Create 3 `multiplyByTwo` goroutines.ntgo multiplyByTwo(in, out)ntgo multiplyByTwo(in, out)ntgo multiplyByTwo(in, out)nnt// Up till this point, none of the created goroutines actually dont// anything, since they are all waiting for the `in` channel tont// receive some data, we can send this in another goroutinentgo func() {nttin <- 1nttin <- 2nttin <- 3nttin <- 4nt}()nnt// Now we wait for each result to come inntfmt.Println(<-out)ntfmt.Println(<-out)ntfmt.Println(<-out)ntfmt.Println(<-out)n}nnfunc multiplyByTwo(in <-chan int, out chan<- int) {ntfmt.Println("Initializing goroutine...")ntfor {nttnum := <-innttresult := num * 2nttout <- resultnt}n}

现在,除了main,multiplyByTwo也可以分成2部分:我们在in通道上等待的点之前和之后的部分(num := <- in)

Go (Golang) 中的 Channels 简介

产生的worker数对应于并发进程数。

在上面的例子中,我们产生了三个worker,并且有四个任务。前三个任务将立即分配一个worker,但第四个任务必须等到其中一个worker完成。

Go (Golang) 中的 Channels 简介

即使task4准备好了,它也需要等到至少一个worker空闲。

“Select”声明

当我们有多个通道等待接收信息时,我们可以使用该select语句,并且希望在其中任何一个通道首先完成时执行一个动作。

select {ncase res := <-someChannel:nt// do somethingncase anotherChannel <- someData:nt// do something elsencase <- yetAnotherChannel:nt// do another thingn}

在这里,执行的操作取决于哪个案例首先完成 - 其他案例将被忽略。

让我们看一个例子,我们有一个快速的方法和一个慢的方法进行乘法:

// The `fast` and `slow` functions do the same thingn// but `slow` takes more time to completenfunc fast(num int, out chan<- int) {ntresult := num * 2nttime.Sleep(5 * time.Millisecond)ntout <- resultnn}nnfunc slow(num int, out chan<- int) {ntresult := num * 2nttime.Sleep(15 * time.Millisecond)ntout <- resultn}nnfunc main() {ntout1 := make(chan int)ntout2 := make(chan int)nnt// we start both fast and slow in differentnt// goroutines with different channelsntgo fast(2, out1)ntgo slow(3, out2)nnt// perform some action depending on which channelnt// receives information firstntselect {ntcase res := <-out1:nttfmt.Println("fast finished first, result:", res)ntcase res := <-out2:nttfmt.Println("slow finished first, result:", res)nt}nn}

如果我们运行这段代码,我们将得到输出:

fast finished first, result: 4

该select语句由caseout1中指定的操作触发并忽略out2:

Go (Golang) 中的 Channels 简介

select 语句的一个常见用例是检测何时需要取消操作- 如果我们正在执行对时间敏感的操作,理想情况下我们希望保持最后期限并在花费太长时间时停止操作。

缓冲通道(Buffered Channels)

在前面的几个示例中,我们看到通道语句阻塞,直到数据发送到通道或从通道接收。

发生这种情况是因为通道没有任何地方可以“存储”进入其中的数据,因此需要等待语句接收数据。

缓冲通道是一种在其中具有存储容量的通道。为了创建缓冲通道,我们在make语句中添加第二个参数来指定容量:

out := make(chan int, 3)

现在out是一个容量为三个整数变量的缓冲通道。这意味着它在阻塞之前最多可以接收三个值:

package mainnnimport "fmt"nnfunc main() {ntout := make(chan int, 3)ntout <- 1ntout <- 2ntout <- 3nnt// this statement will blockntout <- 4n}

您可以将缓冲通道视为普通通道加上存储(或缓冲区):

Go (Golang) 中的 Channels 简介

缓冲通道用于在没有可用接收器时我们不希望通道语句阻塞的情况。添加缓冲区允许我们等待一些接收者被释放,而不会阻塞发送代码。

注意事项和陷阱

通道使 Go 中的并发编程变得更加容易,并使您的代码在某些情况下更具可读性。

但是,在您不需要的地方使用频道很容易。有时,使用指针和等待组来传递信息更容易。

与所有并发编程一样,避免竞争条件很重要,因为这会产生难以预测的错误。

如有疑问,最好在编写代码之前可视化 goroutine 之间的数据流动方式(就像我在这里的一些图表中展示的那样)。