Skip to content

Conversation

@alaingilbert
Copy link

@alaingilbert alaingilbert commented Apr 17, 2025

TLDR: Jump to the Solution section to see the working example.

In highly concurrent code, it is sometimes impossible to know how many "waiters" are going to be present at a certain time.

So I made a test where we want to ensure that in thread 1, the counter is incremented after a 1min sleep.
Another thread 2, will sometime create another Sleep, making it impossible to use BlockUntilContext consistently.


First attempt with BlockUntilContext (not working) ->

func TestSleepNotify(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	var calls atomic.Int32
	clock := NewFakeClock()
	afterCh := make(chan struct{})
	go func() { // thread #1
		clock.Sleep(time.Minute) // We want to wait for this before advancing the clock
		calls.Add(1)
		close(afterCh)
	}()
	go func() {
		if rand.Intn(2) == 0 { // 50% chance of making another Sleep
			clock.Sleep(time.Hour)
		}
	}()
	if clock.BlockUntilContext(ctx, 1) != nil { // Value of 1 or 2 will both work sometimes and fail other times
		t.Fatalf("context was cancelled")
	}
	clock.Advance(time.Minute)
	<-afterCh
	if calls.Load() != 1 {
		t.Fatalf("calls is not equal to 1")
	}
}

So instead, I will close beforeCh just before the Sleep that I'm interested in, to advance the clock when the goroutine is ready to Sleep.
But this fails (sometimes) because the "waiter" is not yet registered when we close the beforeCh so we sometime advance the clock too early.

(not working)

func TestSleepNotify(t *testing.T) {
	var calls atomic.Int32
	clock := NewFakeClock()
	beforeCh := make(chan struct{})
	afterCh := make(chan struct{})
	go func() { // thread #1
		close(beforeCh) // This is too early as the waiter is not yet created
		clock.Sleep(time.Minute) // We want to wait for this before advancing the clock
		calls.Add(1)
		close(afterCh)
	}()
	go func() {
		if rand.Intn(2) == 0 { // 50% chance of making another Sleep
			clock.Sleep(time.Hour)
		}
	}()
	<-beforeCh
	clock.Advance(time.Minute)
	<-afterCh
	if calls.Load() != 1 {
		t.Fatalf("calls is not equal to 1")
	}
}

Solution is SleepNotify

SleepNotify will ensure that the "waiter" is registered before closing the "beforeCh" channel.
So we will only ever advance the clock when the thread we're interested in, is actually sleeping.

func TestSleepNotify(t *testing.T) {
	var calls atomic.Int32
	clock := NewFakeClock()
	beforeCh := make(chan struct{})
	afterCh := make(chan struct{})
	go func() { // thread #1
		clock.SleepNotify(time.Minute, beforeCh) // We want to wait for this before advancing the clock
		calls.Add(1)
		close(afterCh)
	}()
	go func() {
		if rand.Intn(2) == 0 { // 50% chance of making another Sleep
			clock.Sleep(time.Hour)
		}
	}()
	<-beforeCh
	clock.Advance(time.Minute)
	<-afterCh
	if calls.Load() != 1 {
		t.Fatalf("calls is not equal to 1")
	}
}

This is how you can run the 3 examples and see that only the last solution works

go test -race -count 10 -run TestSleepNotify

…tered but before actually waiting for the Sleep to complete
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant