[译] part 22: golang channels

857 阅读10分钟

在上一个教程中,我们讨论了 Go 中如何使用Goroutines实现并发。在本教程中,我们将讨论有关channel以及Goroutines如何使用channel进行通信。

什么是channel

channel可以被认为是Goroutines通信的管道。类似于水在管道中从一端流到另一端的方式,数据可以从一端发送,可以从另一端接收。

channel的声明

每个channel都有一个与之关联的类型。此类型是允许channel传输的数据类型。不允许使用该channel传输其他类型。

chan T 代表类型为Tchannel

channel的零值为nilnil channel没有任何用处,因此得使用类似于make mapmake slice来定义它。

让我们写一些声明channel的代码。

package main

import "fmt"

func main() {
    var a chan int
    if a == nil {
        fmt.Println("channel a is nil, going to define it")
        a = make(chan int)
        fmt.Printf("Type of a is %T", a)
    }
}

Run in playground

在第 6 行声明了var a chan int,可以看到channel的零值为nil。因此,执行if条件内的语句并定义channel。上面的程序中的a是一个int channel。该程序将输出,

channel a is nil, going to define it
Type of a is chan int

使用make声明也是定义channel的有效而简洁的方法。

a := make(chan int)

上面的代码行定义了一个int型的channel a

channel的发送和接收

下面给出了从channel发送和接收数据的语法,

data := <- a // read from channel a
a <- data // write to channel a

箭头相对于通道的方向指定了是发送还是接收数据。

在第 1 行中,箭头从a向指向data,因此我们从通道a读取并将值存储到变量data中。

在第 2 行中,箭头指向a,因此我们把data写入通道a

发送和接收默认是阻塞的

默认情况下,发送和接收是阻塞的。这是什么意思?当数据发送到channel时,发送方被阻塞直到其他Goroutine从该channel读取出数据。类似地,当从channel读取数据时,读取方被阻塞,直到其他Goroutine将数据写入该channel

channel的这种属性有助于Goroutines有效地进行通信,而无需使用在其他编程语言中常见的显式锁或条件变量。

channel示例代码

让我们编写一个程序来了解Goroutines如何使用channel进行通信。

我们在上一篇教程中引用过这个程序。

package main

import (
    "fmt"
    "time"
)

func hello() {
    fmt.Println("Hello world goroutine")
}
func main() {
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}

Run in playgroud 这是上一个教程的代码,这里我们将使用channel重写上述程序。

package main

import (
    "fmt"
)

func hello(done chan bool) {
    fmt.Println("Hello world goroutine")
    done <- true
}
func main() {
    done := make(chan bool)
    go hello(done)
    <-done
    fmt.Println("main function")
}

Run in playgroud

在上面的程序中,我们在第一行创建了一个bool型的done channel。 并将其作为参数传递给hello。第 14 行我们正在从done channel接收数据。这行代码是阻塞的,这意味着在Goroutine将数据写入done channel之前将会一直阻塞。因此,上一个程序中的time.Sleep的就没有必要了,用sleep对程序而言是相当不友好。

代码行<-done表示从done channel接收数据,如果没有任何变量使用或存储该数据,这是完全合法的。

现在我们的main Goroutine被阻塞直到done channel有数据写入。 hello Goroutine接收done channel作为参数,打印Hello world goroutine然后把true写入done channel。当这个写入完成时,main Goroutine从该done channel接收数据,然后结束阻塞打印了main函数的文本。

输出,

Hello world goroutine
main function

让我们通过在hello Goroutine中引入一个sleep来修改这个程序,以更好地理解这个阻塞概念。

package main

import (
    "fmt"
    "time"
)

func hello(done chan bool) {
    fmt.Println("hello go routine is going to sleep")
    time.Sleep(4 * time.Second)
    fmt.Println("hello go routine awake and going to write to done")
    done <- true
}
func main() {
    done := make(chan bool)
    fmt.Println("Main going to call hello go goroutine")
    go hello(done)
    <-done
    fmt.Println("Main received data")
}

Run in playgroud

这个程序将首先打印Main going to call hello go goroutine。然后hello Goroutine启动,打印hello go routine is going to sleep。打印完成后,hello Goroutine将休眠 4 秒钟,在此期间main Goroutine将被阻塞,因为它正在等待来自<-done的通道的数据。 4 秒后hello Goroutine苏醒,然后打印hello go routine awake and going to write to done并写入数据到channel,接着main Goroutine接收数据并打印Main received data

channel 的另外一个例子

让我们再写一个程序来更好地理解,该程序将打印数字各个位的平方和立方的总和。

例如,如果 123 是输入,则此程序将计算输出为

squares = (1 * 1) + (2 * 2) + (3 * 3) cubes = (1 * 1 * 1) + (2 * 2 * 2) + (3 * 3 * 3) output = squares + cubes = 50

我们将构建该程序,使得平方在一个Goroutine中计算,而立方在另一个Goroutine中进行计算,最终在main Goroutine中求和。

package main

import (
    "fmt"
)

func calcSquares(number int, squareop chan int) {
    sum := 0
    for number != 0 {
        digit := number % 10
        sum += digit * digit
        number /= 10
    }
    squareop <- sum
}

func calcCubes(number int, cubeop chan int) {
    sum := 0
    for number != 0 {
        digit := number % 10
        sum += digit * digit * digit
        number /= 10
    }
    cubeop <- sum
}

func main() {
    number := 589
    sqrch := make(chan int)
    cubech := make(chan int)
    go calcSquares(number, sqrch)
    go calcCubes(number, cubech)
    squares, cubes := <-sqrch, <-cubech
    fmt.Println("Final output", squares + cubes)
}

Run in playgroud

calcSquares函数计算各个数字的平方的和,并将其发送到squares channel。类似地,calcCubes计算各个数字的立方的和并将其发送到cubes channel

这两个函数都作为单独的Goroutines运行。每个函数都通过一个channel作为入参。main Goroutine等待来自这两个channel的数据。一旦从两个channel接收到数据,它们就存储在squarescubes中求和,然后打印最终输出。该程序将打印,

Final output 1536

死锁

使用channel时要考虑的一个重要因素是死锁。如果Goroutine正在channel上发送数据,那么期待其他一些Goroutine接收数据。如果发送的数据没有被消费,程序将在运行时产生一个panic

同样,如果Goroutine正在等待从一个channel接收数据,那么其他Goroutine应该在该channel上写入数据,否则程序也会出现panic

package main


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

Run in playgroud

在上面的程序中,创建了一个channel ch,我们用ch <-5channel发送 5。在该程序中,没有其他Goroutinech接收数据。因此,此程序将出现以下运行时错误。

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
    /tmp/sandbox249677995/main.go:6 +0x80

单向channel

到目前为止我们讨论的所有channel都是双向channel,即数据可以在它们上发送和接收。也可以创建单向channel,即仅发送或接收数据的channel

package main

import "fmt"

func sendData(sendch chan<- int) {
    sendch <- 10
}

func main() {
    sendch := make(chan<- int)
    go sendData(sendch)
    fmt.Println(<-sendch)
}

Run in playgroud

在上面的程序中,我们在第 10 行中创建了仅发送channel sendchchan < - int表示当箭头指向chan时仅为发送channel。我们在第 12 行中尝试从该channel接收数据。 发现这是不允许的,当程序编译时,编译器会报错,

main.go:11: invalid operation: <-sendch (receive from send-only type chan<- int)

看起来好像没啥问题,但是一个写channel仅仅用来写,而不能用来读这样有啥意义!

我们接下来将用到channel转化。可以将双向channel转换为仅发送或仅接收的channel,反之亦然。

package main

import "fmt"

func sendData(sendch chan<- int) {
    sendch <- 10
}

func main() {
    chnl := make(chan int)
    go sendData(chnl)
    fmt.Println(<-chnl)
}

Run in playgroud

在上面的程序第 10 行,创建了双向channel chnl。在第 11 行,它作为参数传递给sendData Goroutine,而sendData函数在第 5 行用sendch chan < - int将此chnl转换为仅发送的channel类型。所以现在通道只在sendData Goroutine中是单向的,但它在main Goroutine中是双向的。该程序将打印 10 作为输出。(译者注:这就是单向channel的用途,定义函数或者方法的时候,使用只读或只写会让代码更健壮。)

关闭channel和循环channel

发送者能够关闭channel以通知接收者不再在该channel上发送数据。

接收者可以在从channel接收数据时使用额外的变量来检查channel是否已关闭。

v, ok := <- ch

在上面的语句中,如果成功地从该操作中接收到该值,则oktrue。如果okfalse,则表示我们正在从一个关闭的channel中读取。从关闭的channel中读取的值将是通道类型的零值。例如,如果是int类型,则从关闭的channel中读取到的值将为 0。

package main

import (
    "fmt"
)

func producer(chnl chan int) {
    for i := 0; i < 10; i++ {
        chnl <- i
    }
    close(chnl)
}
func main() {
    ch := make(chan int)
    go producer(ch)
    for {
        v, ok := <-ch
        if ok == false {
            break
        }
        fmt.Println("Received ", v, ok)
    }
}

Run in playgroud

在上面的程序中,生产者Goroutine将 0 到 9 写入channel chnl,然后关闭它。在第 16 行main函数有一个无限for循环,它使变量ok检查channel是否被关闭。如果okfalse,则表示已关闭,因此循环中断。否则,将打印收到的值和ok的值。这个程序将打印,

Received  0 true
Received  1 true
Received  2 true
Received  3 true
Received  4 true
Received  5 true
Received  6 true
Received  7 true
Received  8 true
Received  9 true

for 循环的for range形式可用于从channel接收值,直到它被关闭。

让我们使用for range循环重写上面的程序。

package main

import (
    "fmt"
)

func producer(chnl chan int) {
    for i := 0; i < 10; i++ {
        chnl <- i
    }
    close(chnl)
}
func main() {
    ch := make(chan int)
    go producer(ch)
    for v := range ch {
        fmt.Println("Received ",v)
    }
}

Run in playgroud

for range循环在第 16 行接收来自channel ch的数据直到它被关闭。 ch关闭后,循环自动退出。该程序输出,

Received  0
Received  1
Received  2
Received  3
Received  4
Received  5
Received  6
Received  7
Received  8
Received  9

我们来重写一下上面那个求平方立方和的程序,

如果仔细查看程序,可以注意到在calcSquares函数和calcCubes函数中获取每一位的数字的逻辑重复了。我们将该逻辑的代码抽出来,然后分别在那两个函数中并发调用这个函数。

package main

import (
    "fmt"
)

func digits(number int, dchnl chan int) {
    for number != 0 {
        digit := number % 10
        dchnl <- digit
        number /= 10
    }
    close(dchnl)
}
func calcSquares(number int, squareop chan int) {
    sum := 0
    dch := make(chan int)
    go digits(number, dch)
    for digit := range dch {
        sum += digit * digit
    }
    squareop <- sum
}

func calcCubes(number int, cubeop chan int) {
    sum := 0
    dch := make(chan int)
    go digits(number, dch)
    for digit := range dch {
        sum += digit * digit * digit
    }
    cubeop <- sum
}

func main() {
    number := 589
    sqrch := make(chan int)
    cubech := make(chan int)
    go calcSquares(number, sqrch)
    go calcCubes(number, cubech)
    squares, cubes := <-sqrch, <-cubech
    fmt.Println("Final output", squares+cubes)
}

Run in playgroud

上面程序中的digits函数现在包含从number中获取各位的逻辑,并且它同时由calcSquarescalcCubes函数调用。一旦number中没有更多的位,channel就会在第 13 行被关闭。 calcSquarescalcCubes Goroutines使用for range循环监听各自的channel,直到它关闭。该程序的其余部分和之前的例子是相同的。该程序也会打印

Final output 1536

该节教程就结束了,channel中还有更多的概念,例如缓冲channelworker poolselect。我们将在下一个教程中讨论它们。