通道类型篇(一):基本语法和缓冲通道

上篇教程中,学院君给大家演示了如何通过通道(channel)传递消息实现 Go 协程间的通信, 接下来,我们将通过几篇教程的篇幅来系统了解通道类型及其使用,从而更好地理解 Go 并发编程及其实现,我们首先从通道基本语法说起。

通道声明和初始化

通过上篇教程,想必你已经了解了通道类型的基本使用,我们可以通过 chan 类型关键字来声明通道类型变量:

var ch chan int

上面这个表达式表示声明一个通道类型变量 ch,并且通道中只能传递 int 类型数据。

与其他数据类型不同,通道类型变量除了声明通道类型本身外,还要声明通道中传递数据的类型,比如这里我们指定这个数据类型为 int。前面学学院君介绍过,通道是类型相关的,我们必须在声明通道的时候同时指定通道中传递数据的类型,并且一个通道只能传递一种类型的数据,这一点和数组/切片类似。

此外,我们还可以通过如下方式声明通道数组、切片、字典,以下声明方式表示 chs 中的元素都是 chan int 类型的通道:

var chs [10]chan int
var chs []chan int
var chs map[string]chan int

不过,实际编码时,我们更多使用的是下面这种快捷方式同时声明和初始化通道类型:

ch := make(chan int)

由于在 Go 语言中,通道也是引用类型(和切片、字典一样),所以可以通过 make 函数进行初始化,在通过 make 函数初始化通道时,还可以传递第二个参数,表示通道的容量:

ch := make(chan int, 10)

第二个参数是可选的,用于指定通道最多可以缓存多少个元素,默认值是 0,此时通道可以被称作非缓冲通道,表示往通道中发送一个元素后,只有该元素被接收后才能存入下一个元素,与之相对的,当缓存值大于 0 时,通道可以称作缓冲通道,即使通道元素没有被接收,也可以继续往里面发送元素,直到超过缓冲值,显然设置这个缓冲值可以提高通道的操作效率。

需要注意的是,我们在上一篇教程中通过下面这种方式初始化的是切片,而不是通道:

chs := make([]chan int, 10)

只是切片中的元素类型是通道,这个时候第二个参数是切片的初始容量,而不是通道的。

通道操作符

通道类型变量只支持发送和接收操作,即往通道中写入数据和从通道中读取数据,对应的操作符都是 <-,我们判断是发送还是接收操作的依据是通道类型变量位于 <- 左侧还是右侧,位于左侧是发送操作,位于右侧是接收操作:

ch <- 1   // 往通道中写入数据 1
x := <- ch     // 从通道中读取数据并赋值给变量 

当我们将数据发送到通道时,发送的是数据的副本,同理,从通道中接收数据时,接收的也是数据的副本。

上篇教程我们已经介绍过,发送和接收操作都是原子操作,同时只能进行发送或接收操作,不存在数据发送一半被接收,或者接收一半发送新数据的情况,并且两者都是是阻塞的,如果通道中没有数据,进行读取操作的话会导致读取操作所在的协程阻塞,直到通道中写入了数据;反过来,如果通道中已经有了数据,再往里面写入数据的话,也会导致写入操作所在的协程阻塞,直到其中的数据被其他协程接收。

使用缓冲通道提升性能

当然,上面这种情况发生在非缓冲通道中,对于缓冲通道,情况略有不同,假设 ch 是通过 make(chan int, 10) 进行初始化的通道,则其缓冲区大小是 10,这意味着,在没有被任何其他协程接收的情况下,我们可以一直往 ch 通道中写入 10 个数据,超过 10 个数据才会阻塞当前协程,直到通道被其他协程读取,显然,合理设置缓冲区可以提高通道的操作效率,尤其是在需要持续传输大量数据的场景。

我们可以通过如下示例代码简单测试下通道的缓冲机制:

package main

import (
    "fmt"
    "time"
)

func test(ch chan int)  {
    for i := 0; i < 100; i++ {
        ch <- i
    }
    close(ch)
}

func main()  {
    start := time.Now()
    ch := make(chan int, 20)
    go test(ch)
    for i := range ch {
        fmt.Println("接收到的数据:", i)
    }
    end := time.Now()
    consume := end.Sub(start).Seconds()
    fmt.Println("程序执行耗时(s):", consume)
}

我们在主协程中初始化了一个带缓冲的通道,缓冲大小是 20,然后将其传递到子协程,并且在子协程中发送数据到通道,子协程执行完毕后,调用 close(ch) 显式关闭通道,这一行不能漏掉,否则主协程不知道子协程什么时候执行完毕,从一个空的通道接收数据会报如下运行时错误(死锁):

fatal error: all goroutines are asleep - deadlock!

关闭通道的操作只能执行一次,试图关闭已关闭的通道会引发 panic。此外,关闭通道的操作只能在发送数据的一方关闭,如果在接收一方关闭,会导致 panic,因为接收方不知道发送方什么时候执行完毕,向一个已经关闭的通道发送数据会导致 panic。

回到主协程,我们通过 i := range ch 循环从通道中读取数据,并将其打印出来。当通道关闭后会退出循环。我们对主协程执行时间做了统计,以对比不使用缓冲通道的耗时。

使用缓冲通道,程序执行耗时打印结果如下:

程序执行耗时(s): 0.000779079

然后我们将 ch 通道初始化语句调整为 ch := make(chan int),再次执行,程序耗时如下:

程序执行耗时(s): 0.001526328

显然,使用缓冲通道程序性能更好。

上一篇: Go 协程通信实现(下)—— 通过 channel 进行消息传递

下一篇: 通道类型篇(二):单向通道及其使用