Golang How to differentiate between Context cancel and timeout

eye-catch Golang

Context is explained in the following way on the official site.

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.

https://pkg.go.dev/context

If you want to cancel your processes from the parent, the cancel command somehow needs to be propagated to the children. The Context supports the feature. This post explains how to use context with cancel and timeout. Furthermore, how to implement it if you don’t want to stop the process but want to introduce timeout.

This post contains Goroutine and Channel like the following.

func runChannelWithContext() {
    channel := make(chan string)    // channel
    ctx, cancel := context.WithCancel(context.Background())

    go sendWithContext(ctx, channel)    // Goroutine
    go receiveWithContext(ctx, channel) // Goroutine

    time.Sleep(3 * time.Second)
    cancel()
    time.Sleep(2 * time.Second)
}

Please check the following post, if you don’t know how to use them.

Golang Async/Await with Goroutine and Channel
Golang offers Goroutine and Channel to make Async implementation easy.
Sponsored links

Done: Keep the process running until it receives cancellation

The code looks like this before using context.

import (
    "fmt"
    "time"
)

const numberOfMsg = 3

func send(channel chan string) {
    for i := 0; i < numberOfMsg; i++ {
        channel <- fmt.Sprintf("Hello: %d", (i + 1))
        time.Sleep(time.Second)
    }
}

func receive(channel chan string) {
    for {
        data := <-channel
        fmt.Printf("Received: [%s]\n", data)
    }
}

send function sends data 3 times. The number of messages is defined on numberOfMsg variable.

Now, we want to remove the limitation.

func sendWithContext(ctx context.Context, channel chan int) {
    for i := 0; ; i++ { // numberOfMsg is removed
        select {
        case <-ctx.Done():
            fmt.Println("send loop ends")
            return
        default:
            channel <- fmt.Sprintf("Hello: %d", (i + 1))
            time.Sleep(time.Second)
        }
    }
}

func receiveWithContext(ctx context.Context, channel chan string) {
    for {
        select {
        case data := <-channel:
            fmt.Printf("Received: %s\n", data)
        case <-ctx.Done():
            fmt.Println("receive loop ends")
            return
        }
    }
}

Remember that the Context should be the first parameter. The variable name should be ctx. Context has Done method that returns Channel. If it’s canceled by someone else, it can receive a notification.

It’s important to write both statements in the select clause. If you implement it in the following way, it doesn’t end the process because data := <-channel waits until it receives something.

func receiveWithContext(ctx context.Context, channel chan string) {
    for {
        data := <-channel   // wait for the data...
        fmt.Printf("Received: %s\n", data)

        select {
        case <-ctx.Done():
            fmt.Println("receive loop ends")
            return
        }
    }
}

By writing them in the select clause, the program processes the data that comes first.

Sponsored links

CancelFunc: Cancel the process

It’s easy to cancel the process. There are several methods provided but WithCancel method is the one that we need to use for the cancellation.

func runChannelWithContext() {
    channel := make(chan string)
    ctx, cancel := context.WithCancel(context.Background())

    go sendWithContext(ctx, channel)
    go receiveWithContext(ctx, channel)

    time.Sleep(3 * time.Second)
    cancel()
    time.Sleep(2 * time.Second)
}

It returns a cancel function and if it’s called, the process is notified to the context. The result looks like the following.

$ go run main.go 
Received: Hello: 1
Received: Hello: 2
Received: Hello: 3
receive loop ends: context canceled
send loop ends context canceled

If the main event loop is called later, it might be better to call the cancel function with defer keyword to make sure that the process ends at the end.

func runChannelWithContext() {
    channel := make(chan string)
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    go sendWithContext(ctx, channel)
    go receiveWithContext(ctx, channel)

    doSomethingForLong() // main event loop
}

Timeout

There are some cases where timeout needs to be implemented as well as the cancellation. context.WithTimeout can be used in this case.

Its implementation is basically the same as the previous one. Just change the method name and set the timeout duration.

func runChannelWithTimeout() {
    channel := make(chan string)
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)

    go sendWithContext(ctx, channel)
    go receiveWithContext(ctx, channel)

    time.Sleep(5 * time.Second)
    cancel()
}

The second parameter for WithTimeout is the duration for timeout. It’s 2 seconds. So the process ends before cancel function is called.

$ go run main.go 
Received: Hello: 1
Received: Hello: 2
send loop ends context deadline exceeded
receive loop ends: context deadline exceeded

How to differentiate between Timeout and Cancellation

Let’s call the cancel function before the timeout.

func runChannelWithTimeout() {
    channel := make(chan string)
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)

    go sendWithContext(ctx, channel)
    go receiveWithContext(ctx, channel)

    time.Sleep(1 * time.Second)
    cancel()
    time.Sleep(2 * time.Second)
}

The result is the following.

$ go run main.go 
Received: Hello: 1
Received: Hello: 2
receive loop ends: context canceled
send loop ends context canceled

The error message is different.

  • Timeout: context deadline exceeded
  • Cancel: context canceled

We can use the difference in the Done call. Context package has the corresponding error variables.

  • Timeout -> context.DeadlineExceeded
  • Cancel -> context.Canceled

So we can use them in the select clause.

func runChannelWithTimeout() {
    channel := make(chan string)
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)

    go sendWithContext(ctx, channel)
    // go receiveWithContext(ctx, channel)
    go receiveWithTimeout(ctx, channel)

    time.Sleep(5 * time.Second)
    cancel()
}

func receiveWithTimeout(ctx context.Context, channel chan string) {
    for {
        select {
        case data := <-channel:
            fmt.Printf("Received: %s\n", data)
        case <-ctx.Done():
            err := ctx.Err()
            if errors.Is(err, context.Canceled) {
                fmt.Println("Canceled")
            } else if errors.Is(err, context.DeadlineExceeded) {
                fmt.Println("Timeout")
            }
            return
        }
    }
}

When it’s timeout

$ go run main.go 
Received: Hello: 1
Received: Hello: 2
Received: Hello: 3
Timeout
send loop ends context deadline exceeded

When it’s canceled

$ go run main.go 
Received: Hello: 1
send loop ends context canceled
Canceled

Keep the process but want to introduce timeout

If you want to keep the process but want to introduce timeout, it needs to be implemented in a different way because once the context is canceled or timeout, the case of ctx.Done() is always executed.

Let’s remove return keyword from the previous version.

func receiveWithTimeout(ctx context.Context, channel chan string) {
    for {
        select {
        case data := <-channel:
            fmt.Printf("Received: %s\n", data)
        case <-ctx.Done():
            err := ctx.Err()
            if errors.Is(err, context.Canceled) {
                fmt.Println("Canceled")
            } else if errors.Is(err, context.DeadlineExceeded) {
                fmt.Println("Timeout")
            }
            // return
        }
    }
}

Then, try to execute it.

$ go run main.go 
Received: Hello: 1
Received: Hello: 2
Received: Hello: 3
Timeout
Timeout
Timeout
Timeout
Timeout
...

Tons of Timeout messages are shown in a very short time. This is not what we want.

We want something like that a client sends a request to someone else and consumes the result if it receives the data in the expected time. Otherwise, do something else.

Let’s see the code. It can be implemented it by using time.After(delay).

func sendWithContextWithRandomTime(ctx context.Context, channel chan string) {
    for i := 0; ; i++ {
        select {
        case <-ctx.Done():
            fmt.Printf("send loop ends %s\n", ctx.Err().Error())
            return
        default:
            channel <- fmt.Sprintf("Hello: %d", (i + 1))
            // Sleep duration is random
            randomDelay :=time.Duration(rand.Intn(2000)) * time.Millisecond
            time.Sleep(randomDelay)
        }
    }
}

func receiveWithCancelAndTimeout(ctx context.Context, channel chan string) {
    for {
        select {
        case data := <-channel:
            fmt.Printf("Received: %s\n", data)
        case <-time.After(time.Second):
            fmt.Println("timeout")
        case <-ctx.Done():
            fmt.Printf("receive loop ends: %s\n", ctx.Err().Error())
            return
        }
    }
}

If it receives the data within a second, it consumes the data. If not, timeout is printed.

The result looks like this.

$ go run main.go 
Received: Hello: 1
Received: Hello: 2
timeout
Received: Hello: 3
timeout
Received: Hello: 4
Received: Hello: 5
Received: Hello: 6
timeout
receive loop ends: context canceled
send loop ends context canceled

Comments

Copied title and URL