目录
- 并发与并行
- goroutine
- 通道
- 3.1 无缓冲通道
- 3.2 缓冲通道
- 3.3 单向通道
- 总结
1. 并发与并行
某个周末,你玩着游戏,突然到饭点了。于是,你点了个外卖。这时,你可能会吃两口饭再玩一会儿游戏,说明你支持并发。但是,团战很激烈,于是不得不一边吃饭一边玩游戏,说明你支持并行。
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果。
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。
并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力。
为了提高机器的性能和执行效率,支持并发是编程中一个重要的话题。Go 语言中支持两种并发的风格,一种叫 通信顺序进程(Communicating Sequential Process,CSP),一种是共享内存。CSP 是一个在不同的执行体(goroutine)之间传递值的并发模式,Go 语言中用通道(chan)来传递值。
2. goroutine
Go 语言中,每一个并发执行的活动称为 goroutine,一个程序里面至少有一个主 goroutine,那就是 main 函数执行的活动体。可以通过内置的 go 关键字,创建新的 goroutine:
func main() {
add(1, 2) // 调用 add 函数,等待它返回
go func(x, y int){ // 新建一个 goroutine 去调用函数,不用等待
sum := x + y
fmt.Println("sum is ", sum)
}(3, 4)
time.Sleep(time.Second) // 休眠一段时间,让 goroutine 的函数执行完
}
如上所示,当创建新的 goroutine 执行别的任务时,主函数需要休眠一段时间。因为当 main 函数执行完毕时,所有的 goroutine 都将直接终结,然后程序退出,go add(3, 4)
就很可能还未执行结束就停止了。
那么,我们怎么知道 go add(3, 4)
需要执行多久呢?注意,上面休眠的一秒钟是一个可变值,如果一秒钟内 goroutine 还是没有执行结束,这个程序就是不稳定的。如果时间分配过多了,那么执行的效率就没法保证。
所以,要是 goroutine 之间可以通信就好了。一个 goroutine 执行结束,就可以告知另一个,你可以继续执行了。
3. 通道
如果说 goroutine 是 Go 程序并发的执行体,那通道就是它们之间的连接。通道可以让一个 goroutine 发送特定值给另一个 goroutine。相当于在两个并发的 goroutine 中,通道充当了它们交流的媒介。
创建通道:
ch := make(chan int, 0) // 创建一个 int 类型的通道
通道由 元素类型
和 容量
组成,它们在创建的时候就需要初始化完成。每一个通道是一个具体类型传输的导管,叫做通道的元素类型,一个有 int 类型元素的通道写为 chan int。容量是通道中可包含的元素个数,当容量为 0 时,后面的数字 0 可以省略。
通道可以发送(send)或接收(receive)数据,两者执行的过程统称为 通信
。
x := <- ch // 接收
ch <- x // 发送
当发送完毕时,我们需要通过内置函数 close 将通道关闭。
close(ch)
此时,向通道发送数据会报错并引发 panic
宕机;但是从该通道接收数据不会报错,只会获取通道元素对应的零值。那么,我们如何保证从通道中获取的值是有效的而非对应的零值呢?
第一种方式,ok
语法:
func main() {
ch := make(chan int, 100)
go func() {
for i:=0; i<100; i++ {
ch <- i
}
close(ch) // 当通道关闭后,不能再往通道中发送数据
}()
for {
x, ok := <- ch
if !ok {
break // 通道关闭且数据已读完
}
fmt.Println(x)
}
}
第二种方式,Go 语言提供了 range 循环语法:
func main() {
ch := make(chan int, 100)
go func() {
for i:=0; i<100; i++ {
ch <- i
}
close(ch) // 当通道关闭后,不能再往通道中发送数据
}()
for v := range ch { // 通道关闭则退出循环
fmt.Println(v)
}
}
3.1 无缓冲通道
当通道的容量为 0 时,表示该通道是一个无缓冲通道。
ch := make(chan int) // 创建一个无缓冲通道
无缓冲通道上的发送操作将会阻塞,直到另一个 goroutine 在该通道上执行接收操作;同样地,如果接收操作先执行,接收方的 goroutine 将阻塞,直到另一个 goroutine 向该通道发送数据。
怎么理解这句话呢?比如,你喜欢隔壁班一个女孩子,已经准备表白。于是,你约她到老师办公室门口想给她一个惊喜。这时,你是信息的发送者,心仪的女孩子是数据接收者。如果你先到了,表白这个信息也只能阻塞,等到女孩子出现并等她准备好,才可以继续执行;同样地,女孩子先到,也会阻塞等待你发送信息。
使用无缓冲通道进行的通信,将导致发送和接收 goroutine 的 同步化
。因此,无缓冲通道也叫 同步通道
。通过同步通道,我们可以将上面的例子改写:
func main() {
add(1, 2)
done := make(chan int)
go func(x, y int){
sum := x + y
fmt.Println("sum is ", sum)
done <- 1 // 发送任意数据到通道
}(3, 4)
<- done // 从通道接收数据,并丢弃
}
通过阻塞可以严格地控制程序的执行顺序,并且不用分配额外的时间。当 goroutine 执行结束时,传入一个任意数值到通道中,解除主 goroutine 的阻塞。
注意,此程序中的通道数据并不是我们关心的地方,所以不需要用变量去接收,Go 语言允许我们直接丢弃通道里的数据 <- ch
。
3.2 缓冲通道
缓冲通道有一个元素队列,队列的最大长度是通道的容量,在创建时初始化:
ch := make(chan string, 3) // 创建队列大小为 3 的 int 通道
缓冲通道可以看作是一个固定容量的信箱。当信箱的容量是 3 时,最多能容纳 3 封信,多了就会阻塞。直到有人把它们取出来,每取出一封时,就可以再投递一封。
ch <- "A"
ch <- "B"
ch <- "C" // 通道已满
从中接收一个值(队列的特点:先进先出,FIFO):
fmt.Println(<- ch) // "A"
内置函数 cap 获取通道的容量,len 获取通道的长度:
fmt.Println(cap(ch)) // 3
fmt.Println(len(ch)) // 2
当通道的容量被占满时,再向通道发送数据会阻塞;同样地,从一个空的通道中接收数据也会阻塞(想象一下,信箱里没有信件时,就需要等待其他人寄信)。
3.3. 单向通道
未明确传输方向的通道本身是可以发送,也可以接收数据的。但有时候我们为了表现通道中数据的传输方向,会用到 单向通道
类型。
chan<- int
是一个只能发送的通道,它需要从外界接收数据,用作输入;<-chan int
是一个只能接收的 int 类型的通道,负责输出:
// 向通道中发送数据,完成后关闭通道
func counter(out chan<- int) {
for x:=0; x<100; x++ {
out <- x
}
close(out)
}
// 从通道中读取数据并打印
func printer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
func main() {
ch := make(chan int)
go counter(ch)
go printer(ch)
}
如上所示,我们定义了未声明传输方向的通道 ch
,分别创建两个新的 goroutine
去调用两个函数。当调用 counter
函数时,chan int
被隐式地转化为参数要求的 chan<- int
类型;同样地,printer
函数做了类型 <-chan int
的转变。
总之,在任何赋值操作中,将双向通道转换为单向通道都是被允许的,但反过来不行。
4. 总结
用 CSP 方式来实现并发是 Go 语言的一大特色,正如我们看到的那样,它可以很方便地在两个甚至多个 goroutine
中通信。而这样的并发风格是极为简便的,不需要复杂的语法即可实现。
作为一种类似于数据类型的通信体,通道要在不同的 goroutine
进行数据传输,所以它是一种引用类型,和 slice
、map
一样,在创建时必须用 make
语句进行初始化。
使用通道时,我们需要注意以下几点:
- 对 nil 值的通道,接收或发送消息都会阻塞;
- 对已关闭的通道,不能再次关闭,否则会像对该通道发送数据那样引发 panic;
- 不能关闭值为 nil 的通道,否则会引发 panic;
- 可以向已关闭的通道接收数据,不会引发错误,只会收到当前通道中的元素零值。
所以,在读 chan 时,需要判断 chan 是否已经关闭(用 ok-idiom
语法或 range
循环)
v, ok := <- ch
if !ok {
fmt.Println("chan has been closed! v is 0:%+v", v)
}
否则可能导致协程读取到非预期的零值消息,或者导致协程读消息的循环体死循环。