Goroutine death under control

Original author: Gustavo Niemeyer
  • Transfer

Introduction


Recently I came across a small useful package and decided to share the find. To do this, I am publishing a translation of an article discussing the problem of correctly completing goroutine from the outside and offering a solution as the very smallest tomb package .

Article Translation


One of the reasons why people are attracted to Go is definitely the first-class approach to concurrency. Opportunities such as communication through channels, lightweight processes (goroutines) and their proper planning are not only native to the language, but also tastefully integrated into it.

If you listen to conversations in the community for several days, then there is a good chance that you will hear someone proudly note the principle:

Do not communicate using shared memory, share memory using communication.

There is a blog post on this subject , as well as an interactive code walk exercise .

This model is very practical, and when developing algorithms, you can get significant gains if you approach the task from this perspective, but this is not news.

In my article, I want to refer to the currently open question in Go related to this approach: completion of background activity.

As an example, let's create a specially simplified goroutine that sends strings through a pipe:

type LineReader struct {
        Ch chan string
        r  *bufio.Reader
}
func NewLineReader(r io.Reader) *LineReader {
        lr := &LineReader{
                Ch: make(chan string),
                r:  bufio.NewReader(r),
        }
        go lr.loop()
        return lr
}

The LineReader structure has a Ch channel through which the client can receive lines, as well as an internal buffer r (inaccessible from the outside) used to efficiently read these lines. The NewLineReader function creates an initialized LineReader, starts a read cycle and returns the created structure.

Now let's look at the loop itself:

func (lr *LineReader) loop() {
        for {
                line, err := lr.r.ReadSlice('\n')
                if err != nil {
                        close(lr.Ch)
                        return
                }
                lr.Ch <- string(line)
        }
}

In the loop, we get the line from the buffer, in case of an error, close the channel and stop, otherwise - transfer the line to the other side, possibly blocking while it is doing its own thing. This is all clear and familiar to the Go-developer.

But there are two details associated with the completion of this logic: firstly, information about the error is lost, and secondly, there is no clean way to interrupt the procedure from the outside. Of course, an error can be easily pledged, but what if we want to save it in a database, or send it by wire, or even process it, taking into account its nature? The possibility of a clean stop is also valuable in many cases, for example, for running from under the test runner.

I am not saying that this is something that is difficult to do in any way. I want to say that today there is no generally accepted approach for handling these issues in a simple and consistent way. Or maybe not. The tomb package for Go is my experiment in trying to solve the problem.

The work model is simple: Tomb keeps track of whether the goroutine is alive, dying or dead, as well as the cause of death.

To understand this model, let's see how this concept applies to the LineReader example. As a first step, you need to modify the creation process to add Tomb support:

type LineReader struct {
        Ch chan string
        r  *bufio.Reader
        t  tomb.Tomb
}
 
func NewLineReader(r io.Reader) *LineReader {
        lr := &LineReader{
                Ch: make(chan string),
                r:  bufio.NewReader(r),
        }
        go lr.loop()
        return lr
}

It looks very similar. Only a new field in the structure, even the creation function, has not changed.

Next, we modify the loop function to support error tracking and interruption:

func (lr *LineReader) loop() {
       defer lr.t.Done()
       for {
               line, err := lr.r.ReadSlice('n')
               if err != nil {
                       close(lr.Ch)
                       lr.t.Kill(err)
                       return
               }
               select {
               case lr.Ch <- string(line):
               case <-lr.t.Dying():
                       close(lr.Ch)
                       return
               }
       }
}

Let's note a few interesting points: first, just before the loop function completes, Done is called to track the completion of the goroutine. Then, a previously unused error is now passed to the Kill method , which marks goroutine as dying. Finally, the link to the channel has been changed so that it is not blocked if goroutine dies for any reason.

Tomb has Dying and Dead channels , returned by methods of the same name, which close when Tomb changes its state accordingly. These channels allow you to organize an explicit lock until the state changes, as well as selectively unlock the select expression in such cases, as shown above.

Having such a modified loop as described above, it is easy to implement the Stop method to request pure synchronous goroutine completion from the outside:

func (lr *LineReader) Stop() error {
       lr.t.Kill(nil)
       return lr.t.Wait()
}

In this case, the Kill method will put the tomb into a dying state from outside the goroutine running, and the Wait method will block until the goroutine completes and reports it through the Done method , as shown above. This procedure behaves correctly even if the goroutine was already dead or in a dying state due to internal errors, because only the first call to the Kill method with a real error is remembered as the cause of the death of goroutine. The nil value passed to t.Kill is used as the reason for a clean termination without an actual error and causes Wait to return nil at the end of the goroutine, which indicates a net stop on the Go idioms.

That's actually all that can be said on the topic. When I started developing on Go 1, I was wondering if more support from the language is needed to come up with a good agreement for such problems, such as some kind of tracking the state of goroutine itself, similar to what Erlang does with its lightweight processes, but it turned out that this is more a matter of organizing the work process using existing building blocks.

The tomb package and its type Tomb is an actual representation of a good arrangement for completing a goroutine, with familiar method names, inspired by existing idioms. If you want to take advantage of this, then the package can be obtained using the command:

$ go get launchpad.net/tomb

Detailed API documentation is available at:

gopkgdoc.appspot.com/pkg/launchpad.net/tomb

Good luck!

Also popular now: