Search code examples
goconcurrency

How do we guarantee that a cancelled context will lead to a goroutine's termination?


Suppose that the following situation occurs:

  • We have the Consumer function below, running in a goroutine.

  • Another goroutine is sending integers on the intChan channel without any delay. In other words, on every iteration of the for-loop, there is a value ready to be received on the intChan.

  • The goroutine that started the Consumer goroutine, has cancelled the context passed into the Consumer. Hence, the ctx.Done() channel also has a value ready to be received.

Question:

  • In this situation, both the cases of the select statement are ready to run.
  • According to the tour of Go, the select will pick one case randomly, since both are ready to run.
  • What's the guarantee that the select won't keep picking the <- intChan case? How do we know that the <- ctx.Done() case will eventually be selected, if both cases are ready in every iteration of the for-loop?
func Consumer(ctx context.Context, intChan chan int) {
    for {
        select {
        case <-ctx.Done():
            return
        case i := <-intChan:
            foo(i)
        }
    }
}

I've tried using the Consumer function, in the program below. Both the Consumer and Producer goroutines always seem to terminate, in several runs of this program.

Why don't we end up with runs where the <-ctx.Done() case is never executed?

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

func main() {

    ctx, cancelFunc := context.WithCancel(context.Background())

    var wg sync.WaitGroup
    wg.Add(2) // add 2, because we spawn 2 goroutines

    Producer(ctx, &wg)

    fmt.Println(time.Now())
    time.Sleep(time.Second * 5) // cancel the context after 5 seconds

    cancelFunc()
    fmt.Println("CANCELLED")

    wg.Wait() // wait till both producer and consumer goroutines terminate
    fmt.Println(time.Now())

}

func Producer(ctx context.Context, wg *sync.WaitGroup) {
    intChan := make(chan int)

    go Consumer(ctx, intChan, wg)

    go func() {
        defer wg.Done()
        for {
            select {
            case <-ctx.Done():
                return
            case intChan <- 1:
            }
        }
    }()

}

func Consumer(ctx context.Context, intChan chan int, wg *sync.WaitGroup) {
    defer wg.Done()

    for {
        select {
        case <-ctx.Done():
            return
        case _ = <-intChan:
        }
    }
}

Solution

  • There is no guarantee. The simplest way to guarantee termination would be to check for error with ctx.Err() outside the select statement. It is also common to return the error back to the code that passed the context. I would write the Consumer func like this:

    func Consumer(ctx context.Context, intChan chan int) error {
        for ctx.Err() == nil {
            select {
            case <-ctx.Done():
            case i := <-intChan:
                foo(i)
            }
        }
        return ctx.Err()
    }