本文翻译自Sameer Ajmani的文章《Go Concurrency Patterns: Pipelines and cancellation》。原文地址
介绍
Go语言的并发语意使得构建处理实时流式数据的pipeline非常方便,从而能够有效地利用I/O和多核CPU。本文介绍了构建这种pipeline的一些例子,重点突出了操作失败时的处理细节,并介绍了优雅处理故障的技术。
什么是pipeline?
Go语言对于pipeline没有正式的定义;它只是众多种并发程序之一。通俗地讲,pipeline是通过channel连接一系列的阶段,其中每个阶段是运行相同函数的goroutine。在每一个阶段,goroutine会
- 通过入站channel接收来自上游的数据
- 对该数据执行一些操作,通常会产生新的数据
- 通过出站channel向下游发送数据
第一个阶段只有出站channel,最后一个阶段只有入站channel,除此之外,其它的阶段都有任意数量的出站和入站channel。第一个阶段有时候也被称为source或者是producer,最后一个阶段有时也被称为sink或者是consumer。
我们将通过一个简单的pipeline来解释这样的想法和技术,然后提出一个更有现实意义的例子。
算平方的例子
设想这样的一个有三个阶段的pipeline。
第一个阶段为gen
,这个函数将一个整数切片传入一个channel,在所有的元素都传入channel后,关闭这个channel
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
第二个阶段为sq
,这个函数从一个channel中接收整数,然后将每个整数的平方传入出站channel并返回出站channel。在入站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
}
主函数main
运行最后一个阶段,它接收来自第二阶段的值,并逐个打印直到channel关闭。
func main() {
c := gen(2, 3)
out := sq(c)
fmt.Println(<-out)
fmt.Println(<-out)
}
// 4
// 9
因为sq
的入站和出站channel类型一样,我们可以多次组合使用它。比如下面的main
函数
func main() {
for n := range sq(sq(gen(2, 3))) {
fmt.Println(n)
}
}
// 16
// 81
扇出,扇入
多个函数可以从同一个channel读取数据,直到该channel被关闭:这被称为扇出。扇出提供了一种分发任务,从而并行化使用CPU和I/O的方式。
将多个输入channel复用到单个channel上,在所有输入channel关闭时,关闭这个channel。通过这种方式,一个函数可以从多个输入读取数据,并执行相应的操作直到所有的数据源都被关闭。这被称之为扇入。
我们可以改变上面的pipeline,运行两个sq实例,每个实例从同一个输入channel读取数据。我们引入新的merge
函数,来扇入结果。
func main() {
in := gen(2, 3)
// Distribute the sq work across two goroutines that both read from in.
c1 := sq(in)
c2 := sq(in)
// Consume the merged output from c1 and c2.
for n := range merge(c1, c2) {
fmt.Println(n) // 4 then 9, or 9 then 4
}
}
merge函数为每个入站channel启动一个goroutine,将每个入站channel中的值拷贝到单个出站channel中。一旦所有的output
goroutine启动,merge
函数会启动一个新的goroutine,在出站channel接收所有值后将其关闭。
向一个已经关闭的channel传入值会导致程序崩溃,所以在关闭channel前一定要保证所有的传值操作都已完成。sync.WaitGroup类型提供了一种实现此类同步的简单方法。
func merge(cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
// 为每个入站channel启动一个名为output的goroutine,
// output从每个channel中读取值并传入out,直到入站channel被关闭,然后调用wg.Done
output := func(c <-chan int) {
for n := range c {
out <- n
}
wg.Done()
}
wg.Add(len(cs))
for _, c := range cs {
go output()
}
// 当所有的output 的goroutine都执行完成时,启动一个新的goroutine来关闭out
// 必须在调用wg.Add后执行这个操作。
go func() {
wg.Wait()
close(out)
}()
return out
}
短停止
在上面的pipeline中有这样的模式
- 当所有的传入操作完成时,关闭出站channel
- 持续从入站channel接收数据,直到入站channel被关闭
此模式允许每个接收阶段被写成range
循环,并确保所有goroutine在所有的值成功发送到下游后退出。
但是在实际的pipeline中,阶段并不总是能接收所有入站的值。有时候这是设计决定的:接收端只需要部分数据。更常见的情况是,因为入站值的表示早期阶段的错误,导致阶段提前退出。不论哪种情况,接收器都不应该等待剩余的值到达,并且我们希望较早的阶段停止产生后续阶段不需要的值。
在上面的示例pipeline中,如果一个阶段无法使用所有的入站值,那么尝试发送这些值的goroutine将会无限期地阻塞。
// 接收output中第一个值
out := merge(c1, c2)
fmt.Println(<-out)
return
// 因为没有接收out的第二个值,两个output中的一个将会阻塞
这里存在着资源泄漏,goroutine消耗内存和运行时资源,而goroutine堆栈中的引用会阻止垃圾回收清理数据。goroutine的资源不能被自动垃圾回收;它们必须自己退出。
即使下游阶段没有收到所有的入站值,我们也需要让pipeline的上游阶段退出。一种实现方式是为出站channel添加缓冲区?;撼迩梢员4婀潭ㄊ康闹担蝗绻撼迩杏锌占?,立即完成发送操作。
c := make(chan int, 2)
c <- 1 // 执行成功
c <- 2 // 执行成功
c <- 3 // 阻塞住,直到另外一个goroutine做 <-c这样的操作并且获取1。
回到上面pipeline中的阻塞的goroutine,我们可以考虑在merge
函数返回的出站channel中添加缓冲区。
func merge(cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int, 1)
// ... 其余部分不变 ...
}
尽管修复了这个程序中的阻塞goroutine,这段代码还是有问题。之所以将缓冲区大小设置为1,是因为我们知道merge
接收值的数量,和下游阶段会消耗值的数量。这是脆弱的:如果我们向gen
多传了一个值,或者下游阶段读取更少的值,程序中又将会出现阻塞的goroutine。
相反,我们需要为下游阶段提供一种方法,向发送方指示让它们停止接受输入。
显示取消
在main
函数决定退出,并且不再从out
中接收值时,必须告诉上游阶段的goroutine丢弃将要发送的值。它通过在名为done
的channel上发送值来实现。它发送两个值,因为有可能有两个阻塞的发件人。
func main() {
in := gen(2, 3)
c1 := sq(in)
c2 := sq(in)
done := make(chan struct{}, 2)
out := merge(done, c1, c2)
fmt.Println(<-out) // 4 或者 9
done <- struct{}{}
done <- struct{}{}
}
当out
上发生发送或者它们从done
接收到值时,发送goroutine用select
语句替换它们原有的发送操作。done
的值类型是空结构体,因为值本身并不重要:它是一个接收事件,表示out
上接收到的值应该被抛弃。
goroutineout
继续在入站channelc
上循环,因此上游阶段不会被阻塞(我们稍后将讨论如何允许这个循环提前返回)。
func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
// 为每个cs中的channel启动一个output goroutine
// output从c复制值到out,直到c被关闭,或者从done中收到一个值
// 然后调用wg.Done
output := func(c <-chan int) {
for n := range c {
select {
case out <- n:
case <-done:
}
}
wg.Done()
}
// ...其余代码不变
}
这个方法有一个问题,每个下游接收器需要知道潜在阻塞的上游发送器的数量,并处理发送器提前返回的信号。跟踪这些计数是乏味和容易出错的。
我们需要一种方法,来告诉未知和无限数量的goroutine停止向下游发送它们的值。在Go中,我们可以通过关闭channel来做到这一点,因为在一个关闭的channel上的接收操作可以总是立即执行,产生元素类型的零值。
这意味着main
可以通过关闭done
channel来简单解除所有发送者的阻塞。这个关闭操作时间是向发送者的广播信号。我们扩展没有pipeline函数,接收done
作为参数,并通过defer
语句来关闭它。
func main() {
// 创建一个整个pipeline共享的channel
// 在pipeline退出时,关闭这个channel
// 同时作为信号让启动的所有goroutine退出
done := make(chan struct{})
defer close(done)
in := gen(done, 2, 3)
c1 := sq(done, in)
c2 := sq(done, in)
out := merge(done, c1, c2)
fmt.Println(<-out) // 4或者9
// done 会通过defer被关闭
}
现在pipeline中的每个阶段都会在done
被关闭后立即自由返回。merge
中的output
可以在不消耗其入站channel的情况下返回,因为它知道上游发送者sq
会在done
被关闭时停止发送。output
通过defer
关键字保证wg.Done
在所有返回路径上被调用。
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
}
}
}
// ... 其它代码不变 ...
}
同样的,sq
也可以在done
关闭时返回,通过defer
保证out
在所有返回路径上被关闭。
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
}
以下是设计pipeline的指导方针。
- 当所有的发送操作完成时,阶段关闭它们的出站channel。
- 阶段持续从入站channel接收值,直到这些channel被关闭或者被解除阻塞
通过确保对所有发送的值有足够的缓冲区或者通过在接收器放弃channel时显式地发送信号通知发送方,pipeline可以解除发送方的阻塞。
摘要树
让我们来看看一个更有现实意义的pipeline。
MD5是一种消息摘要算法,可用作文件校验。命令行程序md5sum会打印目录中文件的摘要值。
% md5 *.go
MD5 (bounded.go) = e3635300581854a5dd4ae6f748b38775
MD5 (parallel.go) = 9efb4ffcca07e6994ef003a18925502a
MD5 (serial.go) = 26a2162e7cb28f4ed9f67e92616dbb24
我们的实例程序就像md5,它传入单个目录作为参数,并打印该目录下每个常规文件的摘要值,按路径名排序
% go run serial.go
go run serial.go .
e3635300581854a5dd4ae6f748b38775 bounded.go
9efb4ffcca07e6994ef003a18925502a parallel.go
26a2162e7cb28f4ed9f67e92616dbb24 serial.go
程序的main
函数调用MD5All
函数,这个函数返回一个从路径名到摘要值的映射,然后排序和打印结果
func main() {
// 计算目录下每个文件的MD5值
// 按路径名排序输出结果
m, err := MD5All(os.Args[1])
if err != nil {
fmt.Println(err)
return
}
var paths []string
for path := range m {
paths = append(paths, path)
}
sort.Strings(paths)
for _, path := range paths {
fmt.Printf("%x %s\n", m[path], path)
}
}
函数MD5All
将是我们讨论的焦点,在serial.go中,实现不使用并发的方式,而是遍历目录下的问题,读取并求出每个文件的MD5值。
func MD5All(root string) (map[string][md5.Size]byte, error) {
m := make(map[string][md5.Size]byte)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
m[path] = md5.Sum(data)
return nil
})
if err != nil {
return nil, err
}
return m, nil
}
并行解法
在parallel.go中,我们将MD5All
函数拆成有两个阶段的pipeline。在第一个阶段sumFiles
中,程序遍历目录,为每个文件启动一个goroutine来计算文件的MD5值,并将结果发送到一个类型为result
的channel中。
type result struct {
path string
sum [md5.Size]byte
err error
}
函数sumFiles
返回两个channel:一个用于results
,另外一个用于filepath.Walk
返回的错误信息。Walk
函数启动一个新的goroutine来处理每个常规文件,然后检查done
。如果done
被关闭,Walk
函数马上停止。
func sumFiles(done <-chan struct{}, root string) (<-chan result, <-chan error) {
c := make(chan result)
errc := make(chan error, 1)
go func() {
var wg sync.WaitGroup
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.Mode().IsRegular() {
return nil
}
wg.Add(1)
go func() {
data, err := ioutil.ReadFile(path)
select {
case c <- result{path, md5.Sum(data), err}:
case <-done:
}
wg.Done()
}()
select <-done{
case <-done:
return errors.New("walk canceled")
default:
return nil
}
})
go func(){
wg.Wait()
close(c)
}()
errc <- err
}()
return c, errc
}
函数MD5All
从c
中接收结果,在发生错误时提前返回,通过defer
关闭done
channel
func MD5All(root string) (map[string][md5.Size]byte, error) {
done := make(chan struct{})
defer close(done)
c, errc := sumFiles(done, root)
m := make(map[string][md5.Size]byte)
for r := range c {
if r.err != nil {
return nil, r.err
}
m[r.path] = r.sum
}
if err := <-errc; err != nil {
return nil, err
}
return m, nil
}
有界并行
在parallel.go中,MD5All
为每个文件都启动了一个goroutine。在处理有很多大文件的文件夹时,这可能分配超过机器上可用上限的内存。
可以通过限制并行读取文件的数量来限制占用的内存。在bounded.go中,通过创建固定数量的goroutine来读取文件。现在的pipeline中有三个阶段:遍历目录,读取文件并计算摘要,收集摘要。
第一个阶段,walkFiles
,获取目录中的常规文件的路径。
func walkFiles(done <-chan struct{}, root string) (<-chan string, <-chan error) {
paths := make(chan string)
errc := make(chan error, 1)
go func() {
defer close(paths)
errc <- filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
select {
case paths <- path:
case <-done:
return errors.New("walk canceled")
}
return nil
})
}()
return paths, errc
}
中间阶段启动固定数量的digester
goroutine,从paths
中接收文件名并在channelc
上发送结果。
func digester(done <-chan struct{}, paths <-chan string, c chan<- results) {
for path := range paths {
data, err := ioutil.ReadFile(path)
select {
case c <- result{path, md5.Sum(data), err}:
case <-done:
return
}
}
}
和之前的例子不同,digester
不关闭其输出channel,因为多个goroutine在共享channel上发送。在所有digester
完成后,``MD5All````会关闭这个channel。
c := make(chan result)
var wg sync.WaitGroup
const numDigesters = 20
wg.Add(numDigesters)
for i := 0; i < numDigesters; i++ {
go func() {
digester(done, paths, c)
wg.Done()
}()
}
go func() {
wg.Wait()
close(c)
}()
可以让每个digester
创建和返回自己的输出channel,但是这就需要额外的goroutine来扇出结果。
最后阶段从c
接收所有结果,然后检查errc
中的错误。此检查不能发生地更早,因为在这之前,walkFiles
可能阻塞向下游发送值。
m := make(map[string][md5.Size]byte)
for r := range c {
if r.err != nil {
return nil, r.err
}
m[r.path] = r.sum
}
if err := <-errc; err != nil {
return nil, err
}
return m, nil
结论
本文介绍了在Go语言中构建数据流pipeline的技术。处理这样的pipeline中出现的故障是棘手的,因为pipeline中的每个阶段有可能会阻塞向下游发送值,并且下游阶段可能不再关心输入的数据。我们展示了如何通过关闭一个channel,向pipeline中启动的所有goroutine广播一个“完成”信号,并且定义了正确构造pipeline的指南。
“本译文仅供个人研习、欣赏语言之用,谢绝任何转载及用于任何商业用途。本译文所涉法律后果均由本人承担。本人同意简书平台在接获有关著作权人的通知后,删除文章。”