0

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, witch 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?

timohahaa
  • 29
  • 4
  • Put `if ctx.Err() != nil { return }` before the select statement. If you absolutely cannot tolerate expensiveComputation to happen after the context is cancelled (and it isn't feasible to pass the context on to expensiveComputation), put the same check again into the first case. – Peter Jul 30 '23 at 16:46

2 Answers2

1

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)
            }
erik258
  • 14,701
  • 2
  • 25
  • 31
1
  • if you want a way to cleanly interrupt expensiveComputation, you should pass the context to that function :
func expensiveComputation(ctx context.Context, someValue int) {
    ...
}

and have this function check if the context is completed wherever you see fit

  • there are several ways to handle both a context cancelation and a closed channel, one of them could be:
    change the producer so that it closes the channel both when it has finished generating values and when the context is canceled

Here is an example of how to do it:

func producer(ctx context.Context, count int, values chan<- int) {
    defer close(values)

    for i := 0; i < count; i++ {
        select {
        case values <- i:
            // keep iterating the loop
        case <-ctx.Done():
            // exit loop
            break
        }
    }
}

func foo(ctx context.Context, values <-chan int) {
    for i := range values {
        expensiveComputation(ctx, i)
    }
}

func expensiveComputation(ctx context.Context, i int) {
    select {
    case <-ctx.Done():
        fmt.Printf("expensiveComputation %d: cancelled via context\n", i)
    case <-time.After(time.Second):
        fmt.Printf("expensiveComputation %d: completed\n", i)
    }
}

func main() {
    ctx := context.Background()

    ctx, cancel := context.WithTimeout(ctx, 3500*time.Millisecond)
    defer cancel()

    values := make(chan int, 0)
    go producer(ctx, 10, values)

    foo(ctx, values)
}

https://go.dev/play/p/aEm5xTus5CW


Just to illustrate another way to always check for cancellation first, with less modifications to your code: you can add a separate check before your select intruction:

func foo(ctx context.Context, someChannel <-chan int) {
    for {
        // explicitly check for context cancellation first:
        select {
        case <-ctx.Done():
            // context is done: return now
            return
        default:
            // keep running
        }

        select {
        case someValue := <-someChannel:
            expensiveComputation(someValue)
        case <-ctx.Done():
            return
        }
    }
}

If you want to also check whether someChannel is closed, you will have to add a check for the status of the channel as @timohahaa wrote in his answer :

func foo(ctx context.Context, someChannel <-chan int) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
        }

        select {
        case someValue, ok := <-someChannel:
            if !ok {
                // channel is closed, stop now
                return
            }
            expensiveComputation(someValue)
        case <-ctx.Done():
            return
        }
    }
}
LeGEC
  • 46,477
  • 5
  • 57
  • 104