goconcurrencychannelgoroutine

What is the use of <-ctx.Done() in select statments?


So, as explained in this question, select statement chooses a channel operation at random. And I often see a pattern like this:

func foo(ctx context.Context, someChannel <-chan int) {
    for {
        select {
        case someValue := <-someChannel:
            //do some stuff until the context is done
            expensiveComputation(someValue)
        case <-ctx.Done():
            //context is done - either canceled or time is up for timeout
            return
        }
    }
}

but if we can not guarantee, which case in select will fire, it is possible, that case <-ctx.Done() will be selected not instantly, but after a couple of iterations of expensiveComputation(someValue), witch we actually don't need to do anymore, because context is canceled. It is also possible that it will never get selected, but that's too low of a probability...

Go spec also says that "A receive operation on a closed channel can always proceed immediately, yielding the element type's zero value after any previously sent values have been received." So what's also possible is:

  1. sender closed a channel someChannel
  2. sender canceled a context

now in the select statement there will be two operations, that can proceed, available, and one of them will be chosen at random, and again, some computation will potentially be done after context has been canceled.

So how to properly deal with it? Or, if I'm missing something here, what is it?


Solution

  • select statement chooses a channel operation at random

    Randomly selects one channel that is ready for reading.

    it is possible, that case <-ctx.Done() will be selected not instantly, but after a couple of iterations of expensiveComputation(someValue), witch we actually don't need to do anymore, because context is canceled

    So you're describing a situation where the writer to someChannel continues to write or the channel's buffer is not empty, even though the context is cancelled. If someChannel is not ready for reading it will not be selected (unless it's closed, as you say, more on that below). I think you get that, I just wanted to make it explicit that a channel not ready for reading is never selected.

    expensiveComputation(someValue)

    In real life, if you want cancellation to be effected quickly, you'd pass the context to your expensiveComputation so that it could then respect its cancellation. expensiveComputation could then return an indication of whether the context was cancelled during expensiveComputation so that you can drop out of the select immediately. That might look something like:

                if ok := expensiveComputation(ctx, someValue); !ok {
                  // context has been cancelled during `expensiveComputation` 
                  return
                }
    

    A receive operation on a closed channel can always proceed immediately

    To quote the tour (which you should take if you haven't yet!)

    Receivers can test whether a channel has been closed by assigning a second parameter to the receive expression: after

    v, ok := <-ch
    

    ok is false if there are no more values to receive and the channel is closed.

    Keep in mind also that

    Receiving from a nil channel blocks forever

    So if you want to close channels that are read in a select, you need to combine these two ideas: use the second value from the receive to know that the channel is closed, then set the channel to nil so you don't keep reading zero values from it.

    func foo(ctx context.Context, someChannel <-chan int) {
        for {
            select {
            case someValue, ok := <-someChannel:
                if ! ok { 
                   someChannel = nil
                   // this case will never again  be selected
                } else {
                   expensiveComputation(someValue)
                }
            case <-ctx.Done():
                //context is done - either canceled or time is up for timeout
                return
            }
        }
    }
    

    In your case, someChannel is the only source of work for foo, but in some cases there may be multiple channels involved. In your case it might make more sense to return from foo when the channel is closed.

            case someValue, ok := <-someChannel:
                if ! ok { 
                   return // end foo, it will receive no more work
                } else {
                   expensiveComputation(someValue)
                }