信道简单示例

TrumanWong
9/24/2024
TrumanWong

示例一

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

当我们运行上面的程序时,会输出如下错误信息:

fatal error: all goroutines are asleep - deadlock!

信道可以在协程之间进行消息传递,当ch <- 1往信道ch中写入一个数据1时,它会让所在的协程进入阻塞状态,等待其他协程从信道ch中读取数据,因此n := <-ch以及后续代码将无法得到执行的机会。也就是说,在信道未关闭的情况下,从信道读取超时会引发deadlock异常

信道至少涉及2个协程,上面示例程序中只有一个主协程,因而会引发deadlock异常。

示例二

正如前面提到的,信道至少涉及2个携程,因此,我们对示例一稍作修改,让它再开启一个协程。

func main() {
    ch := make(chan int)
    go func() {
        ch <- 1
    }()
    n := <-ch
    // fmt.Println(<-ch)
    fmt.Println(n)
}

当主协程运行到n := <-ch语句时,<-ch会让主协程进入阻塞状态,等待其他协程往信道ch中写入数据后再进行读取。这就保证了第03~04行开启的子协程一定执行完成。

如果将示例二程序中// fmt.Println(<-ch)代码的注释取消掉,那么也会导致主协程再次进入阻塞后超时,由于程序没有任何一个协程再往信道ch中写入值,无法再次读取到值,因此会抛出deadlock错误。

示例三

信道可以用close来关闭,如果在往信道写入值后手动调用close来关闭信道,这样其他协程即使在信道中没有写入值,协程也可以继续从信道中读取到值(零值):

func main() {
    ch := make(chan int)
    go func() {
        ch <- 1
        close(ch)
    }()
    n := <-ch
    fmt.Println(n)
    fmt.Println(<-ch)
}

上面代码中,注意第05close(ch)关闭了信道ch。关闭后,主协程可继续从ch中读取到值,运行到fmt.Println(<-ch)时并未抛出deadlock错误,而是打印出值0。这就说明协程可以继续从关闭的信道中读取到值,即使信道中没有写入数据。换句话说,当一个协程从一个没有数据的信道中读取数据且这个信道未关闭时,就会进入阻塞状态,超时后抛出deadlock错误

上述示例有一个潜在的问题,就是无法区分fmt.Println(<-ch)从信道读取到的0是写入信道的值还是int类型对应的零值,因为二者都是0

示例四

为了区分从已经关闭的信道中读取到的值是真正的业务数据还是对应的零值,需要用到n, ok := <-ch这种比较优雅的读取方式,如果信道中有值,那么ok返回true,否则返回false

func main() {
    ch := make(chan int)
    go func() {
        ch <- 0
        ch <- 1
        close(ch)
    }()
    for {
        if n, ok := <-ch; ok {
            fmt.Println(n)
        } else {
            break
        }
    }
    fmt.Println("end")
}

在上述实例中,第04行和第05行分别往信道ch中写入数据01。然后用for循环从信道ch中读取值并打印,如果信道已经关闭,则用break跳出for循环。运行程序可以看到依次输出值07。在用close关闭信道ch后,n, ok := <-ch可以通过ok来排除读取到的非业务值(零值)。

示例五 for...range遍历信道

除了用for无限循环来遍历信道中的值外,更优雅的方式是通过for...range来遍历信道:

func main() {
    ch := make(chan int)
    go func() {
        for i := 0; i < 10; i++ {
            ch <- i
        }
        close(ch)
    }()

    for n := range ch {
        fmt.Println(n)
    }
}

在上述示例中,用for循环依次往信道ch中写入0~9。第7close(ch)关闭信道ch。然后利用for n := range ch语句可以方便快捷地对信道ch中的数据进行遍历和读取,且不会读取关闭之后的零值。

使用for…range循环遍历信道时,如果信道未关闭就会引发deadlock错误。换句话说,for…range循环遍历信道,必须关闭信道

示例六 只读和只写通道

信道定义时有只读信道、只写信道以及双向信道之分。一般来说,将一个信道定义为只读或只写是没有意义的。只读信道或者只写信道一般只用于函数参数中,用于对参数进行约束,从而提高了稳定性。

一般来说,通过信道在不同的协程间进行数据传递,会涉及一个数据发送方和数据接收方,其中数据发送方本质上是一个函数,那么它只需要往信道中写入数据即可,而不用读取数据;其中的数据接收方本质上也是一个函数,那么它只需要从信道中读取数据即可,而不用写入数据。

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

func read(ch <-chan int) {
    for {
        n := <-ch
        fmt.Println(n)
    }
}

func main() {
    ch := make(chan int)
    go write(ch)
    go read(ch)
    var input string
    fmt.Scanf("please input %s", input)
}

在上述示例中,函数write的参数是ch chan<- int类型,表示一个只写的chan int类型信道,在函数体中,只可以对信道ch写入int类型的数据,而不能写入其他类型的数据或者读取数据。。

函数read的参数是<-chan int类型,表示一个只读的chan int类型信道,在函数体中只可以从信道ch中读取int类型的数据,而不能写入类型。

不能关闭一个只读的信道

write函数体中没有调用close(ch)关闭信道。如果关闭了信道,由于接收信道值的是for无限循环,就会读取到零值0,因此上述程序会一直循环,无法退出

一般来说,如果有明确数量的数据写入信道(有限循环),那么可以在写完后用close关闭信道,注意要用for...range循环读取信道的数据。

如果没有明确数量的数据写入信道(无限循环),也就无法用close关闭信道了。没有close关闭的信道不能用for...range循环来读取,只能用for{}无限循环来读取信道的数据。也就是说,发送方写多少,接收方就读取多少。

for{}无限循环读取信道的数据时,最好有退出的条件,这样可以防止超时或者在异常的时候进行优雅的处理。这就需要用到select关键字。

select实现多路监听多个信道

select语块是为信道特殊设计的,它和switch语法非常相近,都可以有多个case块和一个default块,但是它们之间也有很多不同。在select语言块中,所有的case语句要么执行信道的写入操作,要么执行信道的读取操作。

select关键字和符号{之间不得有任何表达式。select语句块里面的case语句是随机执行的,而不能是顺序执行的

fallthrough关键字不能用在select语句块里面。

select语句块本身不带有循环监听机制,需要通过外层for来启用循环监听模式。在监听case分支中,如果没有满足监听条件则进入阻塞模式。如果有多个满足条件分支则任选一个分支执行。另外,可以用default分支来处理所有case分支都不满足条件的情况。

func write1(ch chan<- int) {
    time.Sleep(time.Second)
    ch <- 1
}

func write2(ch chan<- int) {
    time.Sleep(2 * time.Second)
    ch <- 2
}

func main() {
    ch1, ch2 := make(chan int), make(chan int)
    go write1(ch1)
    go write1(ch2)

    for {
        select {
        case n := <-ch1:
            fmt.Println(n)
        case n := <-ch2:
            fmt.Println(n)
        case <-time.After(time.Second):
            fmt.Println("timeout")
            goto EXIT
        }
    }
EXIT:
    var input string
    fmt.Scanf("please input %s", input)
}

在上面示例中,定义了2个信道ch1ch2,分别用go write1(ch1)go write1(ch1)启动了2个协程。在write1write2两个函数体中,先调用time.Sleep函数休眠一段时后再往信道中写入数据。

然后用for配合select开启循环监听case分支,case <-time.After(time.Second)语句中的time.After函数返回一个信道,其类型为<-chan Time。这实际上是一个超时处理。如果超时,就需要跳出for循环,此处用goto EXIT来实现。如果用break,只能跳出select中的一个case选项,而不能跳出for循环