| Go Concurrency Patterns: Pipelines and cancellation |
| 13 Mar 2014 |
| Tags: concurrency, pipelines, cancellation |
| |
| Sameer Ajmani |
| |
| * Introduction |
| |
| Go's concurrency primitives make it easy to construct streaming data pipelines |
| that make efficient use of I/O and multiple CPUs. This article presents |
| examples of such pipelines, highlights subtleties that arise when operations |
| fail, and introduces techniques for dealing with failures cleanly. |
| |
| * What is a pipeline? |
| |
| There's no formal definition of a pipeline in Go; it's just one of many kinds of |
| concurrent programs. Informally, a pipeline is a series of _stages_ connected |
| by channels, where each stage is a group of goroutines running the same |
| function. In each stage, the goroutines |
| |
| - receive values from _upstream_ via _inbound_ channels |
| - perform some function on that data, usually producing new values |
| - send values _downstream_ via _outbound_ channels |
| |
| Each stage has any number of inbound and outbound channels, except the |
| first and last stages, which have only outbound or inbound channels, |
| respectively. The first stage is sometimes called the _source_ or |
| _producer_; the last stage, the _sink_ or _consumer_. |
| |
| We'll begin with a simple example pipeline to explain the ideas and techniques. |
| Later, we'll present a more realistic example. |
| |
| * Squaring numbers |
| |
| Consider a pipeline with three stages. |
| |
| The first stage, `gen`, is a function that converts a list of integers to a |
| channel that emits the integers in the list. The `gen` function starts a |
| goroutine that sends the integers on the channel and closes the channel when all |
| the values have been sent: |
| |
| .code pipelines/square.go /func gen/,/^}/ |
| |
| The second stage, `sq`, receives integers from a channel and returns a |
| channel that emits the square of each received integer. After the |
| inbound channel is closed and this stage has sent all the values |
| downstream, it closes the outbound channel: |
| |
| .code pipelines/square.go /func sq/,/^}/ |
| |
| The `main` function sets up the pipeline and runs the final stage: it receives |
| values from the second stage and prints each one, until the channel is closed: |
| |
| .code pipelines/square.go /func main/,/^}/ |
| |
| Since `sq` has the same type for its inbound and outbound channels, we |
| can compose it any number of times. We can also rewrite `main` as a |
| range loop, like the other stages: |
| |
| .code pipelines/square2.go /func main/,/^}/ |
| |
| * Fan-out, fan-in |
| |
| Multiple functions can read from the same channel until that channel is closed; |
| this is called _fan-out_. This provides a way to distribute work amongst a group |
| of workers to parallelize CPU use and I/O. |
| |
| A function can read from multiple inputs and proceed until all are closed by |
| multiplexing the input channels onto a single channel that's closed when all the |
| inputs are closed. This is called _fan-in_. |
| |
| We can change our pipeline to run two instances of `sq`, each reading from the |
| same input channel. We introduce a new function, _merge_, to fan in the |
| results: |
| |
| .code pipelines/sqfan.go /func main/,/^}/ |
| |
| The `merge` function converts a list of channels to a single channel by starting |
| a goroutine for each inbound channel that copies the values to the sole outbound |
| channel. Once all the `output` goroutines have been started, `merge` starts one |
| more goroutine to close the outbound channel after all sends on that channel are |
| done. |
| |
| Sends on a closed channel panic, so it's important to ensure all sends |
| are done before calling close. The |
| [[https://golang.org/pkg/sync/#WaitGroup][`sync.WaitGroup`]] type |
| provides a simple way to arrange this synchronization: |
| |
| .code pipelines/sqfan.go /func merge/,/^}/ |
| |
| * Stopping short |
| |
| There is a pattern to our pipeline functions: |
| |
| - stages close their outbound channels when all the send operations are done. |
| - stages keep receiving values from inbound channels until those channels are closed. |
| |
| This pattern allows each receiving stage to be written as a `range` loop and |
| ensures that all goroutines exit once all values have been successfully sent |
| downstream. |
| |
| But in real pipelines, stages don't always receive all the inbound |
| values. Sometimes this is by design: the receiver may only need a |
| subset of values to make progress. More often, a stage exits early |
| because an inbound value represents an error in an earlier stage. In |
| either case the receiver should not have to wait for the remaining |
| values to arrive, and we want earlier stages to stop producing values |
| that later stages don't need. |
| |
| In our example pipeline, if a stage fails to consume all the inbound values, the |
| goroutines attempting to send those values will block indefinitely: |
| |
| .code pipelines/sqleak.go /first value/,/^}/ |
| |
| This is a resource leak: goroutines consume memory and runtime resources, and |
| heap references in goroutine stacks keep data from being garbage collected. |
| Goroutines are not garbage collected; they must exit on their own. |
| |
| We need to arrange for the upstream stages of our pipeline to exit even when the |
| downstream stages fail to receive all the inbound values. One way to do this is |
| to change the outbound channels to have a buffer. A buffer can hold a fixed |
| number of values; send operations complete immediately if there's room in the |
| buffer: |
| |
| c := make(chan int, 2) // buffer size 2 |
| c <- 1 // succeeds immediately |
| c <- 2 // succeeds immediately |
| c <- 3 // blocks until another goroutine does <-c and receives 1 |
| |
| When the number of values to be sent is known at channel creation time, a buffer |
| can simplify the code. For example, we can rewrite `gen` to copy the list of |
| integers into a buffered channel and avoid creating a new goroutine: |
| |
| .code pipelines/sqbuffer.go /func gen/,/^}/ |
| |
| Returning to the blocked goroutines in our pipeline, we might consider adding a |
| buffer to the outbound channel returned by `merge`: |
| |
| .code pipelines/sqbuffer.go /func merge/,/unchanged/ |
| |
| While this fixes the blocked goroutine in this program, this is bad code. The |
| choice of buffer size of 1 here depends on knowing the number of values `merge` |
| will receive and the number of values downstream stages will consume. This is |
| fragile: if we pass an additional value to `gen`, or if the downstream stage |
| reads any fewer values, we will again have blocked goroutines. |
| |
| Instead, we need to provide a way for downstream stages to indicate to the |
| senders that they will stop accepting input. |
| |
| * Explicit cancellation |
| |
| When `main` decides to exit without receiving all the values from |
| `out`, it must tell the goroutines in the upstream stages to abandon |
| the values they're trying to send. It does so by sending values on a |
| channel called `done`. It sends two values since there are |
| potentially two blocked senders: |
| |
| .code pipelines/sqdone1.go /func main/,/^}/ |
| |
| The sending goroutines replace their send operation with a `select` statement |
| that proceeds either when the send on `out` happens or when they receive a value |
| from `done`. The value type of `done` is the empty struct because the value |
| doesn't matter: it is the receive event that indicates the send on `out` should |
| be abandoned. The `output` goroutines continue looping on their inbound |
| channel, `c`, so the upstream stages are not blocked. (We'll discuss in a moment |
| how to allow this loop to return early.) |
| |
| .code pipelines/sqdone1.go /func merge/,/unchanged/ |
| |
| This approach has a problem: _each_ downstream receiver needs to know the number |
| of potentially blocked upstream senders and arrange to signal those senders on |
| early return. Keeping track of these counts is tedious and error-prone. |
| |
| We need a way to tell an unknown and unbounded number of goroutines to |
| stop sending their values downstream. In Go, we can do this by |
| closing a channel, because |
| [[https://golang.org/ref/spec#Receive_operator][a receive operation on a closed channel can always proceed immediately, yielding the element type's zero value.]] |
| |
| This means that `main` can unblock all the senders simply by closing |
| the `done` channel. This close is effectively a broadcast signal to |
| the senders. We extend _each_ of our pipeline functions to accept |
| `done` as a parameter and arrange for the close to happen via a |
| `defer` statement, so that all return paths from `main` will signal |
| the pipeline stages to exit. |
| |
| .code pipelines/sqdone3.go /func main/,/^}/ |
| |
| Each of our pipeline stages is now free to return as soon as `done` is closed. |
| The `output` routine in `merge` can return without draining its inbound channel, |
| since it knows the upstream sender, `sq`, will stop attempting to send when |
| `done` is closed. `output` ensures `wg.Done` is called on all return paths via |
| a `defer` statement: |
| |
| .code pipelines/sqdone3.go /func merge/,/unchanged/ |
| |
| Similarly, `sq` can return as soon as `done` is closed. `sq` ensures its `out` |
| channel is closed on all return paths via a `defer` statement: |
| |
| .code pipelines/sqdone3.go /func sq/,/^}/ |
| |
| Here are the guidelines for pipeline construction: |
| |
| - stages close their outbound channels when all the send operations are done. |
| - stages keep receiving values from inbound channels until those channels are closed or the senders are unblocked. |
| |
| Pipelines unblock senders either by ensuring there's enough buffer for all the |
| values that are sent or by explicitly signalling senders when the receiver may |
| abandon the channel. |
| |
| * Digesting a tree |
| |
| Let's consider a more realistic pipeline. |
| |
| MD5 is a message-digest algorithm that's useful as a file checksum. The command |
| line utility `md5sum` prints digest values for a list of files. |
| |
| % md5sum *.go |
| d47c2bbc28298ca9befdfbc5d3aa4e65 bounded.go |
| ee869afd31f83cbb2d10ee81b2b831dc parallel.go |
| b88175e65fdcbc01ac08aaf1fd9b5e96 serial.go |
| |
| Our example program is like `md5sum` but instead takes a single directory as an |
| argument and prints the digest values for each regular file under that |
| directory, sorted by path name. |
| |
| % go run serial.go . |
| d47c2bbc28298ca9befdfbc5d3aa4e65 bounded.go |
| ee869afd31f83cbb2d10ee81b2b831dc parallel.go |
| b88175e65fdcbc01ac08aaf1fd9b5e96 serial.go |
| |
| The main function of our program invokes a helper function `MD5All`, which |
| returns a map from path name to digest value, then sorts and prints the results: |
| |
| .code pipelines/serial.go /func main/,/^}/ |
| |
| The `MD5All` function is the focus of our discussion. In |
| [[pipelines/serial.go][serial.go]], the implementation uses no concurrency and |
| simply reads and sums each file as it walks the tree. |
| |
| .code pipelines/serial.go /MD5All/,/^}/ |
| |
| * Parallel digestion |
| |
| In [[pipelines/parallel.go][parallel.go]], we split `MD5All` into a two-stage |
| pipeline. The first stage, `sumFiles`, walks the tree, digests each file in |
| a new goroutine, and sends the results on a channel with value type `result`: |
| |
| .code pipelines/parallel.go /type result/,/}/ HLresult |
| |
| `sumFiles` returns two channels: one for the `results` and another for the error |
| returned by `filepath.Walk`. The walk function starts a new goroutine to |
| process each regular file, then checks `done`. If `done` is closed, the walk |
| stops immediately: |
| |
| .code pipelines/parallel.go /func sumFiles/,/^}/ |
| |
| `MD5All` receives the digest values from `c`. `MD5All` returns early on error, |
| closing `done` via a `defer`: |
| |
| .code pipelines/parallel.go /func MD5All/,/^}/ HLdone |
| |
| * Bounded parallelism |
| |
| The `MD5All` implementation in [[pipelines/parallel.go][parallel.go]] |
| starts a new goroutine for each file. In a directory with many large |
| files, this may allocate more memory than is available on the machine. |
| |
| We can limit these allocations by bounding the number of files read in |
| parallel. In [[pipelines/bounded.go][bounded.go]], we do this by |
| creating a fixed number of goroutines for reading files. Our pipeline |
| now has three stages: walk the tree, read and digest the files, and |
| collect the digests. |
| |
| The first stage, `walkFiles`, emits the paths of regular files in the tree: |
| |
| .code pipelines/bounded.go /func walkFiles/,/^}/ |
| |
| The middle stage starts a fixed number of `digester` goroutines that receive |
| file names from `paths` and send `results` on channel `c`: |
| |
| .code pipelines/bounded.go /func digester/,/^}/ HLpaths |
| |
| Unlike our previous examples, `digester` does not close its output channel, as |
| multiple goroutines are sending on a shared channel. Instead, code in `MD5All` |
| arranges for the channel to be closed when all the `digesters` are done: |
| |
| .code pipelines/bounded.go /fixed number/,/End of pipeline/ HLc |
| |
| We could instead have each digester create and return its own output |
| channel, but then we would need additional goroutines to fan-in the |
| results. |
| |
| The final stage receives all the `results` from `c` then checks the |
| error from `errc`. This check cannot happen any earlier, since before |
| this point, `walkFiles` may block sending values downstream: |
| |
| .code pipelines/bounded.go /m := make/,/^}/ HLerrc |
| |
| * Conclusion |
| |
| This article has presented techniques for constructing streaming data pipelines |
| in Go. Dealing with failures in such pipelines is tricky, since each stage in |
| the pipeline may block attempting to send values downstream, and the downstream |
| stages may no longer care about the incoming data. We showed how closing a |
| channel can broadcast a "done" signal to all the goroutines started by a |
| pipeline and defined guidelines for constructing pipelines correctly. |
| |
| Further reading: |
| |
| - [[https://talks.golang.org/2012/concurrency.slide#1][Go Concurrency Patterns]] ([[https://www.youtube.com/watch?v=f6kdp27TYZs][video]]) presents the basics of Go's concurrency primitives and several ways to apply them. |
| - [[https://blog.golang.org/advanced-go-concurrency-patterns][Advanced Go Concurrency Patterns]] ([[http://www.youtube.com/watch?v=QDDwwePbDtw][video]]) covers more complex uses of Go's primitives, especially `select`. |
| - Douglas McIlroy's paper [[https://swtch.com/~rsc/thread/squint.pdf][Squinting at Power Series]] shows how Go-like concurrency provides elegant support for complex calculations. |