Croll
Croll

Reputation: 3791

Go async/await pattern with context cancellation

In my application I need async/await pattern with context cancellation support. In practice I have a function like:

func longRunningTask() <-chan int32 {
    r := make(chan int32)

    go func() {
        defer close(r)
        
        // Simulate a workload.
        time.Sleep(time.Second * 3)
        r <- rand.Int31n(100)
    }()

    return r
}

However, it will not support context cancellation. In order to fix this, I can add an argument and modify function to wait for ctx.Done() channel signal in a select statement, to abort an operation if context is cancelled.

If done this way, the function will not properly abort if run twice or more times (because the context pointer will be shared), since context cancellation channel only receives one signal:

ctx := ...
go func() { r := <-longRunningTask(ctx) } // Done() works
go func() { r := <-longRunningTask(ctx) } // ?
// cancel() ...

Here is what I see about Done:

   // go/context.go

   357  func (c *cancelCtx) Done() <-chan struct{} {
   358      c.mu.Lock()
   359      if c.done == nil {
   360          c.done = make(chan struct{})
   361      }
   362      d := c.done
   363      c.mu.Unlock()
   364      return d
   365  } // Done() returns the same channel for all callers, and cancellation signal is sent once only
  1. Does the go source mean context does not really support abortion of a function that calls other "long-running" functions, "a chained cancellation"?

  2. What are options to write asynchronious functions that will support context cancellation in an unlimited recursion of .Done() usage?

Upvotes: 0

Views: 2707

Answers (1)

colm.anseo
colm.anseo

Reputation: 22097

  1. Does the go source mean context does not really support abortion of a function that calls other "long-running" functions, "a chained cancellation"?

No. A task can call other long-running tasks, passing a context down the call chain. This is a standard practice. And if a context is canceled, a nested call will error and bubble-up the cancelation error along the call stack

  1. What are options to write asynchronious functions that will support context cancellation in an unlimited recursion of .Done() usage?

Recursion is no different that a couple of nested calls that take a context. Provided the recursive calls take a context input parameter and return an error (that is check), a recursive call chain will bubble-up a cancelation event just like a set of non-recursive nested calls.


First, let's update your wrapper function to support context.Context:

func longRunningTask(ctx context.Context) <-chan int32 {
    r := make(chan int32)

    go func() {
        defer close(r)
        
        // workload
        i, err := someWork(ctx)
        if err != nil {
            return
        }
        r <- i
    }()

    return r
}

And then someWork - to use the sleep workload would look like this:

func someWork(ctx context.Context) (int32, error) {

    tC := time.After(3*time.Second) // fake workload

    // we can check this "workload" *AND* the context at the same time
    select {
    case <-tC:
        return rand.Int31n(100), nil
    case <-ctx.Done():
        return 0, ctx.Err()
    }
}

The important thing to note here is, we can alter the fake "workload" (time.Sleep) in a way that it becomes a channel - and thus watch it and our context via a select statement. Most workloads are of course not this trivial...

Fortunately the Go standard library is full of support for context.Context. So if your workload consists of lots of potentially long-running SQL query, each query can be passed a context. Same with HTTP requests or gRPC calls. If your workload consists of any of these calls, passing in the parent context will cause any of these potentially blocking calls to return with an error when the context is canceled - and thus your workload will return with a cancellation error, letting the caller know what happened.

If your workload does not fit neatly into this model e.g. computing a large Mandelbrot-Set image. Checking the context for cancelation after every pixel can have negative performance impact as polling selects are not free:

select {
case <-ctx.Done(): // polling select when `default` is included
    return ctx.Err()
default:
}

In cases like this, tuning could be applied and if say pixels are calculated at a rate of 10,000/s - polling the context every 10,000 pixels will ensure the task will return no later than 1 second from the point of cancelation.

Upvotes: 2

Related Questions