Go并发模式:管道和终止

本文翻译自Sameer Ajmani的文章《Go Concurrency Patterns: Pipelines and cancellation》。原文地址

gophers

介绍

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中。一旦所有的outputgoroutine启动,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添加缓冲区。缓冲区可以保存固定数量的值;如果缓冲区中有空间,立即完成发送操作。

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
}

函数MD5Allc中接收结果,在发生错误时提前返回,通过defer关闭donechannel

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
}

中间阶段启动固定数量的digestergoroutine,从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的指南。

“本译文仅供个人研习、欣赏语言之用,谢绝任何转载及用于任何商业用途。本译文所涉法律后果均由本人承担。本人同意简书平台在接获有关著作权人的通知后,删除文章。”

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容