go 语言中向一个已经关闭的channel发送数据会引起panic,因此go并发中一个基本的原则就是在数据发送端关闭channel。
虽然channel是双向的通道,两个go routine可以通过一个channel进行双向通信,但是在一般的数据流的模式下,我们宁可将channel降级为单向的通道,以获取更安全可读的代码。本文主要介绍一些将channel作为单向通信的并发模式,文末会简单给一个双向通信的例子。
生产-消费者
一个生产者对多个消费者的模式:
生产者是发送数据方,拥有关闭channel的权利,我们且称为拥有channel的所有权。生产者把channel作为output传递给消费者,消费者将生产者的output作为自己的input,读取并消费数据。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
out := numberGen()
var wg sync.WaitGroup
wg.Add(3)
go printNumber(1, out, &wg)
go printNumber(2, out, &wg)
go printNumber(3, out, &wg)
wg.Wait()
fmt.Println("will exit")
}
//生产go routine
func numberGen() <-chan int {
out := make(chan int)
go func() {
numbers := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
for _, num := range numbers {
out <- num
}
close(out)
}()
return out
}
//消费go routine
func printNumber(pid int, in <-chan int, wg *sync.WaitGroup) {
LOOP:
for {
select {
case num, ok := <-in:
if ok {
fmt.Printf("pid: %d, num: %d\n", pid, num)
time.Sleep(time.Second)
} else {
break LOOP
}
}
}
fmt.Printf("pid:%d, end\n", pid)
wg.Done()
}
1)WaitGroup是用于主routine等待子routine的,用途是阻塞主routine,等待子routine全部结束。
2)生产者numberGen返回的是一个只读的channel,约束了这个channel的用法。
3)消费者printNumber接受一个只读的channel作为自己的输入。
4)消费者需要使用 num, ok := <-in
这样的方式,用以处理通道被关闭的情况。
5)生产者返回的channel不必是同步的,也可以有一个缓冲大小。这样消费者在channel满之前上可以做到异步生产。
6)这个模式能保证数据按照生产的顺序被处理,但是不能保证按照生产的顺序被处理完成。
一个消费者对多个生产者的模式:
一个消费者对应多个生产者的情况略复杂一点,首先,生产者的数量不一定在编写的时候能确定下来,另外每一个生产者随时可能关闭通道,而关闭的通道在select语句中还是会被选择到,所以直接在消费者中写for-select行不通。
解决的办法是加一个合并器,将多个生产者的output合并成一个output。实现的思路是对于每一个生产者,使用一个接收器接收输出,然后再把输出合并到一个channel中。这个过程被称作“扇入”。
代码示例:
func fanIn(channels ...<-chan int) <-chan int {
var wg sync.WaitGroup
multiplexedStream := make(chan int)
multiplex := func(c <-chan int) {
defer wg.Done()
for {
select {
case obj, ok := <-c:
if !ok {
return
}
multiplexedStream <- obj
}
}
}
wg.Add(len(channels))
for _, c := range channels {
go multiplex(c)
}
go func() {
wg.Wait()
close(multiplexedStream)
}()
return multiplexedStream
}
fanIn代码引自《Concurrent in Go》Katherine Cox-Buday,略有修改
消费者的写法:
func main() {
output1 := numberGen()
output2 := numberGen()
output3 := numberGen()
//这里可以传入数组
output := fanIn(output1, output2, output3)
for {
select {
case num, ok := <-output:
if !ok {
return
}
fmt.Printf("main reveived: %d\n", num)
}
}
}
多个生成者和多个消费者的模式的做法就是把多个生产者一个消费者和一个生产者多个消费者串联起来。
代码示例:
//3个生产者4个消费者
func main() {
output1 := numberGen()
output2 := numberGen()
output3 := numberGen()
//这里可以传入数组
output := fanIn(output1, output2, output3)
var wg sync.WaitGroup
wg.Add(4)
for i := 0; i < 4; i++ {
go printNumber(i, output, &wg)
}
wg.Wait()
}
任务控制
上边的例子中,go routine的结束是由生产者决定的,所有生产者都关闭数据通道之后,消费者routine退出。在实际应用中,生产者routine未必是消费者routine的控制者,消费者routine的退出也未必一定要停止数据的生产。于是除了数据流的上下游关系,还需要引入控制流的上下游关系。
实现的方式就是在链路中传入额外的控制channel。最简单的例子就是传入一个channel,在关闭时跳出for循环:
func printNumberWithDone(done chan interface{}, gid int, in <-chan int, wg *sync.WaitGroup) {
LOOP:
for {
select {
case <-done:
break LOOP
case num, ok := <-in:
if ok {
fmt.Printf("gid: %d, num: %d\n", gid, num)
time.Sleep(time.Second)
} else {
break LOOP
}
}
}
fmt.Printf("gid:%d, end\n", gid)
wg.Done()
}
1)任何一个可能阻塞的routine启动,都应该有一个cancel机制。
2)一般cancel的所有者是root routine,如果生产者和消费者需要不同的控制,应该传入不同的控制channel。
3)控制关系的图和数据流的图不一定相同,常常是不相同。
4)如果需要更多控制,可以在控制通道中传入预先定义好的命令,routine中根据不同的命令改变行为。比如有一个if-else分支,如果控制通道收到1就执行if分支收到0就执行else分支。
context
context的基本用法可以查看官方文档,也可以看这篇博客:Go语言实战笔记(二十)| Go Context。
这里主要介绍为什么context比channel要好,先看一个简单的示例:
func main() {
ctx, cancelFunc := context.WithCancel(context.Background())
out := producer(ctx)
loop:
for {
select {
case num, ok := <-out:
if !ok {
fmt.Println("out channel closed")
break loop
}
if num < 5 {
fmt.Printf("recv num: %d\n", num)
} else {
cancelFunc()
}
}
}
}
func producer(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
count := 0
loop:
for {
select {
case <-ctx.Done():
fmt.Println(ctx.Err())
break loop
case out <- count:
}
time.Sleep(time.Second * 1)
count++
}
}()
return out
}
输出:
recv num: 0
recv num: 1
recv num: 2
recv num: 3
recv num: 4
context canceled
out channel closed
在这个简单的示例中,context的用法和channel没有什么本质区别,在root routine调用cancel后子routine跳出循环。但是在跨系统边界的时候,使用context有更清晰的语义。举个例子,一个调用事件的同步接口,在没有事件的时候可能阻塞在调用上,这时候需要提供一个cancel的机制。如果使用channel的做法,需要给出明确的文档,需要检查channel输入的合法性等。 而在接口中接收一个context作为参数,cancel,timeout等都可以实现。
func recv(ctx context.Context, params...)out EventType
context真正强大之处在于,可以通过context创建出子context,形成一棵树。树种的每一个子树的根都可以取消整个子树,但是不影响兄弟节点。看例子:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
out := producer(ctx)
middleman(ctx, out)
time.Sleep(time.Second * 30)
cancel()
}
func producer(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
loop:
for {
select {
case <-ctx.Done():
break loop
case out <- 1:
}
time.Sleep(time.Second * 2)
}
}()
return out
}
func middleman(ctx context.Context, input <-chan int) {
next := make(chan int)
go func() {
defer close(next)
timeoutCtx1, cancel1 := context.WithTimeout(ctx, time.Second*5)
defer cancel1()
consumer(timeoutCtx1, next, 1)
consumer(ctx, next, 2)
loop:
for {
select {
case <-ctx.Done():
fmt.Println(ctx.Err())
break loop
case num, ok := <-input:
if !ok {
break loop
}
fmt.Printf("will send num: %d\n", num)
next <- num
}
}
}()
}
func consumer(ctx context.Context, input <-chan int, cid int) {
go func() {
loop:
for {
select {
case <-ctx.Done():
fmt.Println(ctx.Err())
break loop
case num, ok := <-input:
if !ok {
break loop
}
time.Sleep(time.Second * 1)
fmt.Printf("cid: %d, deal number: %d\n", cid, num)
}
}
}()
}
output:
will send num: 1
cid: 1, deal number: 1
will send num: 1
cid: 2, deal number: 1
will send num: 1
cid: 1, deal number: 1
context deadline exceeded
will send num: 1
cid: 2, deal number: 1
will send num: 1
cid: 2, deal number: 1
will send num: 1
cid: 2, deal number: 1
...
1)数据的流向是producer->middleman->consumer,有一个producer,一个middleman,两个consumer。
2)控制树是主routine是root,主线程启动了producer routine和middleman routine,middleman routine启动了两个consumer routine。
3)主routine通过WithCancel创建了一个root context,并传给了middleman, middleman在这个root context的基础上创建了1个timeOut context,并且给其中一个consumer传递这个带有timeout的context。于是这段代码实现了这样一个功能,如果root routine中调用了root context返回的cance函数,所有的子routine都会被终结 ,否则,接收timeout的context那个consumer在超时后会结束,而接收root context的那个consumer会继续工作。
双向通信
上边介绍的模式都是基于单向通信的,但是其实channel是可以双向通信的。实现双向通信的channel有点像打乒乓球,交替接收和发送数据:
func main() {
pipe := make(chan int)
defer close(pipe)
go func() {
for {
num, ok := <-pipe
if !ok {
break
}
fmt.Printf("receivce from main: %d\n", num)
num += 1
pipe <- num
}
}()
pipe <- 1
for {
num := <-pipe
fmt.Printf("receive from routine: %d\n", num)
if num > 10 {
break
}
num += 1
pipe <- num
}
}
目前没有看到必须使用这种模式的场景,但是上述代码是合法的。