通道基本使用方式

TrumanWong
9/21/2024
TrumanWong

通道基本使用方式

通道是Go语言中的一等公民,其操作方式比较简单和形象。如下所示,以箭头<-作为操作符进行通道的读取和写入。

ch<- number
<-ch

通道声明与初始化

chan作为Go语言中的类型,其最基本的声明方式如下:

var name chan T

其中,name代表chan的名字,为用户自定义的;chan T代表通道的类型,T代表通道中的元素类型。在声明时,channel必须与一个实际的类型T绑定在一起,代表通道中能够读取和传递的元素类型。通道的表示形式有如下有三种:chan Tchan<- T<-chan T

具体来说,不带<-的通道可读可写,而带<-的类型限制了通道的读写。例如,chan<- float代表该通道只能写入浮点数,<-chan string代表该通道只能读取字符串:

chan int
chan<- float
<-chan string

一个还未初始化的通道会被预置为nil,这一点可以通过简单打印得出:

var ch chan int
fmt.Println(ch)

比较有意思的一点是,一个未初始化的通道在编译时和运行时并不会报错,不过,显然无法向通道中写入或读取任何数据。要对通道进行操作,需要使用make初始化通道,在内存中分配通道的空间:

var ch = make(chan int)

通道写入数据

可以通过如下简单的方式向通道中写入数据:

ch<- 5

对于无缓冲通道,能够向通道写入数据的前提是必须有另一个协程在读取通道。否则,当前的协程会陷入休眠状态,直到能够向通道中成功写入数据。

无缓冲通道的读与写应该位于不同的协程中,否则,程序将陷入死锁的状态,如下所示:

func main() {
    ch := make(chan int)
    ch<- 5
    fmt.Println(<-ch)
}

这段代码将进入死锁状态,因为最后一行的<-ch永远无法执行,运行报错:

fatal error: all goroutines are asleep - deadlock!

通道读取数据

通道中读取数据可以直接使用<-ch,和写入数据一样,如果不能直接读取通道的数据,那么当前的读取协程将陷入堵塞,直到有协程写入通道为止。读取通道也可以有返回值,如下代码接收通道中的数据并赋值给data

data := <-ch

读取通道还有两个返回值的形式,借助编译时将该形式转换为不同的处理函数。第1个返回值仍然为通道读取到的数据,第2个返回值为布尔类型,返回值为false代表当前通道已经关闭:

data, ok := <-ch

通道关闭

通过内置的close函数可以关闭通道:

close(ch)

在正常读取的情况下,通道返回的oktrue。通道在关闭时仍然会返回,但是data为其类型的零值,ok也变为了false和通道读取不同的是,不能向已经关闭的通道中写入数据

    ch := make(chan int)
    close(ch)
    ch<- 5 // panic: send on closed channel

通道关闭会通知所有正在读取通道的协程,相当于向所有读取协程中都写入了数据。

如下所示,有两个协程正在等待通道中的数据,当main协程关闭通道后,两个协程都会收到通知:

func main() {
    ch := make(chan int)
    go func() {
        data, ok := <-ch
        fmt.Println("goroutine one:", data, ok)
    }()

    go func() {
        data, ok := <-ch
        fmt.Println("goroutine two:", data, ok)
    }()

    close(ch)
    time.Sleep(time.Second)
}

程序结果打印如下,可以看出两个协程都读取到了结果,但是结果都是零值:

goroutine two: 0 false
goroutine one: 0 false

要注意的是,如果读取通道是一个循环操作,那么下例会出现一个大问题——关闭通道并不能终止循环,依然会收到一个永无休止的零值序列:

go func() {
    for {
        data, ok := <-ch
        fmt.Println("goroutine one:", data, ok)
    }
}()

循环打印出:

goroutine one: 0 false
goroutine two: 0 false
goroutine one: 0 false
...

因此,在实践中会通过第二个返回的布尔值来判断通道是否已经关闭,如果已经关闭,那么退出循环是一种比较常见的操作:

go func() {
    for {
        data, ok := <-ch
        if !ok {
            break
        }
    }
}()
试图重复关闭一个channel将导致panic异常,试图关闭一个nil值的channel也将导致panic异常。
func main() {
    ch := make(chan int)
    close(ch)
    close(ch) // panic: close of closed channel
}

在实践中,并不需要关心是否所有的通道都已关闭,当通道没有被引用时将被Go语言的垃圾自动回收器回收。关闭通道会触发所有通道读取操作被唤醒的特性,被使用在了很多重要的场景中,例如一个协程退出后,其创建的一系列子协程能够快速退出的场景。

通道作为参数和返回值

通道作为一等公民,可以作为参数和返回值。通道是协程之间交流的方式,不管是将数据读取还是写入通道,都需要将代表通道的变量通过函数传递到所在的协程中去。如下所示,代表协程执行的worker函数以通道chan int作为参数。

func worker(id int, ch chan int) {
    for val := range ch {
        fmt.Printf("worker %d received %c\n", id, val)
    }
}

通道作为返回值一般用于创建通道的阶段,下例中的createWorker函数创建了通道ch,并新建了一个worker协程,最后返回的通道可能继续传递给其他的消费者使用:

func createWorker(id int) chan int {
    ch := make(chan int)
    go worker(id, ch)
    return ch
}

由于通道是Go语言中的引用类型而不是值类型,因此传递到其他协程中的通道,实际引用了同一个通道。

单方向通道

一般来说,一个协程在大多数情况下只会读取或者写入通道,为了表达这种语义并防止通道被误用,Go语言的类型系统提供了单方向的通道类型。例如chan<-float代表该通道只能写入而不能读取浮点数,<-chan string代表该通道只能读取而不能写入字符串。

上例中worker函数的作用主要是读取通道的信息,因此可以将其函数改写如下,而不影响其任何功能:

func worker(id int, ch<-chan int)

普通的通道具有读和写的功能,普通的通道类型能够隐式地转换为单通道的类型

ch := make(chan int)
worker(id, ch)

反之,单通道的类型不能转换为普通的通道类型:

ch1 := make(chan<- int)
ch2 := make(chan int)
ch2 = ch1

如下试图将单通道类型赋值给普通通道类型,编译时报错为:

cannot use ch1 (variable of type chan<- int) as chan int value in assignment

试图在只能写入的通道中读取数据,编译时报错为:

ch := make(chan<- int)
fmt.Printf(<-ch)
// invalid operation: cannot receive from send-only channel ch (variable of type chan<- int)