搜尋框綁 onInput 直接打 API,使用者打「debounce」這個字,你就打了 8 次 API。

更糟的是後面那段:8 個請求不保證照順序回來。第 3 個字的結果比第 8 個字晚到,畫面就會顯示成搜尋「deb」的結果——使用者明明已經打完了。你的 server 白扛 7 倍流量,還換來一個錯的畫面。

這就是 debounce 存在的原因:在事件連續觸發時,只在最後一次觸發後過了指定時間才執行。使用者停手 300 毫秒才打那一次 API,前面 7 次連送都不送。

這個功能在前端很常見,但後端同樣用得到(批量寫入、限制回呼頻率、合併重複事件)。而它剛好是一個小到能一眼看完、又同時用上三個核心機制的練習題:

  1. Closure(閉包,函式結束後仍然記得住外層變數的能力):記住上一次的 timer
  2. Timer:延遲執行
  3. 並行控制:如果有多個 goroutine / thread 同時呼叫它,要確保 timer 的讀寫不會打架

同一個東西在三個語言寫一次,最有意思的不是語法差異,是第 3 點在 JavaScript 和 Python 裡根本不用寫,到了 Go 就非寫不可。那個「為什麼」就是三個語言並行模型的差別。


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_task 和 asyncio.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 是協作式並行——「協作式」的意思是,切換時機由程式自己決定(寫了 await 才讓出控制權),不是作業系統隨時可以打斷你。所以兩段程式碼之間不會被硬插隊,task 的讀寫都在同一個 event loop 裡,不需要鎖。


Go

Go 的版本更複雜,因為 goroutine(Go 的輕量執行單位,開一個的成本遠低於 OS thread,所以可以開幾萬個)是真正的平行——它不是輪流,是同一瞬間真的有多個在跑。多個 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(M 個 goroutine 被排到 N 個 OS thread 上跑,由 Go 自己的排程器分配),可以真正在多個 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(goroutine 之間傳遞訊息的管道)或 atomic(由 CPU 保證「讀了就改、中間不會被插隊」的操作)確保「停止和執行只有一個能發生」。但對大多數業務場景,上面的版本已經夠用——race window(兩者可能撞在一起的那個時間縫隙)極小。

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

→ 下一篇:3 語言實作 Worker Pool