Golang How to implement Debounce and how to use time.Timer properly

eye-catch Golang

timer.Timer is simple but it’s not as straightforward to use it as it looks.

Sponsored links

What can we do with time.Timer?

There are two functions to create a new instance of time.Timer. It’s time.NewTimer and time.AfterFunc. Let’s see the difference.

When creating an instance by time.NewTimer, we have to receive a notification from timer.C channel to do something after the specified time elapses.

func runTimer1() {
    timer := time.NewTimer(time.Second)

    var wg sync.WaitGroup
    wg.Add(1)

    go func(timer *time.Timer) {
        <-timer.C
        fmt.Println("this is executed after 1 second")
        wg.Done()
    }(timer)

    wg.Wait()
}

When using time.AfterFunc, we don’t have to use timer.C. The callback is automatically called after the specified time elapses.

func runTimer2() {
    var wg sync.WaitGroup
    wg.Add(1)

    callback := func() {
        fmt.Println("this is executed after 1 second")
        wg.Done()
    }

    timer := time.AfterFunc(time.Second, callback)

    wg.Wait()
}

This time.Timer can be reset and stop.

timer.Stop()
timer.Reset()

It looks so simple but we have to be careful when using Stop/Reset.

Sponsored links

How to Stop time.Timer correctly

When calling Stop() method, the timer could be already expired because the timer is running concurrently. We can know whether the timer has expired or not by the return value of Stop() function.

if timer.Stop() {
    // timer stopped
} else {
    // timer was already expired
}

It’s better to consume timer.C if timer.Stop() returns false to make the subsequent process work properly.

// Don't use this code
if !timer.Stop() {
    <-timer.C
}

However, <-timer.C could block if it is already consumed. To avoid the case, it’s better to write it in the following way.

if !timer.Stop() {
    select {
        case <-timer.C:
        default:
    }
}

If you don’t need to reset the timer, the select clause is not needed.

How to Reset time.Timer correctly

timer.Reset() resets the timer. There are two cases when using this function.

  1. The timer has not been expired yet and needs to be reset and extended the time
  2. The timer has already expired

The timer must be stopped or has expired when calling timer.Reset() because it’s not possible to know whether the notification to the channel timer.C is before or after the function call if timer.Reset() is called for the running timer.

Therefore, timer.Stop() must be called first. Even if the timer is not expired when timer.Reset() is called, the state could change while the reset process is running. timer.Stop() must always be called.

// NG code
if !timer.Stop() {
    <-timer.C: // this blocks when timer has been expired
}
timer.Reset()

However, <-timer.C could block in a case where the timer has already expired because it’s already consumed. As I mentioned above, it must be written in a select clause.

if !timer.Stop() {
    select {
        case <-timer.C:
        default:
    }
}
timer.Reset()

Channel timer.C is not assigned when using AfterFunc

I used timer.AfterFunc. It blocked when I added the stop function call with <-timer.C. Let’s try one thing.

func runTimer2() {
    var wg sync.WaitGroup
    wg.Add(1)

    callback := func() {
        fmt.Println("this is executed after 1 second")
        wg.Done()
    }

    timer := time.AfterFunc(time.Second, callback)

    wg.Wait()
    fmt.Println(<-timer.C) // Added here
}

The result is as follows.

this is executed after 1 second
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive (nil chan)]:
play-with-go-lang/utils.runTimer2()
        /workspaces/play-with-go-lang/utils/timer.go:42 +0xa6
play-with-go-lang/utils.RunTimer(...)
        /workspaces/play-with-go-lang/utils/timer.go:11
main.main()
        /workspaces/play-with-go-lang/main.go:47 +0x10
exit status 2

I thought timer.C is already consumed on another goroutine to call the callback but it’s not.

If we check the code in GitHub, we can see the comment below.

// AfterFunc waits for the duration to elapse and then calls f
// in its own goroutine. It returns a Timer that can
// be used to cancel the call using its Stop method.
// The returned Timer's C field is not used and will be nil. <----- this line was added
func AfterFunc(d Duration, f func()) *Timer {

I checked the comment in version 1.21.5 but it is not added.

It means that we don’t have to take care about the channel timer.C when using time.AfterFunc.

How to Debounce in Golang

If Debounce is needed, it can be implemented by using time.AfterFunc. As we know, we don’t have to take care of channel timer.C. So we just call timer.Stop() followed by timer.Reset().

type Debouncer struct {
    timeout  time.Duration
    timer    *time.Timer
    callback func()
}

func NewDebounce(timeout time.Duration, callback func()) Debouncer {
    return Debouncer{
        timeout:  timeout,
        callback: callback,
    }
}

func (m *Debouncer) Debounce() {
    if m.timer == nil {
        m.timer = time.AfterFunc(m.timeout, m.callback)

        return
    }

    m.timer.Stop()
    m.timer.Reset(m.timeout)
}

I think the following code is enough if the callback needs to be updated.

func (m *Debouncer) UpdateDebounceCallback(callback func()) {
    m.timer.Stop()
    m.timer = time.AfterFunc(m.timeout, callback)
}

If the two functions are called on other goroutines, it must be locked by Mutex. It would be good to know about atomic if you don’t know it.

type Debouncer struct {
    timeout  time.Duration
    timer    *time.Timer
    callback func()
    mutex    sync.Mutex // Added
}

func (m *Debouncer) Debounce() {
    m.mutex.Lock()         // Added
    defer m.mutex.Unlock() // Added

    if m.timer == nil {
        m.timer = time.AfterFunc(m.timeout, m.callback)

        return
    }

    m.timer.Stop()
    m.timer.Reset(m.timeout)
}

func (m *Debouncer) UpdateDebounceCallback(callback func()) {
    m.mutex.Lock()         // Added
    defer m.mutex.Unlock() // Added

    m.timer.Stop()
    m.timer = time.AfterFunc(m.timeout, callback)
}

It can be used in this way below.

func runDebounce() {
    var wg sync.WaitGroup
    wg.Add(1)

    debounceCallCount := 0
    callback := func() {
        fmt.Printf("Debounce call count: %d\n", debounceCallCount)
        wg.Done()
    }
    debouncer := NewDebounce(time.Second, callback)

    debounceCallCount++
    debouncer.Debounce()
    time.Sleep(500 * time.Millisecond)

    debounceCallCount++
    debouncer.Debounce()
    time.Sleep(500 * time.Millisecond)

    debounceCallCount++
    debouncer.Debounce()
    time.Sleep(500 * time.Millisecond)

    wg.Wait()
}

When this function is called, it shows only Debounce call count: 3. It’s properly debounced.

Overview

There are two ways to instantiate time.Timer.

  • time.NewTimer()
  • time.AfterFunc()

Drain timer.C before calling timer.Reset()

if !timer.Stop() {
    select {
        case <-timer.C:
        default:
    }
}
timer.Reset()

When using time.AfterFunc(), we don’t have to care about the channel timer.C because it’s nil.

Comments

Copied title and URL