pipeline in Go
什么是 pipeline
pipeline 是一种简单的计算机编程模式,它从一个输入流中读取数据,并将结果写入另一个输出流。通过 pipeline 我们可以轻易的将代码按照单一职责的原则拆分为高内聚低耦合的小模块,通过模块间的拼装,可以构建出高度可扩展的系统。
流式处理、函数式编程、应用网关对微服务的 API 编排,都是受 pipeline 技术的影响。我们将代码按照「单一职责」的原则拆分为高内聚低耦合的小模块,通过模块间的拼装,可以构建出高度可扩展的系统。例如 ls | grep test
命令,便是将 ls
和 grep
命令组合在一起,形成了一个 pipeline。
在 Go 中,我们通常以 goroutine 构建一个个模块,然后使用 channel 来控制模块之间的信息流向;这样由 channel 和模块组成的一整个数据流就称为一个 pipeline。
这些模块会做这几件事:
- 通过
inbound channels
(只读的 channel)获取数据 - 对数据进行处理
- 将处理后的数据通过
outbound channels
(只写的 channel)传递
特别地,pipeline 的第一个模块只有 outbound channels
,通常将该模块称为 srouce
或 producer
;最后一个模块则只有 inbound channels
,通常被称为 sink
或 consumer
。
基础的 pipeline 示例
这里我们将实现 shell 中的 echo $nums | sq | sum
命令,作为基础的 go pipeline 示例。
首先,我们实现一个 echo()
函数作为 pipeline 的入口:
func echo(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
接下来我们实现一个 sq()
函数,它将接收一个 <-chan int
类型的 channel,并将其中的数据平方后再通过同样的 channel 返回:
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
类似的,sum()
函数将接收一个 <-chan int
类型的 channel,并将其中的数据相加后再通过同样的 channel 返回:
func sum(in <-chan int) <-chan int {
out := make(chan int)
go func() {
total := 0
for n := range in {
total += n
}
out <- total
close(out)
}()
return out
}
我们将 echo()
sq()
sum()
组合在一起,形成一个 pipeline:
var nums = []int{1, 2, 3, 4, 5}
res := sum(sq(echo(nums...)))
for n := range res {
fmt.Println(n)
}
上面的代码即等价于 shell 中的 echo $nums | sq | sum
命令。
我们也可以通过 修饰器模式 对上面 sum(sq(echo(nums...)))
进行简化:
type EchoFunc func([] int) <-chan int
type PipeFunc func(<-chan int) <-chan int
func pipeline(nums []int, echoFunc EchoFunc, pipeFunc ...PipeFunc) <-chan int {
out := echoFunc(nums)
for foo := range pipeFunc {
out = foo(out)
}
return out
}
然后代码便可以改写成这样:
var nums = []int{1, 2, 3, 4, 5}
res := pipeline(nums, echo, sq, sum)
for n := range res {
fmt.Println(n)
}
Fan-out && Fan-in
Fan-in 即是多对一,多个输入、一个输出;Fan-out 则是一对多,一个输入、多个输出。
得益于 goroutine 和 channel,我们可以实现具备 Fan-out 和 Fan-in 特性的 pipeline,也就是能够并发处理任务的 pipeline。
这里我们希望同时有三个任务,去并发的处理数组 nums
中的数据,处理完之后,通过 merge()
方法将其结果合并、供 sum()
统一求和处理。
下面是我们的主函数,可以看到,同时有多个 job 处理来自 echo
产生的数据,也就是对应上图的 fan-out:
const MaxJobNum = 3 // 同时起三个任务
func main() {
nums := []int{1, 2, 3}
in := echo(nums)
var jobs [MaxJobNum]<-chan int
for i := range jobs { // **并发处理** echo 产生的数据
jobs[i] = sum(sq(in))
}
for n := range sum(merge(jobs[:])) {
fmt.Println(n)
}
}
merge()
函数将所有 job 的执行结果合入一个 channel 中,也就是所谓的 fan-in。下面是它的具体实现:
func merge(cs []<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
output := func(c <-chan int) {
defer wg.Done()
for n := range c {
out <- n
}
}
for _, c := range cs {
wg.Add(1)
go output(c) // fan-in
}
go func() {
wg.Wait() // 保证 close 时不会再有数据发送至 channel
close(out) // 这里必须要 close,不然外层的 for-range 会一直阻塞
}
return out
}
提前终止
为什么需要「提前终止」
在前面的 pipeline demo 中,可以看到每个模块都有如下行为:
- 模块发送完所有的数据后,总是会主动关闭
outbound channels
- 模块通过
for range
进行监听和消费inbound channels
;也就是,直到inbound channels
被主动关闭才会停止对它的消费
不过这也就要求我们的程序:
- 模块发送的数据全部被下游接受(因为我们用的是无缓冲 channel 进行通信)
- 每个模块都可以成功关闭自身出方向 channel(否则下游的
for range
会持续等待,导致程序阻塞)
而在现实中,下游并不总是接受所有来自上游的数据,可能是设计本就如此(下游只需要部分数据即可),更可能的情况是下游在处理数据时出现异常、导致无法处理之后的数据。无论是哪种情况,都会导致我们前面的 demo 挂起阻塞。
如下例,下游模块只需要一个数据:
// Consume the first value from the output.
out := merge(c1, c2)
fmt.Println(<-out) // 4 or 9
return
// 我们只从 out 中拿了一个数据,因此 c1 或者 c2 中的一个会在发送时被阻塞
}
这也就造成了 资源泄漏:goroutine 的引用存放在堆上,不会被 gc 主动回收,必须由 goroutine 自己 return;而上例将有一个 goroutine 挂起,持续占用内存和运行时资源。
当然,我们可以通过让 merge()
返回带 buffer 的 channel,简单快速解决上面 goroutine 挂起问题的方法:
func merge(cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int, 1) // enough space for the unread inputs
// ... the rest is unchanged ...
这里我们为 out
设置了一个大小为 1 的缓冲区,这样即使下游只拿一个数据,merge()
中的 goroutine 也不会发生阻塞了。
不过这显然并不是一个好方法:我们不可能总是知道下游具体行为是什么,无法总是提前确定一个合适的 buffer。
因此,我们希望让下游在不能消费更多数据时,主动通知上游退出,让上游无需关心下游具体行为,专注自己的实现,也就是,让上游提前终止。
如何实现「显式退出」
Don't communicate by sharing memory, share memory by communicating.
go 并发的宗旨是通过「交流」来共享数据,也就是通过 channel 传递来共享信息。主动通知上游退出,本质上,也就是希望向所有上游传递一个「退出信号」。
最容易想到的便是我们维护一个用于发送退出信号的带缓冲 channel done
,当需要通知上游时即向 done
中发送对应上游数量的退出信号:
func main() {
in := echo(2, 3)
// 假定有两个上游
c1 := sq(in)
c2 := sq(in)
done := make(chan struct{}, 2)
out := merge(done, c1, c2)
fmt.Println(<-out) // 4 or 9
// 消费完需要的数据后,通知两个上游已经可以终止了
done <- struct{}{}
done <- struct{}{}
}
func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
output := func(c <-chan int) {
for n := range c {
select {
case out <- n:
case <-done: // 如果收到 done 中的退出信号,跳出当前的 select
}
}
wg.Done()
}
// ... the rest is unchanged ...
显然,这也不是一个足够优雅的方式:下游必须知道将可能被阻塞的消息数量,才可以发送足够数量的退出信号。
更好的方式是,让所有上游监听一个无缓冲的 channel,当需要上游退出时,close
这个 channel,这样所有上游都可以监听到这个退出信号了(从一个已关闭的无缓冲 channel 中总是拿到对应类型的零值)
func main() {
// 让所有模块都监听 done 这个无缓冲 channel
done := make(chan struct{})
defer close(done) // done 被 close 时,能够向所有监听的模块广播「退出信号」
in := echo(done, 2, 3)
c1 := sq(done, in)
c2 := sq(done, in)
// Consume the first value from output.
out := merge(done, c1, c2)
fmt.Println(<-out) // 4 or 9
// defer 语句保证在函数执行完之后,done 会被 close
}
模块中,我们使用 select
语句实现对 done
的监听,例如 merge()
和 sq()
。
func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
output := func(c <-chan int) {
defer wg.Done()
for n := range c {
select {
case out <- n:
case <-done:
return // 收到退出信号后,return 以调用 wg.Done() 保证 out 被 close
}
}
}
// ... the rest is unchanged ...
func sq(done <-chan struct{}, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-done:
return // 收到退出信号后,return 以保证 out 被 close
}
}
}()
return out
}
总结
最后,再总结一下 go 中 pipeline 这一编程范式的准则:
- 当一个模块出方向的 channel 发送完所有的数据后,主动关闭这个出方向的 channel
- 下游通过
for range
监听并消费上游出方向的 channel;当不再需要更多数据时,应该主动通知上游退出(广播退出信号)