Go 采用了 CSP 的并发模型。它的基础是 channel 和 goroutine。channel 是 Go 语言的一种数据类型,是传递消息的载体,使用起来十分简单,是并发安全的;而 goroutine 是并发执行的实体。

本文为以后讲「Go的并发模型」打个基础,讲解 channel 是如何使用的。

基本使用

我们可以将通道认为是队列(先进先出),队列的长度就是通道容纳消息的容量。
消息的类型可以是任意的,它需要在我们初始化或声明通道变量的时候指定。

通道的关键字是chan,通道变量的类型是chan <type>,属于 引用类型

我们可以通过下面的方式得到通道变量:

1
2
3
4
5
// 声明通道,chan0是一个int类型的通道,初始值为nil
var chan0 chan int

// 通过make初始化通道,make的第二个参数是通道的容量
chan1 := make(chan int, 5)

我们使用操作符<-对通道进行操作,它可以放在通道的左边或右边,分别代表从通道获取消息将消息放入通道。下面是具体例子:

1
2
3
4
5
6
7
8
chan1 := make(chan int, 5)

// 将值1和2分别放入通道中,按照队列的顺序,通道将存放[1, 2]
chan1 <- 1
chan1 <- 2

// 从通道中获取消息,此时elem1的值将是1。
elem1 := <-chan1

对于已经不再使用的通道,我们要养成主动关闭的习惯,接收方可以通过关闭的动作感知到通道的状态,而做出处理。

关闭通道使用close方法:

1
close(chan1)

对于通道的容量,有下面的特性:

  • 当通道的容量已满时,发送操作会被阻塞,直到有空位为止;
  • 当通道为空时,接收操作会被阻塞,直到有新的元素到来。

通道特性

我们现在知道了通道是如何使用的,包括声明,赋值取值和关闭。

通道存放的消息,其实都是元素的副本或者是元素引用的副本(浅复制),而不是原始元素。所以在元素进入通道时,通道是做了「复制」操作的。

还记得我们在最开始的时候说过,通道是「并发安全」的,使用简单,是因为通道本身的特性为我们做了保证:

  1. 阻塞,对于发送操作和接收操作,在操作没有完成之前,代码会被阻塞,不会进行后续操作;
  2. 同一个通道的发送操作是互斥的,接收操作也是互斥的,原因是为了保证顺序的一致,先到达的操作肯定会先完成;
  3. 单个操作是原子性的。前面提到,元素进出通道时会触发复制操作,以接收操作为例,操作包含了赋值给新变量、删除通道对应的元素,这两步形成的操作是原子性的。

通道的特性保证了它的简单性,使用者可以省去许多开发上的精力。

非缓冲通道

当我们使用make创建一个通道时,不指定容量,或者容量为0,那么该通道是不带缓冲的。

对于非缓冲通道的使用,它的接收操作和发送操作,执行时便会阻塞,只有两者同时存在时,操作才会进行。也就是说,收和发必须同时存在,否则便会阻塞。

单向通道

通道可以是单向的,在声明或初始化通道时,我们可以指定通道是send-only还是receievd-only,只需要在关键字chan的左边或右边加上<-即可。

1
2
3
4
5
// chan1 是一个send-only通道,只进不出
chan1 := make(chan<- int, 5)

// chan2 是一个received-only通道,只出不进
chan2 := make(<-chan int, 5)

单向通道可以起到约束作用,比如作为方法的参数,它可以限制方法对通道的使用。

比如,下面的方法,它限制了变量 ch 只能接收,不能发送

1
2
3
func receivedOnlyChannel(ch <-chan int) {
fmt.Println(<-ch)
}

然后我们可以向 receivedOnlyChannel 传入通道变量。

1
2
3
4
chan3 := make(chan int, 5)
chan3 <- 1

receivedOnlyChannel(chan3)

注意Panic

如果对通道使用不当,会造成panic发生。

  • 当通道关闭后,再次对通道进行关闭,会引发panic;
  • 当通道关闭后,对其进行发送操作,会引发panic;
  • 当我们只有一个goroutine,而通道发生了阻塞,会引发panic:all goroutines are asleep,程序死锁。

使用range获取通道元素

像我们在遍历其他容器一样,我们可以使用关键字range,对通道的元素进行顺序接收:

1
2
3
4
5
6
7
8
chan1 := make(chan int, 5)
chan1 <- 1
chan1 <- 2
chan1 <- 3
close(chan1)
for elem := range chan1 {
fmt.Println(elem)
}

我们需要特别注意第5行的close操作,如果我们只有一个goroutine时,不关闭chan1将会造成for循环的阻塞,导致panic发生。

select语句

针对通道的接收,Go 语言提供了select语句,它的使用类似于switch语句,不同的是,它接收的是通道参数。先看下面一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
chan1 := make(chan int, 1)

chan2 := make(chan int, 1)
chan2 <- 1

var chan3 chan int

select {
case <-chan1:
t.Log("receive from chan1")
case elem := <-chan2:
t.Log("receive from chan2, elem:", elem)
case <-chan3:
t.Log("receive from chan3")
default:
t.Log("default case")
}

我们定义了3个通道:chan1、chan2、chan3,其中只有chan2是有元素的,chan1没有元素存在,而chan3只是声明而没有初始化,是个nil值。

对于select来说,它会选择一个满足条件的分支进行执行,分支的case表达式是顺序求值的,那么会出现下面的情况:

  1. 对所有case表达式按顺序执行求值操作,如果当前case是阻塞的,则认为是不满足条件;
  2. 假设只有一个case满足条件,则执行当前case分支,如果不止一个case表达式满足条件,则采用伪随机算法选择其中一个执行;
  3. 当所有case表达式都不满足条件时:
    1. 如果存在default分支,则执行default分支;
    2. 否则,select语句被阻塞,直到有任意一个分支满足为止。

当我们需要对通道不断进行获取时,可以将for语句与select语句搭配使用,但需要注意的是,我们需要通过接收通道时的第二个参数,主动感知通道是否已关闭,来做出相应的动作,让我们的程序逻辑更加合理。

总结

本文是对 Go 语言通道 - Channel 的使用说明,包括如何初始化和使用、通道的特性、使用注意事项,以及如何搭配rangeselect使用,其中的 demo 可以在 此处 获得。

如果有错误或补充,欢迎留言指正。