ctx->Done not getting called for ContextWithTimeout

Hi,

I’m new to go and I’m trying to understand why the following code doesn’t work. I have a context with a timeout and I am doing a for loop with a select with <-ctx.Done() as the first case. I expected the function to exit when the timeout happens but it doesn’t. Can someone please tell me what I am doing wrong?

package main

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

func main() {
	ctx, cancel := context.WithTimeout(context.TODO(), time.Second)
	defer cancel()
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Ended at:", time.Now())
			return
		default:
			fmt.Println(".")
		}
	}
}

This also fails to exit

func main() {
	ctx, cancel := context.WithTimeout(context.TODO(), time.Second)
	defer cancel()
	for ctx.Err() == nil {
		fmt.Println(".", ctx, ctx.Err())
	}
}

Theses programs does work for me.
I wouldn’t consider them correct as they are redlining the CPU at 100% while polling.

As your number of cores and OS vary polling might work better or worst.

Something like this works on the playground (instead of just my CPU):

package main

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

func main() {
	ctx, cancel := context.WithTimeout(context.TODO(), time.Second)
	defer cancel()
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Ended at:", time.Now())
			return
		default:
			fmt.Println(".")
			time.Sleep(time.Second / 4)
		}
	}
}
func main() {
	ctx, cancel := context.WithTimeout(context.TODO(), time.Second)
	defer cancel()
	for ctx.Err() == nil {
		fmt.Println(".", ctx, ctx.Err())
                time.Sleep(time.Second/4)
	}
}

The people writing the scheduler tries to make it able to handle cases like this, but it seems like there is a bug and it doesn’t properly track timers or something.
Even when the scheduler is able to solve polling loops it is a huge performance cost so you shouldn’t write this code on purpose.

1 Like

The first one I wouldn’t consider correct too.
Since the default branch is always ready, it would be legal to choose it always:
A select … chooses one at random if multiple are ready

But for me as well, both terminate.
Which platform/version are you using?

It runs forever on the playground.

The problem here is that context was never cancelled. You defer-ed it. Defer means that the function will execute when the func where you defined it exits. Main will never do, since you use infinite loop. To exit from the loop with context, you need to call cancel() without defer.

Did you try to debug it? What system do you use?

That was what I was thinking of as well. The original code I had was actually reading some bytes at the default case and would wait for 100 ms for at least one byte. I wrote a mock for the reader using bytes.Buffer and put a 100 ms wait inside the Read to mimic the behavior. It would intermittently fail when running the test suite and always pass on its own. Maybe the 100 ms wait wasn’t enough for Go to be able to process the ctx->Done.

Excellent point. I hadn’t realized that select chooses one at random. I thought the cases were placed in priority. However, the second case does not use a select.

I’m using go version 1.22. I did try it on the playground for both version 1.21 and 1.22 and they both fail. I was running it inside a go test on my Windows machine when I first saw the behavior. However, I’ve run it on my Windows machine as a simple app and that was fine.

Yes, I have tried to debug it. If I put print statements, they show me that done is never called even when the timeout has passed. But if I put a breakpoint in, it always works.

Default fires only if none of the cases are ready.
https://go.dev/tour/concurrency/6

If it’s windows machine, then maybe it’s something similar to time definition issues on windows, because of the system clock. Here runtime: time.Sleep takes more time than expected on Windows (1ms -> 10ms) · Issue #44343 · golang/go · GitHub. One of the comments advise to try windows.TimeBeginPeriod(1)

Could be as well. From the answers I’ve got here. It looks like the use of ctx is not really recommended inside tight loops.

That’s good to know. Thanks for pointing that out.

An alternate form for @Jorropo’s suggestion of putting some kind of wait/sleep in the loop to stop it from constantly polling:

ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
defer cancel()

for {
	select {
	case <-ctx.Done():
		fmt.Println("Ended at:", time.Now())
		return
	case <-time.Tick(1 * time.Second):
		fmt.Println(".")
	}
}

Yep, that works too.