Go并发模式:管道和取消

 

WHY?

Go的并发原语可以轻松构建流数据流水线,从而有效利用I/O和多个CPU。

WHAT?

管道是一种数据结构,发送方可以以字符流形式将数据送入该结构,接收方可以从该结构接收数据。

HOW?

Go中没有正式的管道定义;但它是众多并发程序中的一种,是通过通道(channel)连接的一系列阶段,且每个阶段是一组运行同一个函数的goroutine。在每个阶段:goroutine们

1.通过输入通道,从上游的获取数据

2.在数据上执行一系列数据,通常会产生新值

3.通过输出通道,将新产生的值送往下游

每个阶段都拥有任意数量的输入通道和输出通道,除了第一个和最后一个阶段,第一个阶段只有输出通道,最后一个阶段只有输入通道,第一个又被称为来源或生产者,最后一个阶段又被称为槽或消费者。

DO:

在正式代码之前,可以先从HOW之中的叙述大致推理出函数的样子。假设共有三个阶段,则三个阶段的函数形式类似:

func stage1() <-chan {}

func stage2(in <-chan) <-chan{}

func stage(in <-chan){}

这样心里就有数了。如果要求某些数的平方值:该如何运用Go管道的技术呢?

依上述伪代码尝试写一下。

首先第一阶段,要有数字,然后要把这些数以管道的形输出给下一阶段使用:

func genx(nums...int) <-chan int {
	out := make(chan int)
	for n := range nums {
		out <- n
	}

	close(out)

	return out
}

观察这个函数,符不符合上面的WHAT和HOW?

发现并不符合,HOW中要求每一个阶段都是一组goroutine的组合,而这个第一阶段并没有goroutine。所以要用到Goroutine而要用到goroutine且goroutine要满足从通过输入通道从上游获取值,通过输出通道向下游发送值,而且要处理旧值得到新值,意思是所有一切都要在Goroutine中处理,不能直接在函数中处理,要在函数中重开goroutine处理。

改动第一个阶段代码:

func genx(t *testing.T, nums...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}

		t.Logf("genx Closed")
		close(out)
	}()

	return out
}

为什么把把close(out)放在goroutine中执行呢?因为向一个关闭的channel发送数据会引起panic,而goroutine的执行时必 genx的执行顺序慢的,所以如果放在外面,在把nums写入out的时候就会报错。

继续第二阶段,生成平方数:

func sqx(t *testing.T, in <-chan int) <-chan int{
	out := make(chan int)
	go func() {
		for n := range in {
			//t.Logf("NN=%v", n*n)
			out <- n * n
		}

		t.Logf("sqx Closed")
		close(out)
	}()

	return out
}

第三阶段,消费者输出:

func TestX(t *testing.T) {
	a := genx(4, 5, 6)
	b := sqx(t, a)
	go func() {
		for n := range b {
			t.Logf("^o^_%v", n)
		}
	}()
}

这样对吗?运行一下发现,没有任何打印。这是为何?因为TestX运行在主goroutine中,一旦运行完毕程序立刻就退出了,并没有给TestX中的goroutine执行的机会。而我们在TestX的goroutine中调用的是range而不是 <- b这样的函数,不会引起函数的goroutine阻塞,直到有数据读入才停止阻塞。引起阻塞就不会退出主routine,就能看到打印。

所以第三阶段把代码修改为:

func TestX(t *testing.T) {
	a := genx(4, 5, 6)
	b := sqx(t, a)
	
	for n := range b {
		t.Logf("^o^_%v", n)
	}
}

这下可以了,打印出16, 25,36。

这样貌似结束了。

但是还有一些问题。

如果输入的Goroutine不止一个呢?

这里牵涉到两个概念。扇入和扇出。

扇出:多个功能可以从同一个Channel读取,直到该通道关闭为止,提供了一种在一群工作者之间,分配工作给并行化CPU使用和IO的方法。

扇入:从多个输入读取,并继续执行,直到一切都停止下来。当以下情况发生时。通过复用多个输入的Channel到一个单独的Channel。当所有的输入都关闭的时候,这个Channel将会关闭(这个Channel关闭会导致一切【接收输入,输出等】都停止下来。)

扇出的代码:

func TestX1(t *testing.T) {
	in := genx(4, 5, 6)

	// 一个输入点(扇形的顶点),有多引用(扇形的扇面),扇出
	c1 := sqx(t, in)
	c2 := sqx(t, in)
	
	for n := range mergex(c1, c2) {
		t.Logf("n=%v", n)
	}
}

扇入的代码:

// 扇入,很多输入(扇面),汇聚到一个Channel(扇形顶点)
func mergex(t *testing.T, cs ... <-chan int) <-chan int {
	var wg sync.WaitGroup
	out := make(chan int)
	output := func(c <-chan int) {
		for n:= range c {
			out <- n
		}

		wg.Done()
	}

	wg.Add(len(cs))

	for _, c := range cs{
		go output(c)
	}

	go func() {
		wg.Wait()
		// when all the  are closed,
		t.Logf("mergex closed")
		close(out)
	}()

	return out
}

执行后TestX1的打印为:

  main_test.go:230: genx Closed
    main_test.go:245: sqx Closed
    main_test.go:245: sqx Closed
    main_test.go:282: n=16
    main_test.go:282: n=36
    main_test.go:282: n=25
    main_test.go:307: mergex closed

可以看到genx关闭了一次,sqx关闭两次,最后关闭的是mergex。

当所有的输入两个sqx都关闭了之后,接下来mergex就关闭了。mergex关闭之后,一切都停止了。

 

突然停止:

我们的管道功能有一种模式:

  • 所有发送操作完成后,阶段关闭其出站通道。
  • 阶段保持从入站通道接收值,直到这些通道关闭。

此模式允许将每个接收阶段写为range循环,并确保在所有值成功发送到下游后所有goroutine都退出。

但在实际管道中,阶段并不总是接收所有入站值。有时这是设计的:接收器可能只需要一个值的子集来取得进展。更常见的是,阶段会提前退出,因为入站值表示较早阶段的错误。在任何一种情况下,接收器都不必等待剩余的值到达,并且我们希望早期阶段停止产生后续阶段不需要的值。

在我们的示例管道中,如果某个阶段无法使用所有入站值,则尝试发送这些值的goroutine将无限期地阻塞:


    //从输出中获取第一个值。
    out:= merge(c1,c2)
    fmt.Println(< -  out)// 4或9 
    返回
    //由于我们没有收到out的第二个值,
    //其中一个输出goroutine挂起试图发送它。
}

这是资源泄漏:goroutines消耗内存和运行时资源,goroutine堆栈中的堆引用使数据不被垃圾收集。Goroutines不是垃圾收集; 他们必须自己退出。

即使下游阶段未能收到所有入站值,我们也需要安排管道的上游阶段退出。一种方法是将出站通道更改为具有缓冲区。缓冲区可以包含固定数量的值; 如果缓冲区中有空间,则立即发送操作:

c:= make(chan int,2)//缓冲区大小为2
c < -  1 //立即成功
c < -  2 //立即成功
c < -  3 //阻塞,直到另一个goroutine <-c并收到1

当在通道创建时知道要发送的值的数量时,缓冲区可以简化代码。例如,我们可以重写gen以将整数列表复制到缓冲通道中,并避免创建新的goroutine

func gen(nums ...int) <-chan int {
    out := make(chan int, len(nums))
    for _, n := range nums {
        out <- n
    }
    close(out)
    return out
}

回到我们管道中阻塞的goroutines,我们可能会考虑为返回的出站通道添加一个缓冲区merge

func merge(cs ... < -  chan int)<-chan int { 
    var wg sync.WaitGroup 
    out:= make(chan int,1)//足够的空间用于未读输入
    // ...其余部分保持不变。 ..

虽然这修复了此程序中阻塞的goroutine,但这是错误的代码。此处缓冲区大小为1的选择取决于知道merge 将接收的值的数量以及下游阶段将消耗的值的数量。这很脆弱:如果我们传递一个额外的值gen,或者如果下游阶段读取任何更少的值,我们将再次阻止goroutines。

上面这一小段没有看懂!等会看下缓冲区Channel的用法,回头再看。

相反,我们需要为下游阶段提供一种方式,向发件人表明他们将停止接受输入。

main决定退出而不接收所有值时 out,它必须告诉上游阶段的goroutines放弃他们试图发送的值。它通过在名为的通道上发送值来实现done。它发送两个值,因为可能有两个阻塞的发件人:

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 first value from output.
    done := make(chan struct{}, 2)
    out := merge(done, c1, c2)
    fmt.Println(<-out) // 4 or 9

    // Tell the remaining senders we're leaving.
    done <- struct{}{}
    done <- struct{}{}
}

发送goroutines用一个select语句替换它们的发送操作,该语句在发送out时或从接收到的值时继续done。值类型done是空结构,因为值无关紧要:它是指示out应该放弃发送的接收事件。所述output够程继续循环在其入站通道,c,所以上游阶段不被堵塞。(我们将在稍后讨论如何让这个循环尽早返回。)

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Start an output goroutine for each input channel in cs.  output
    // copies values from c to out until c is closed or it receives a value
    // from done, then output calls wg.Done.
    output := func(c <-chan int) {
        for n := range c {
            select {
            case out <- n:
            case <-done:
            }
        }
        wg.Done()
    }
    // ... the rest is unchanged ...

这种方法存在一个问题:每个下游接收器需要知道可能被阻塞的上游发送器的数量,并安排在早期返回时发信号通知这些发送器。跟踪这些计数是乏味且容易出错的。

我们需要一种方法来告诉未知和无限数量的goroutine停止向下游发送它们的值。在Go中,我们可以通过关闭通道来完成此操作,因为 关闭通道上的接收操作总是可以立即进行,从而产生元素类型的零值。

这意味着main只需关闭done频道即可解锁所有发件人。这种关闭实际上是发送者的广播信号。我们将每个管道函数扩展为接受 done作为参数并通过defer语句安排接近发生 ,以便所有返回路径main将通知管道阶段退出。

func main() {
    // Set up a done channel that's shared by the whole pipeline,
    // and close that channel when this pipeline exits, as a signal
    // for all the goroutines we started to exit.
    done := make(chan struct{})
    defer close(done)

    in := gen(done, 2, 3)

    // Distribute the sq work across two goroutines that both read from in.
    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

    // done will be closed by the deferred call.
}

我们的每个管道阶段现在都可以在done关闭后自由返回。在output常规merge可以返回而不消耗其入站通道,因为它知道上游发送者,sq将停止尝试时发送 done关闭。 output确保wg.Done通过defer语句在所有返回路径上调用:

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Start an output goroutine for each input channel in cs.  output
    // copies values from c to out until c or done is closed, then calls
    // wg.Done.
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            select {
            case out <- n:
            case <-done:
                return
            }
        }
    }
    // ... the rest is unchanged ...

同样,一旦done关闭,sq就可以返回。 通过defer声明,sq确保其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
}

以下是管道施工的指南:

  • 所有发送操作完成后,阶段关闭其出站通道。
  • 阶段保持从入站通道接收值,直到这些通道关闭或发件人被解锁。

管道通过确保为所有发送的值提供足够的缓冲区或通过在接收方放弃信道时显式地发送信令来发送信号,从而解锁发送方。

最后以将一个目录作为参数,并打印该目录下每个常规文件的摘要值,按路径名排序。。

1.获取某个目录下的全部文件路径,并导出到Channel paths中。

2.读取paths中的路径,并按路径读取文件中的数据,并对数据生成md5签名,返回路径+签名+err的 Channel

3.读取2返回的Channel并打印其中的结果

第一版先不用并发执行:

func main() {
    // Calculate the MD5 sum of all files under the specified directory,
    // then print the results sorted by path name.
    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 reads all the files in the file tree rooted at root and returns a map
// from file path to the MD5 sum of the file's contents.  If the directory walk
// fails or any read operation fails, MD5All returns an error.
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
}

第二版使用并发执行:

我们分裂MD5All成两个阶段的管道。

第一个阶段,sumFiles遍历树,在新的goroutine中消化每个文件,并在值为类型的通道上发送结果result

func sumFiles(done <-chan struct{}, root string) (<-chan result, <-chan error) {
    // For each regular file, start a goroutine that sums the file and sends
    // the result on c.  Send the result of the walk on errc.
    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 err
            }
            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()
            }()
            // Abort the walk if done is closed.
            select {
            case <-done:
                return errors.New("walk canceled")
            default:
                return nil
            }
        })
        // Walk has returned, so all calls to wg.Add are done.  Start a
        // goroutine to close c once all the sends are done.
        go func() {
            wg.Wait()
            close(c)
        }()
        // No select needed here, since errc is buffered.
        errc <- err
    }()
    return c, errc
}

第二阶段,MD5All从中接收摘要值c。 MD5All错误时提前返回,done通过以下方式关闭defer:

func MD5All(root string) (map[string][md5.Size]byte, error) {
    // MD5All closes the done channel when it returns; it may do so before
    // receiving all the values from c and errc.
    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
}

第二版函数有个很大问题就是对每个文件启动一个goroutine求md5和,在具有许多大文件的目录中,这可能会分配比计算机上可用内存更多的内存。

我们可以通过限制并行读取的文件数来限制这些分配。

第三版有限的并行性

我们通过创建固定数量的goroutine来读取文件。我们的管道现在有三个阶段:走树,读取和消化文件,并收集摘要。

第一个阶段,walkFiles发出树中常规文件的路径:

func walkFiles(done <-chan struct{}, root string) (<-chan string, <-chan error) {
    paths := make(chan string)
    errc := make(chan error, 1)
    go func() {
        // Close the paths channel after Walk returns.
        defer close(paths)
        // No select needed for this send, since errc is buffered.
        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接收文件名,并在通道上c发送results:

// digester reads path names from paths and sends digests of the corresponding
// files on c until either paths or done is closed.
func digester(done <-chan struct{}, paths <-chan string, c chan<- result) {
	for path := range paths { // HLpaths
		data, err := ioutil.ReadFile(path)
		select {
		case c <- result{path, md5.Sum(data), err}:
		case <-done:
			return
		}
	}
}

与前面的示例不同,digester不会关闭其输出通道,因为多个goroutine正在共享通道上发送。相反,代码MD5All 安排digesters在完成所有操作后关闭频道:

// MD5All reads all the files in the file tree rooted at root and returns a map
// from file path to the MD5 sum of the file's contents.  If the directory walk
// fails or any read operation fails, MD5All returns an error.  In that case,
// MD5All does not wait for inflight read operations to complete.
func MD5All(root string) (map[string][md5.Size]byte, error) {
	// MD5All closes the done channel when it returns; it may do so before
	// receiving all the values from c and errc.
	done := make(chan struct{})
	defer close(done)

	paths, errc := walkFiles(done, root)

	// Start a fixed number of goroutines to read and digest files.
	c := make(chan result) // HLc
	var wg sync.WaitGroup
	const numDigesters = 20
	wg.Add(numDigesters)
	for i := 0; i < numDigesters; i++ {
		go func() {
			digester(done, paths, c) // HLc
			wg.Done()
		}()
	}
	go func() {
		wg.Wait()
		close(c) // HLc
	}()
	// End of pipeline. OMIT

	m := make(map[string][md5.Size]byte)
	for r := range c {
		if r.err != nil {
			return nil, r.err
		}
		m[r.path] = r.sum
	}
	// Check whether the Walk failed.
	if err := <-errc; err != nil { // HLerrc
		return nil, err
	}
	return m, nil
}

我们可以让每个消化器创建并返回自己的输出通道,但是我们需要额外的goroutine来扇入结果。

 

参考:https://blog.golang.org/pipelines

最后阶段接收所有results从那里c检查错误errc。此检查不会更早发生,因为在此之前,walkFiles可能阻止向下游发送值:

本文介绍了在Go中构建流数据管道的技术。处理此类管道中的故障非常棘手,因为管道中的每个阶段都可能阻止尝试向下游发送值,并且下游阶段可能不再关心传入的数据。我们展示了关闭一个通道如何向管道启动的所有goroutine广播“完成”信号,并定义正确构建管道的准则。

 

你可能感兴趣的:(Go高级)