Debounce 的定義:「在事件連續觸發時,只在最後一次觸發後過了指定時間才執行。」

搜尋框輸入每個字元都打一次 API——用 debounce 讓使用者停止輸入 300ms 後才打。Log 寫入每次都觸發 flush——debounce 讓它合併成批量寫入。

這個功能在前端很常見,但後端同樣用得到(批量寫入、限制回呼頻率、合併重複事件)。

Debounce 的實作需要三樣東西:

  1. Closure:記住上一次的 timer
  2. Timer:延遲執行
  3. 並行控制:如果有多個 goroutine/thread,要確保 timer 的讀寫是安全的

JavaScript / Node.js

JavaScript 最自然——closure 和 timer 是語言的核心功能:

function debounce(fn, delay) {
    let timer = null;  // 閉包變數,記住 timer
 
    return function(...args) {
        if (timer) {
            clearTimeout(timer);  // 取消上一個 timer
        }
        timer = setTimeout(() => {
            fn.apply(this, args);
            timer = null;
        }, delay);
    };
}
 
// 使用
const debouncedSave = debounce((data) => {
    console.log("saving:", data);
}, 300);
 
debouncedSave("a");  // 取消,因為 300ms 內又來了
debouncedSave("ab"); // 取消
debouncedSave("abc"); // 300ms 後執行

為什麼在 Node.js 不需要鎖:Node.js 是單 thread 的 event loop,JavaScript 不會有兩個 callback 同時執行(concurrent 但不是 parallel),所以 timer 這個閉包變數不存在 race condition。


Python(asyncio 版)

Python 的 asyncio 版本,思路和 JavaScript 類似,但需要用 asyncio.create_taskasyncio.CancelledError

import asyncio
from typing import Callable, Any
 
def debounce(fn: Callable, delay: float):
    task: asyncio.Task | None = None
 
    async def wrapper(*args: Any, **kwargs: Any) -> None:
        nonlocal task
        if task is not None:
            task.cancel()  # 取消上一個 task
 
        async def delayed():
            await asyncio.sleep(delay)
            await fn(*args, **kwargs)
 
        task = asyncio.create_task(delayed())
 
    return wrapper
 
# 使用
async def save(data: str):
    print(f"saving: {data}")
 
debounced_save = debounce(save, 0.3)
 
async def main():
    await debounced_save("a")
    await debounced_save("ab")
    await debounced_save("abc")  # 只有這個會在 300ms 後執行
    await asyncio.sleep(0.5)    # 等待執行完成
 
asyncio.run(main())

asyncio 的單 thread 特性:和 JavaScript 一樣,asyncio 是協作式並行,task 的讀寫在同一個 event loop 裡,不需要鎖。


Go

Go 的版本更複雜,因為 goroutine 是真正的並行——多個 goroutine 可能同時呼叫 debounced function,timer 變數需要保護:

import (
    "sync"
    "time"
)
 
func Debounce(fn func(), delay time.Duration) func() {
    var mu sync.Mutex
    var timer *time.Timer
 
    return func() {
        mu.Lock()
        defer mu.Unlock()
 
        if timer != nil {
            timer.Stop()
        }
        timer = time.AfterFunc(delay, func() {
            fn()
            mu.Lock()
            timer = nil
            mu.Unlock()
        })
    }
}
 
// 使用
save := func() {
    fmt.Println("saving...")
}
debouncedSave := Debounce(save, 300*time.Millisecond)
 
// 從多個 goroutine 安全地呼叫
go debouncedSave()
go debouncedSave()
debouncedSave()  // 只有最後一個在 300ms 後執行

需要 mutex 的原因

Go 的 goroutine 是 M:N scheduling,可以真正在多個 CPU 核心上平行執行。如果不加鎖:

Goroutine A: 讀 timer == nil
Goroutine B: 讀 timer == nil
Goroutine A: 建立新 timer,timer = timerA
Goroutine B: 建立新 timer,timer = timerB(覆蓋了 timerA,但 timerA 不會被 Stop!)

結果:timerA 和 timerB 都會在 300ms 後觸發,fn 執行了兩次。


三個實作的對照

JavaScriptPython (asyncio)Go
Closurelet timer = nullnonlocal taskvar timer *time.Timer
取消舊 timerclearTimeouttask.cancel()timer.Stop()
建新 timersetTimeoutasyncio.create_tasktime.AfterFunc
需要鎖?不需要(單 thread)不需要(協作式)需要(真正並行)

這三個實作揭示了 B01 前幾篇的核心差異

  • Closure:三個語言都用到,語法不同但概念相同(B01-09)
  • Timer 的取消語意clearTimeout vs task.cancel() vs timer.Stop()——Go 的 Stop() 不保證 callback 不執行(如果 timer 已經觸發),需要額外處理
  • 並行模型決定了你是否需要鎖(B01-12):event loop 天生安全,goroutine 需要顯式鎖

Go 的 Timer 細節(值得注意)

time.AfterFunc 回傳的 *Timer.Stop() 的語意是「嘗試停止」——如果 timer 已經觸發,.Stop() 返回 false,callback 可能已經在另一個 goroutine 裡跑了。

更嚴謹的 Go 實作需要用 channel 或 atomic 確保「停止和執行只有一個能發生」,但對大多數業務場景,上面的版本已經夠用,race window 極小。

這種細節差異,正是「理解模型」vs「只記語法」的分水嶺——知道 Go timer 的語意,你才知道為什麼官方文件說 Stop() 之後要額外判斷。

下一篇:3 語言實作 Worker Pool