Golang Implement Debounce logic in different ways

eye-catch Golang

There are multiple ways to implement Debounce Logic in Golang. I want to compare the differences and check which one is the best solution. The implementation requires knowledge about time.Timer. Please check the following post if you are not familiar with it.

It also shows how to implement Debounce but this post adds a feature. If input is continuously provided, the callback is never triggered. We want to address this issue in this post.

Sponsored links

Simple Debounce implementation

Let’s see the simple implementation. This code is written in the post above too.

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

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

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

    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()
    defer m.mutex.Unlock()

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

There is no problem if this is used for user input. What if a system sends data depending on something e.g. file, DB, system state, etc… The callback will never be triggered in this case. This is what we want to solve in this post.

By the way, it’s not necessary to drain timer.C in this way for AfterFunc() because it’s always nil.

if !m.timer.Stop() {
    <-m.timer.C
}
Sponsored links

Introduce force timeout that triggers the callback

If the Debounce() function is repeatedly called, the specified callback is not triggered. Let’s add an additional timeout to trigger the callback forcibly. We need startedTime to calculate how long it has been elapsed from the first Debounce() call.

type Debouncer2 struct {
    Timeout      time.Duration
    ForceTimeout time.Duration
    Callback     func()
    timer        *time.Timer
    startedTime  *time.Time
    mutex        sync.Mutex
}

func NewDebouncer2(timeout, forceTimeout time.Duration, callback func()) *Debouncer2 {
    return &Debouncer2{
        Timeout:      timeout,
        ForceTimeout: forceTimeout,
        Callback:     callback,
    }
}

func (m *Debouncer2) Debounce() {
    if m.timer == nil {
        m.assignTimerOnlyOnce()

        return
    }

    m.mutex.Lock()
    defer m.mutex.Unlock()

    if m.startedTime != nil && time.Since(*m.startedTime) > m.ForceTimeout {
        m.startedTime = nil
        m.Callback()

        return
    }

    now := time.Now()
    if !m.timer.Stop() {
        m.startedTime = &now
    } else {
        if m.startedTime == nil {
            m.startedTime = &now
        }
    }

    m.timer.Reset(m.Timeout)
}

func (m *Debouncer2) assignTimerOnlyOnce() {
    callback := func() {
        m.mutex.Lock()
        defer m.mutex.Unlock()

        if m.startedTime != nil {
            m.startedTime = nil
            m.Callback()
        }
    }

    m.timer = time.AfterFunc(m.Timeout, callback)
    now := time.Now()
    m.startedTime = &now
}

This part checks the interval between the first call and the last call. If it’s bigger than ForceTimeou, it triggers the callback.

if m.startedTime != nil && time.Since(*m.startedTime) > m.ForceTimeout {
    m.startedTime = nil
    m.Callback()

    return
}

In other cases, the program reaches here. Stop() returns false If the callback is already called because the specified time has already been elapsed. In this case, it must be handled as the first call because it is the first call after the last trigger.

now := time.Now()
if !m.timer.Stop() {
    m.startedTime = &now
} else {
    if m.startedTime == nil {
        m.startedTime = &now
    }
}

Why do we need the following code? If Stop() returns true, it means that the callback has not been triggered or callback was called by ForceTimeout. startedTime must be initialized in the latter case.

if m.startedTime == nil {
    m.startedTime = &now
}

By the way, startedTime must be guarded by mutex because the callback could be triggered while processing Debounce() function.

Since assignTimerOnlyOnce() is called only once, it can be set in NewDebouncer in the following way.

type Debouncer2_2 struct {
    Timeout      time.Duration
    ForceTimeout time.Duration
    Callback     func()
    timer        *time.Timer
    startedTime  *time.Time
    mutex        sync.Mutex
}

func NewDebouncer2_2(timeout, forceTimeout time.Duration, callback func()) *Debouncer2_2 {
    result := &Debouncer2_2{
        Timeout:      timeout,
        ForceTimeout: forceTimeout,
        Callback:     callback,
    }

    internalCallback := func() {
        result.mutex.Lock()
        defer result.mutex.Unlock()

        if result.startedTime != nil {
            result.startedTime = nil
            result.Callback()
        }
    }

    result.timer = time.AfterFunc(timeout, internalCallback)
    result.timer.Stop()

    return result
}

func (m *Debouncer2_2) Debounce() {
    m.mutex.Lock()
    defer m.mutex.Unlock()

    if m.startedTime != nil && time.Since(*m.startedTime) > m.ForceTimeout {
        m.startedTime = nil
        m.Callback()

        return
    }

    now := time.Now()
    if !m.timer.Stop() {
        m.startedTime = &now
    } else {
        if m.startedTime == nil {
            m.startedTime = &now
        }
    }

    m.timer.Reset(m.Timeout)
}

Pros and Cons

Pros

  • It’s easy to understand

Cons

  • It consumes CPU power if Debounce() is called in short interval

This implementation looks fine at first. However, Stop() and Reset() are called whenever Debounce() is called. If the interval is short, it consumes CPU power.

Use channel and time.After()

To improve the implementation above, I considered using channel and time.After(). The implementation is shorter than the previous one.

type Debouncer3 struct {
    timeChan chan time.Time
}

func NewDebouncer3(ctx context.Context, timeout, forceTimeout time.Duration, callback func()) *Debouncer3 {
    result := &Debouncer3{
        timeChan: make(chan time.Time, 100),
    }

    go func() {
        var startedTime *time.Time

        for {
            select {
            case <-ctx.Done():
                return
            case <-time.After(timeout):
                if len(result.timeChan) == 0 && startedTime == nil {
                    continue
                }

                startedTime = nil

                callback()
            case calledTime := <-result.timeChan:
                if startedTime == nil {
                    startedTime = &calledTime
                } else if time.Since(*startedTime) > forceTimeout {
                    startedTime = nil
                    callback()
                }
            }
        }
    }()

    return result
}

func (m *Debouncer3) Debounce() {
    m.timeChan <- time.Now()
}

The second case with time.After is executed when Debounce() is not called for the specified time. If the length of the channel is 0, it means Debounce() is not called or called only once. If it’s called once, startedTime is also set.

if len(result.timeChan) == 0 && startedTime == nil {
    continue
}

The third case is executed when Debounce() is called. Initialize startedTime for the first call. Otherwise, check the elapsed time between the first call and the current call. It’s NOT the last call. A new value is added to the channel while the goroutine is running. Therefore, it could not be the last call.

if startedTime == nil {
    startedTime = &calledTime
} else if time.Since(*startedTime) > forceTimeout {
    startedTime = nil
    callback()
}

Pros and Cons

Pros

  • Simple implementation

Cons

  • High frequent memory allocation
  • time.Since() is called a lot
  • The goroutine keeps running every second

time.After creates a channel. While Debounce() is repeatedly called, the channel is just created and released without using it. I think it’s not a big problem but we can still improve it.

Use channel and time.Ticker

The main problem in the previous examples is to do something whenever Debounce() is called. Let’s improve this point here.

type Debouncer4 struct {
    timeChan chan time.Time
}

func NewDebouncer4(ctx context.Context, timeout, forceTimeout time.Duration, callback func()) *Debouncer4 {
    instance := &Debouncer4{
        timeChan: make(chan time.Time, 100),
    }

    go func() {
        var startedTime *time.Time
        var updatedTime *time.Time

        ticker := time.NewTicker(timeout)
        ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                if updatedTime == nil {
                    ticker.Stop()
                    startedTime = nil
                    updatedTime = nil
                    callback()

                    continue
                }

                now := time.Now()
                diffForceTimeout := startedTime.Add(forceTimeout).Sub(now)
                diffNormalTimeout := updatedTime.Add(timeout).Sub(now)

                diff := diffNormalTimeout
                if diffForceTimeout < diffNormalTimeout {
                    diff = diffForceTimeout
                }

                if diff <= 0 {
                    ticker.Stop()
                    startedTime = nil
                    updatedTime = nil
                    callback()

                    continue

                }

                ticker.Reset(diff)
            case timestamp := <-instance.timeChan:
                if startedTime == nil {
                    startedTime = &timestamp
                    ticker.Reset(timeout)
                } else {
                    updatedTime = &timestamp
                }
            }
        }
    }()

    return instance
}

func (m *Debouncer4) Debounce() {
    m.timeChan <- time.Now()
}

Let’s check the third case first. startedTime must be initialized for the first call and reset the ticker. The ticker starts running by this call. Otherwise, update the timestamp.

case timestamp := <-instance.timeChan:
    if startedTime == nil {
        startedTime = &timestamp
        ticker.Reset(timeout)
    } else {
        updatedTime = &timestamp
    }

Let’s check the second case that looks the most complicated in this post. If Debounce() is called only once, this if clause is executed.

if updatedTime == nil {
    ticker.Stop()
    startedTime = nil
    updatedTime = nil
    callback()

    continue
}

If Debounce() is called twice, the program reaches here. It calculates the remaining time to trigger the callback. Then, take the shorter one.

now := time.Now()
diffForceTimeout := startedTime.Add(forceTimeout).Sub(now)
diffNormalTimeout := updatedTime.Add(timeout).Sub(now)

diff := diffNormalTimeout
if diffForceTimeout < diffNormalTimeout {
    diff = diffForceTimeout
}

The calculation part seems to be written in the following way.

diffForceTimeout := time.Since(*startedTime) - forceTimeout
diffNormalTimeout := time.Since(*updatedTime) - timeout

Note that this way is not the same as the one above because different now time is used in the two time.Since() call. The difference might be nanoseconds but if you want to make it more precise, the first implementation should be used.

The last part is either trigger or reset. If the diff is 0 or negative, it’s time to trigger the callback. If it’s positive, the timer needs to be reset with the calculated remaining time.

if diff <= 0 {
    ticker.Stop()
    startedTime = nil
    updatedTime = nil
    callback()

    continue

}

ticker.Reset(diff)

Pros and Cons

Pros

  • A few executions even though highly frequent input
  • The goroutine keeps sleeping until Debounce() is called

Cons

  • The implementation is complicated

Take Benchmark for the timer reset

It’s better to take the benchmark for the logic used in the examples above.

package benchmark_test

import (
    "testing"
    "time"
)

func BenchmarkTimerAfterFunc(b *testing.B) {
    timer := time.AfterFunc(time.Hour, func() {})

    for i := 0; i < b.N; i++ {
        timer.Stop()
        timer = time.AfterFunc(time.Hour, func() {})
    }
}

func BenchmarkTimerTimer(b *testing.B) {
    timer := time.NewTimer(time.Hour)

    for i := 0; i < b.N; i++ {
        timer.Stop()
        timer.Reset(time.Hour)
    }
}

func BenchmarkTimerTimerWithIf(b *testing.B) {
    timer := time.NewTimer(time.Hour)

    for i := 0; i < b.N; i++ {
        if !timer.Stop() {
            <-timer.C
        }
        timer.Reset(time.Hour)
    }
}

func BenchmarkTimerTicker(b *testing.B) {
    ticker := time.NewTicker(time.Hour)
    ticker.Stop()

    for i := 0; i < b.N; i++ {
        ticker.Reset(time.Hour)
    }
}

func BenchmarkTimerTicker2(b *testing.B) {
    ticker := time.NewTicker(time.Hour)

    for i := 0; i < b.N; i++ {
        ticker.Stop()
        ticker.Reset(time.Hour)
    }
}

func BenchmarkTimerAfter(b *testing.B) {
    for i := 0; i < b.N; i++ {
        select {
        case <-time.After(time.Nanosecond):
        case <-time.After(time.Millisecond):
        }
    }
}

// $ go test ./benchmark -benchmem -bench Timer
// goos: linux
// goarch: amd64
// pkg: play-with-go-lang/benchmark
// cpu: Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz
// BenchmarkTimerAfterFunc-12               8241452               150.3 ns/op            80 B/op          1 allocs/op
// BenchmarkTimerTimer-12                  18680098                62.07 ns/op            0 B/op          0 allocs/op
// BenchmarkTimerTimerWithIf-12            18710127                61.95 ns/op            0 B/op          0 allocs/op
// BenchmarkTimerTicker-12                 31019991                39.37 ns/op            0 B/op          0 allocs/op
// BenchmarkTimerTicker2-12                16327724                65.91 ns/op            0 B/op          0 allocs/op
// BenchmarkTimerAfter-12                  1209708                991.1 ns/op           400 B/op          6 allocs/op
// PASS
// ok      play-with-go-lang/benchmark     6.126s

It shows that Reseting a timer is faster than re-assigning a timer by AfterFunc(). There’s no difference between BenchmarkTimerTimer and BenchmarkTimerTimerWithIf because Stop() always returns true.

If Stop() is not called, it’s 1.6 times faster than calling Stop() and Reset().

Overview

I showed 3 ways to implement debounce logic. Even though the last one is the best way, it might be a bit complicated and too much for some systems. Choose the simpler one if you are sure that your system doesn’t have highly frequent inputs.

Comments

Copied title and URL