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.
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
}
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 = ×tamp
ticker.Reset(timeout)
} else {
updatedTime = ×tamp
}
}
}
}()
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 = ×tamp
ticker.Reset(timeout)
} else {
updatedTime = ×tamp
}
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