📌 30 秒摘要(讀完用自己的話寫一句)
這頁在講:Go 官方部落格 2010–2013 年的六篇經典。我逐篇對照現行文件查證過——結論是過時程度差很多:
defer/panic/recover與反射三法則一字未改仍成立;切片與 race detector 機制對、周邊 API 或平台清單過時;而 error handling 那篇今天照做會錯。
🗺 心智圖(Canvas)
Go Blog 經典六篇
Link to original 🐹 Go Blog 經典六篇
🧯 錯誤與清理
🎯 什麼情境該想到我
當你「函式有很多條 return 路徑,怕漏掉清理」,或在猶豫「這裡該 panic 還是回 error」的時候。
⚙️ 怎麼用(步驟 / 公式)
defer 的三條規則(背這三條就夠)
- 參數在
defer那一行就求值,不是執行時:func a() { i := 0 defer fmt.Println(i) // 印 0,不是 1 i++ }- LIFO 順序,在外層函式 return 之後執行:
for i := 0; i < 4; i++ { defer fmt.Print(i) } // 印 3210- ⭐ defer 可以讀寫具名回傳值:
這條是在返回前統一加工 error 的關鍵手法。 func c() (i int) { defer func() { i++ }() return 1 // 實際回傳 2 }用法:開了就馬上 defer 關
src, err := os.Open(srcName) if err != nil { return } defer src.Close() // 就寫在 Open 旁邊 dst, err := os.Create(dstName) if err != nil { return } // src 仍會被關掉 defer dst.Close() return io.Copy(dst, src)兩個好處:不會忘記關(之後加新的 return 路徑也不會漏),以及關的地方就在開的旁邊,比擺在函式尾端清楚。
同樣用在mu.Lock(); defer mu.Unlock()。⭐ panic 的邊界——這條慣例是 Go 的標準
Go 函式庫的慣例是:即使套件內部使用 panic,它對外的 API 仍然回傳明確的 error 值。
標準庫的實例:
encoding/json用遞迴函式編碼,遇到錯誤時用panic把堆疊一路展開回最上層,在那裡 recover 並轉成 error 回傳。
所以判準是:panic 可以當內部的控制流捷徑,但不可以穿過套件邊界。
recover只在 deferred 函式裡有效;沒被 recover 的 panic 會一路到 goroutine 堆疊頂端並終止程式。5. ★
defer的兩個時機規則(最常搞錯的地方)func f() { x := 1 defer fmt.Println("A:", x) // ★ 參數「現在」就求值 → 印 A: 1 defer func() { fmt.Println("B:", x) // ★ 閉包「執行時」才讀 → 印 B: 2 }() x = 2 } // 輸出(後進先出):B: 2 然後 A: 1
規則 說明 ① 參數在 defer那一行就求值defer f(x)記下的是「當下的 x」② 函式本體在 return 之後才執行 閉包裡讀到的是「最終的值」 ③ 後進先出(LIFO) 多個 defer 反序執行 ★ 想要「執行時才決定」就包一層閉包;想要「當下的快照」就直接傳參數。
6. ★
defer+ 具名回傳值 = 可以改回傳值func doWork() (err error) { // ★ 具名回傳值 defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) // ★ 改掉回傳的 err } }() mustSucceed() return nil }這是 把 panic 轉成 error 的標準寫法,也是把「內部用 panic 簡化錯誤傳遞」的技巧封裝在套件邊界的方法
(encoding/json與text/template內部都這樣做)。另一個常用場景:包裝錯誤
func query(ctx context.Context) (err error) { defer func() { if err != nil { err = fmt.Errorf("query: %w", err) } // ★ 所有 return 路徑統一包裝 }() ... }7. ★★
defer在迴圈裡 = 資源洩漏// ❌ 所有檔案都要等函式結束才關 —— 檔案數多就爆 fd for _, name := range files { f, err := os.Open(name) if err != nil { return err } defer f.Close() // ★ 累積到函式結束 process(f) } // ✅ 包成一個函式,讓 defer 有正確的邊界 for _, name := range files { if err := func() error { f, err := os.Open(name) if err != nil { return err } defer f.Close() // ★ 每輪結束就關 return process(f) }(); err != nil { return err } }★ 「defer 的作用域是函式,不是區塊」 —— 這是它和 C++ RAII / Rust drop 最大的差異。
8. ★
recover只在「被 defer 直接呼叫的函式」裡有效// ❌ 沒用:recover 不在 defer 直接呼叫的函式裡 defer func() { helper() }() func helper() { recover() } // ★ 攔不到 // ❌ 沒用:不是在 defer 裡 if r := recover(); r != nil { } // ★ 永遠是 nil // ✅ 正確 defer func() { if r := recover(); r != nil { ... } }()★ 而且 panic 跨不過 goroutine 邊界:
go func() { panic("boom") // ★★ 直接終止「整個程式」,呼叫端的 recover 攔不到 }()→ 每個
go出去的函式都該有自己的defer recover(),尤其是處理外部輸入的 worker。9. 什麼時候該用 panic(很少)
該用 不該用 程式設計錯誤(不變式被破壞、不可能到達的分支) ★ 可預期的失敗(檔案不存在、網路錯誤、輸入不合法) 套件初始化失敗( regexp.MustCompile)業務邏輯的分支 內部深層遞迴的錯誤傳遞(★ 但必須在套件邊界 recover 轉成 error) 跨 API 邊界 ★ Go 的慣例很明確:錯誤用回傳值,panic 留給「程式寫錯了」。
一個函式會 panic 這件事沒有出現在它的簽章裡,呼叫端無從得知——這就是為什麼它不該是常規的錯誤機制。🧪 我實際套用的紀錄
- 2026-08-29:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 迴圈裡的
defer會累積到函式結束(見上)。這是 fd / 連線洩漏最常見的來源。- ★
defer的錯誤不要吞掉。defer f.Close()會丟掉 Close 的錯誤——
寫入的檔案尤其致命(Close 才會 flush,錯誤代表資料沒寫進去):defer func() { if cerr := f.Close(); cerr != nil && err == nil { err = cerr } }()- ★
recover之後程式狀態是不確定的。panic 發生時可能有一半的工作做完了、鎖還鎖著、資料半寫。
recover 適合「記錄並讓這一個請求失敗」,不適合「假裝沒事繼續跑」。- ★ goroutine 裡的 panic 會終止整個程式(見上)。HTTP server 的 handler 有內建 recover,
但你自己go出去的沒有。defer有成本(雖然 Go 1.14 之後的 open-coded defer 已經很便宜)。極熱的迴圈上可以手動處理。os.Exit不會執行 defer。log.Fatal也是(它內部呼叫os.Exit)——
★ 所以log.Fatal不該在需要清理資源的地方用,也不該在函式庫裡用。- panic 訊息會印出整個 goroutine 堆疊,可能含敏感資料——生產環境的 panic 處理要過濾。
🔗 相關工具
- 工具-Go現代錯誤處理 —— 錯誤該用回傳值,panic 是例外
- 工具-Go錯誤該包還是該轉 —— defer + 具名回傳值統一包裝
- 工具-靠通訊共享記憶體 —— ★ 每個 goroutine 都要自己 recover
- 工具-用例外處理錯誤 —— 其他語言的對照
- 工具-防禦式編程 —— 什麼該斷言、什麼該回錯
- Effective Go、Go Blog 經典六篇
🎯 什麼情境該想到我
當你要包裝、辨識或合併錯誤的時候。
⚠️ 這張卡是用來取代官方 2011 年〈Error handling and Go〉的做法的。 那篇教的 type assertion 辨錯與
net.Error.Temporary(),今天照做會錯(查證見 Go Blog 經典六篇)。⚙️ 怎麼用(步驟 / 公式)
1. 包裝時用
%w,保留原因if err := doThing(); err != nil { return fmt.Errorf("處理訂單 %s: %w", id, err) // %w 而不是 %v }
%v會把錯誤壓成字串、切斷追溯鏈;%w保留原始錯誤,讓下游還認得出來。2. 辨識「是哪一個」用
errors.Is(Go 1.13+)if errors.Is(err, os.ErrNotExist) { ... } // ✅ if err == os.ErrNotExist { ... } // ❌ 被包裝過就失效
Is會沿著Unwrap鏈整棵樹找。3. 辨識「是哪一種型別」用
errors.AsType(Go 1.26+)或errors.As(1.13+)// Go 1.26 起,官方文件明說「For most uses, prefer AsType」 if pathErr, ok := errors.AsType[*os.PathError](err); ok { log.Println(pathErr.Path) } // 1.26 之前 var pathErr *os.PathError if errors.As(err, &pathErr) { ... }兩者都會走整棵錯誤樹;直接用 type assertion
err.(*os.PathError)只看最外層,被包裝就漏掉——這正是 2011 那篇的做法。4. 多個錯誤一起回報用
errors.Join(1.20+)return errors.Join(err1, err2, err3) // nil 會被忽略合併後仍可被
errors.Is/AsType穿透,比fmt.Errorf("multiple: %v", errs)好得多。5. 自訂錯誤要能被解開
帶結構化欄位時,實作
Unwrap() error(或多錯誤的Unwrap() []error),否則Is/AsType走不進去。5. ★ 三種錯誤設計,怎麼選
設計 長相 呼叫端怎麼判斷 何時用 ① 不透明(opaque) fmt.Errorf("...")只能印出來 ★ 預設就用這個——呼叫端不需要分辨時 ② 哨兵(sentinel) var ErrNotFound = errors.New("not found")errors.Is(err, ErrNotFound)呼叫端要區分幾種固定情況 ③ 型別(typed) type ValidationError struct{ Field string }errors.AsType[*ValidationError](err)★ 呼叫端需要錯誤裡的資料(哪個欄位錯了、retry-after 幾秒) ★ 從①開始,有需求再升級。
哨兵與型別錯誤都是 API 的一部分——一旦公開,就不能隨便改,而且呼叫端會依賴它。
不要一開始就為每種失敗定義一個 sentinel,那是把內部細節變成契約。6. ★ 什麼時候該包裝、什麼時候不該
// ✅ 加上「這層在做什麼」的脈絡 if err := db.Query(ctx, q); err != nil { return fmt.Errorf("load user %s: %w", id, err) } // ❌ 沒有新資訊,只是讓訊息變長 if err := doThing(); err != nil { return fmt.Errorf("doThing failed: %w", err) // ★ 呼叫堆疊已經說了是 doThing } // ❌ 每一層都包 → 「a: b: c: d: e: connection refused」三條規則:
規則 說明 ① 只在「加得出新脈絡」時包裝 識別碼、操作名稱、參數——呼叫端看不到的東西 ② ★ 跨套件邊界時包裝 內部錯誤往外傳時,加上你這一層的語義 ③ ★ 用 %w還是%v是一個 API 決定%w= 公開承諾「底層錯誤可被判斷」;%v= 切斷,把它變成不透明。
不想讓呼叫端依賴底層是哪個資料庫的錯 → 用%v7. ★ 錯誤訊息的寫法
// ✅ 小寫開頭、不加句點、不加 "error"/"failed" —— 因為它會被嵌進更長的句子 errors.New("connection refused") fmt.Errorf("open %s: %w", path, err) // → 最終使用者看到:「sync config: open /etc/app.yaml: permission denied」 // ❌ errors.New("Error: Failed to connect.") // → 「sync config: Error: Failed to connect.: ...」 ★ 讀起來很糟★ 把錯誤訊息想成「句子的一節」而不是「一個完整的句子」。
8.
errors.Join(Go 1.20+):多個錯誤一起回報var errs []error for i, item := range items { if err := validate(item); err != nil { errs = append(errs, fmt.Errorf("item %d: %w", i, err)) } } if err := errors.Join(errs...); err != nil { // ★ 全 nil 時回傳 nil return err } // errors.Is / AsType 會走整棵樹,所以 Join 起來的每一個都判斷得到適用:表單驗證(要一次回報所有錯誤)、批次處理、多個清理動作的
defer。
不適用:只要第一個錯誤就該中止的流程。9. ★★ 只在一個地方處理錯誤:處理 or 回傳,不要兩個都做
// ❌ 最常見的錯誤處理反模式 if err != nil { log.Error("failed to save", "err", err) // ★ log 了 return err // ★ 又回傳了 } // → 同一個錯誤在每一層各 log 一次,一個請求失敗產生五筆重複的 error log★ 規則:中間層只「包裝並回傳」,只有最上層(HTTP handler、main、worker 迴圈)才 log。
這樣一個失敗只會有一筆 log,而且帶著完整的包裝鏈:
handle request: load user abc: query: connection refused最上層該做的事:
if err != nil { // ① 記錄(含 trace id) slog.ErrorContext(ctx, "request failed", "err", err, "path", r.URL.Path) // ② 轉成對外的表示(★ 不要把內部錯誤訊息直接回給使用者) switch { case errors.Is(err, ErrNotFound): http.Error(w, "not found", 404) case errors.Is(err, ErrForbidden): http.Error(w, "forbidden", 403) default: http.Error(w, "internal error", 500) } }🧪 我實際套用的紀錄
- 2026-08-29:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 不要「log 了又回傳」(見上)。這是 Go 錯誤處理最常見的反模式,會讓日誌無法使用。
- ★
%w是 API 承諾。一旦用了,呼叫端就可能開始errors.Is(err, sql.ErrNoRows)——
你之後換資料庫就是 breaking change。不想承諾就用%v。errors.Is比較的是「值」,AsType/As比較的是「型別」。搞混會永遠判斷失敗。- ★ 自訂錯誤型別要實作
Unwrap() error,否則errors.Is/As走不進去。
包裝多個時實作Unwrap() []error(配合errors.Join的語義)。- ★ 非 nil 的 nil 錯誤:回傳
var e *MyError = nil給error介面 →err != nil為真。
見 工具-Go介面小而隱式 的完整說明。- 不要把
context.Canceled當成錯誤記錄。使用者取消是正常行為,記成 ERROR 會污染告警。
errors.Is(err, context.Canceled)時降級成 INFO 或直接忽略。- 不要用錯誤做流程控制。
if errors.Is(err, ErrNotFound) { create() }可以,
但「用 panic/error 取代正常分支」不行。- 錯誤訊息不要洩漏內部資訊給外部使用者(SQL、路徑、堆疊)——log 裡留完整的,回應裡給概括的。
🔗 相關工具
- 工具-Go錯誤該包還是該轉 —— 更深入的包裝 vs 轉換決策
- 工具-Go介面小而隱式 —— ★ 非 nil 的 nil error
- 工具-defer-panic-recover —— 什麼時候該用 panic(很少)
- 工具-讓測試失敗訊息有用 —— 同一套「訊息要有用」的紀律
- 工具-Go命名慣例 ——
ErrXxxvsXxxError- 工具-遙測與監控 —— 錯誤該在哪一層被記錄
- Go Blog 經典六篇、Modern Go Guidelines
🧱 資料與反射
🎯 什麼情境該想到我
當你「改了一個切片,另一個切片的內容也跟著變」,或「只留下一小段資料,記憶體卻降不下來」的時候。
⚙️ 怎麼用(步驟 / 公式)
1. 心智模型:切片是「指向陣列的表頭」
一個切片值只有三個欄位:指標、長度(len)、容量(cap)。它不擁有資料,只是描述底層陣列的一段。
→ 所以重新切片不會複製,兩個切片可能指向同一塊記憶體。這是所有陷阱的根源。2. 陷阱一:共享底層陣列
把切片傳進函式、或
s[1:3]這樣切,得到的是同一塊記憶體的另一個視角。改其中一個,另一個看得到。
要真的獨立,就copy:c := make([]byte, len(b)) copy(c, b)3. ⭐ 陷阱二:一小段切片會把整塊記憶體釘住
func FindDigits(filename string) []byte { b, _ := os.ReadFile(filename) // 讀進整個檔案 return digitRegexp.Find(b) // ⚠️ 回傳的切片指向整份檔案的陣列 }回傳的
[]byte指向包含整個檔案的陣列……只要這個切片還在,GC 就不能釋放那個陣列;幾個有用的位元組,讓整份檔案留在記憶體裡。修法就是回傳前複製一份:
b = digitRegexp.Find(b) c := make([]byte, len(b)) copy(c, b) return c4. append 的行為
append在容量夠時就地寫入(會影響共用同一陣列的其他切片);容量不夠時配置新陣列並複製(從此與原本脫鉤)。
所以append之後兩個切片是否還共享,取決於當時 cap 夠不夠——這是不確定的,不要依賴它。5. ★★ 最陰險的一個:
append覆寫「別人的」資料base := []int{1, 2, 3, 4, 5} a := base[:2] // [1 2] len=2, cap=5 ← ★ cap 是 5 不是 2 b := base[2:4] // [3 4] a = append(a, 99) // ★ cap 還夠,就地寫進 base[2] fmt.Println(b) // [99 4] ★ b 被改掉了! fmt.Println(base) // [1 2 99 4 5]根因:
s[:n]不會限制 cap,所以append會往後覆寫還在被別人使用的區段。修法:三索引切片
s[low:high:max],把 cap 釘住a := base[:2:2] // ★ len=2, cap=2 → append 必定重新配置,不會碰到 base a = append(a, 99) fmt.Println(base) // [1 2 3 4 5] ★ 安全★ 回傳「內部 slice 的一段」給外部時,一定要用三索引或
slices.Clone。
否則呼叫端一append就會默默改壞你的內部狀態。6.
nil切片 vs 空切片var a []int // nil:a == nil,len=0,cap=0 b := []int{} // 非 nil:b != nil,len=0
行為 nil切片空切片 len/cap/range/append✅ 完全正常 ✅ == niltrue false json.Marshal★ null★ []★ JSON 那一行是實務上最常咬人的:API 回傳
null而不是[],前端就炸了。
→ 回傳 slice 的 API,要嘛保證初始化成[]T{},要嘛在前端處理 null。
慣例:內部用var s []T(零值可用),對外序列化前確保非 nil。7. Go 1.21+ 的
slices套件(別再自己寫)slices.Clone(s) // ★ 深一層複製,取代手寫 make+copy slices.Delete(s, i, j) // 刪除區間(★ 會就地搬移,原 slice 失效) slices.Insert(s, i, v...) // 插入 slices.Contains(s, v) // 有沒有 slices.Index(s, v) // 位置 slices.Sort(s) / SortFunc // 見 [[快速排序]] slices.BinarySearch(s, v) // 見 [[二分搜尋]] slices.Equal(a, b) // 逐元素比較 slices.Reverse(s)⚠️
slices.Delete會把後面的元素往前搬,並把尾端清零(Go 1.22 起)——
原本的 slice 變數在呼叫後就不該再使用,要用回傳值。8. ★ 記憶體釘住:
slices.Clip與明確複製// 大 buffer 裡只要一小段時 small := slices.Clone(big[100:110]) // ★ 徹底脫鉤,big 可被 GC
slices.Clip(s)則是把 cap 縮到 len(釋放尾端多餘容量,但底層陣列仍是同一塊)。🧪 我實際套用的紀錄
- 2026-08-29:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 回傳內部 slice 的一段給外部 = 把內部狀態交出去。用
slices.Clone或三索引s[a:b:b]。- ★ 不要依賴
append會不會重新配置。cap 夠就就地改、不夠就複製——這是實作細節,且會隨版本變。copy只複製min(len(dst), len(src))個。dst沒配置長度(只有 cap)時copy會複製 0 個——
一定要make([]T, len(src))而不是make([]T, 0, len(src))。slices.Clone只複製一層。[][]int、[]*T、map欄位要再往下(見 原型模式)。- 大 slice 傳值不貴(只有 24 bytes 的表頭),但傳進去被改了就是被改了。要唯讀語義就用
iter.Seq或複製。- ★ Go 1.22 起迴圈變數每輪新建,所以
for _, v := range s { go func(){ use(v) }() }不再有「所有 goroutine 看到同一個 v」的經典 bug。但 go.mod 的 go 版本要 ≥ 1.22 才生效。s = append(s[:i], s[i+1:]...)刪除元素會留下尾端的懸掛參考(若元素是指標)→ 記憶體洩漏。用slices.Delete(它會清零)。- 並行讀寫同一個 slice 仍然是資料競爭。slice 本身沒有任何同步保證(見 工具-用race偵測器抓資料競爭)。
🔗 相關工具
- 原型模式 —— ★ 深淺拷貝的完整說明(哪些欄位要手動複製)
- 鏈結串列 —— 為什麼 Go 幾乎總是該用 slice
- 堆疊與佇列 —— slice 當堆疊/佇列的正確寫法與記憶體洩漏
- 二分搜尋、快速排序 ——
slices.BinarySearch/slices.Sort- 工具-用race偵測器抓資料競爭 —— 並行存取 slice
- 工具-Go現代錯誤處理 —— 同屬 Go 的語言細節
- Effective Go、Go Blog 經典六篇
🎯 什麼情境該想到我
當你用
reflect,**卡在「為什麼我改不動這個值」**的時候。⚙️ 怎麼用(步驟 / 公式)
三條法則
1. 反射從介面值走到反射物件。
反射的本質就是檢視 interface 變數裡存的那組(型別, 值)配對。入口是兩個型別:t := reflect.TypeOf(x) // reflect.Type v := reflect.ValueOf(x) // reflect.Value2. 反射從反射物件走回介面值。
i := v.Interface() // 把型別與值資訊包回 interface{}所以它是可逆的——這也是為什麼
fmt.Println(v.Interface())會印出原本的值。3. ⭐ 要修改反射物件,這個值必須是「可設定的」(settable)。
這是最微妙的一條,也是絕大多數reflect卡關的原因。為什麼改不動:你傳的是複本
var x float64 = 3.4 v := reflect.ValueOf(x) v.SetFloat(7.1) // ❌ panic: reflect: reflect.Value.SetFloat using unaddressable value
reflect.ValueOf(x)拿到的是x的一份複本——就算能改也改不到原本的x,所以 Go 直接禁止。解法:傳指標,再取
Elem()p := reflect.ValueOf(&x) // 這是 *float64 的複本,但它指向 x v := p.Elem() // 解參考 → 這個才是可設定的 v.SetFloat(7.1) // ✅ x 變成 7.1判斷用
v.CanSet()。一個 API 慣例
為了讓 API 精簡,
Value的 getter/setter 操作的是能容納該值的最大型別:所有有號整數都用int64。所以v.Int()回傳int64、SetInt也吃int64,要用實際型別時得自己轉換。4. ★ 反射的成本(決定要不要用的關鍵數字)
成本 說明 速度 比直接存取慢一到兩個數量級。每次 Field(i)、MethodByName都要查型別資訊配置 ★ reflect.ValueOf(x)會讓x逃逸到 heap → GC 壓力編譯期檢查全失效 型別錯誤變成執行期 panic,而且常常在生產環境才爆 工具失效 ★ 重新命名欄位時 IDE 不會幫你改 struct tag;靜態分析看不到反射用到的欄位 可讀性 讀的人無法從程式碼看出「這裡會碰到哪些欄位」 ★ 所以規則是:能不用就不用。 但該用的時候它不可取代。
5. ★ 三個替代方案的決策表
需求 首選 為什麼 同樣邏輯、不同型別(Map、Filter、Sort、Min) ★ 泛型(Go 1.18+) 編譯期特化、可內聯、型別安全 大量重複的樣板程式碼(DTO 轉換、mock、enum 字串) ★ 程式碼產生( go:generate+stringer/mockgen/sqlc)零執行期成本、可讀、可除錯、可 diff 編譯期無法得知型別(JSON、ORM、DI 容器、template) 反射 沒有別的辦法 只有幾個具體型別 type switch 最簡單直接 ★ 實務上最常見的誤用:用反射寫「通用的 struct 欄位複製」。
這在 Go 1.18 之後多半該用泛型或go:generate產生的程式碼——
反射版又慢又不安全,而且重構時會默默壞掉。6. ★ 第三法則的實務含意:什麼時候「改不動」
var x float64 = 3.4 v := reflect.ValueOf(x) v.SetFloat(7.1) // ★ panic: reflect: reflect.Value.SetFloat using unaddressable value // ✅ 要傳指標,並且 Elem() 進去 v := reflect.ValueOf(&x).Elem() v.SetFloat(7.1) // ok // ★ 未匯出欄位永遠改不動(CanSet() == false),即使傳了指標三個檢查點:
CanSet()為 false 時,原因只會是——沒傳指標、沒Elem()、或欄位未匯出。7. 實務上該怎麼寫反射程式碼
// ★ 一定要先檢查再操作,不要直接 panic func setField(obj any, name string, val any) error { v := reflect.ValueOf(obj) if v.Kind() != reflect.Ptr || v.IsNil() { return fmt.Errorf("obj must be a non-nil pointer, got %T", obj) } v = v.Elem() if v.Kind() != reflect.Struct { return fmt.Errorf("obj must point to a struct, got %s", v.Kind()) } f := v.FieldByName(name) if !f.IsValid() { return fmt.Errorf("no field %q on %T", name, obj) } if !f.CanSet() { return fmt.Errorf("field %q is not settable (unexported?)", name) } rv := reflect.ValueOf(val) if !rv.Type().AssignableTo(f.Type()) { return fmt.Errorf("cannot assign %s to field %q of type %s", rv.Type(), name, f.Type()) } f.Set(rv) return nil }★ 用反射的程式碼要「防禦性地」寫:每一步都檢查並回傳有用的錯誤訊息,
而不是讓它 panic(見 工具-讓測試失敗訊息有用)。快取
reflect.Type的分析結果:ORM 與 JSON 庫都會把「這個型別的欄位對應」算一次存進sync.Map,
不要每次呼叫都重新走一遍欄位——這是反射程式碼最大的效能改善點。🧪 我實際套用的紀錄
- 2026-08-29:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 有泛型或程式碼產生能解就不要用反射(見決策表)。
- ★ struct tag 是字串,沒有編譯期檢查。打錯
json:"naem"不會報錯,只會默默序列化成錯的名字。
→ 一定要有序列化的往返測試(marshal → unmarshal → 比對)。- ★ 重新命名欄位時 tag 不會跟著改,靜態分析與 IDE 都看不到反射的使用。
這是「改了程式卻在執行期才爆」的主要來源之一。- 反射看不到未匯出欄位(讀得到型別資訊,但
CanSet()為 false)。
→ 深拷貝、diff 這類通用工具會默默略過未匯出欄位(見 原型模式)。reflect.DeepEqual不適合當測試斷言:它對 nil vs 空 slice、未匯出欄位、函式欄位的行為常常不是你要的。
用google/go-cmp的cmp.Diff,它的失敗訊息也好得多。- 反射 + 並行:
reflect.Value不是並行安全的,快取型別資訊時要用sync.Map或sync.Once。- 效能敏感路徑上要量。
pprof裡看到reflect.佔比高,就是該換方案的訊號。🔗 相關工具
- 工具-Go介面小而隱式 —— 介面的 (型別, 值) 二元組是反射的基礎
- 原型模式 —— 反射式深拷貝的限制
- 工具-資料編碼與演進 —— 序列化與 struct tag
- 工具-讓測試失敗訊息有用 —— 反射錯誤訊息該怎麼寫
- 訪問者模式 —— type switch 作為反射的替代
- 工具-程式碼調校 —— 先量再優化
- Go Blog 經典六篇
🔀 並行
🎯 什麼情境該想到我
當你要「最多等 N 秒」或「同時問多個來源、只要最快回來的那一個」的時候。
⚙️ 怎麼用(步驟 / 公式)
1. 逾時:
select+ 一個會自己響的 channelselect { case v := <-ch: // 拿到值 case <-time.After(time.Second): // 逾時,放棄這次接收 }
time.After回傳一個會在指定時間後送值的 channel。2. ⭐ 但跨越 API 邊界時,正解是
context來源文章寫於 2010 年、
context還不存在。今天的分工是:
- 函式內部的一次性等待 →
select+time.After就夠。- 要能被呼叫端取消、要往下游傳遞、要設整體期限 → 用
context.WithTimeout,把ctx當第一個參數傳下去。3. 競速:向多個來源查詢,取最快的
func Query(conns []Conn, query string) Result { ch := make(chan Result, len(conns)) // ⭐ 緩衝是關鍵 for _, conn := range conns { go func(c Conn) { ch <- c.DoQuery(query) }(conn) } return <-ch }為什麼一定要有緩衝:主函式只收一個值就走了,剩下的 goroutine 還想送。若 channel 無緩衝,它們會永遠卡在送出那一行——goroutine 洩漏。
給足len(conns)的緩衝,每個 goroutine 都送得出去然後正常結束,沒被讀走的值交給 GC。原文用的是「非阻塞送出(
select+default)」加緩衝,並自己點出:如果結果比主函式抵達接收點更早到,非阻塞送出會失敗——修法就是把 channel 開緩衝。4. ★
time.After的記憶體問題已經在 Go 1.23 修掉了(2026-09-21 查證)過去的標準建議是「在迴圈裡不要用
time.After,要用NewTimer+Stop」。這個建議現在過時了。官方文件(
pkg.go.dev/time#After)現在寫:“Before Go 1.23, this documentation warned that the underlying Timer would not be recovered by the garbage collector until the timer fired… As of Go 1.23, the garbage collector can recover unreferenced, unstopped timers. There is no reason to prefer NewTimer when After will do.”
★ 所以在 Go 1.23+ 上,
select裡直接用time.After是正確且推薦的。
(但如果你的go.mod還在 1.22 以下,舊建議仍然適用。)5. ★ 三個
select的實用技巧① nil channel 動態關掉分支
var tick <-chan time.Time // ★ nil:這個 case 永遠不會被選中 if enabled { t := time.NewTicker(time.Second) defer t.Stop() tick = t.C } for { select { case <-tick: // ★ enabled 為 false 時自動「消失」 doPeriodic() case <-ctx.Done(): return } }比
if enabled { ... }包在外面乾淨得多,也避免了兩份幾乎一樣的迴圈。②
default做非阻塞操作select { case ch <- v: // 送出去了 default: // ★ 滿了就丟棄(或記一筆 metric)—— 不阻塞發布者 }→ 這是背壓的取捨點:丟棄 vs 阻塞,沒有第三種選擇(見 觀察者模式)。
③
select {}永久阻塞
select{}(沒有任何 case)會永遠阻塞當前 goroutine。偶爾用在main的結尾讓服務不退出——
但更好的做法是等一個 signal channel,才能優雅關閉。6. ★ 逾時的完整決策表
情境 用什麼 函式內部的一次性等待 select+time.After★ 跨 API 邊界、要能被上游取消 context.WithTimeout/WithCancel,ctx當第一個參數整個請求的總期限 context.WithDeadline(★ deadline 會沿著呼叫鏈往下傳)HTTP client http.Client{Timeout: ...}或req.WithContext(ctx)逾時後要做收尾動作 ★ context.AfterFunc(ctx, f)(Go 1.21+)週期性執行 time.NewTicker+defer Stop()(★ Ticker 一定要 Stop,它不像 Timer 會被 GC 回收)★ 最重要的一條:逾時必須是「端到端」的,不是每一層各設一個。
每層各設 5 秒 → 三層就是 15 秒。用context傳遞同一個 deadline,才是正確的總時限控制。7. 競速模式的兩個變體
// ① 取最快的一個(原文的作法) func first(ctx context.Context, conns []Conn, q string) (Result, error) { ch := make(chan Result, len(conns)) // ★ 緩衝 = len,避免 goroutine 洩漏 ctx, cancel := context.WithCancel(ctx) defer cancel() // ★ 拿到結果後通知其他人可以停了 for _, c := range conns { go func(c Conn) { if r, err := c.Query(ctx, q); err == nil { ch <- r } }(c) } select { case r := <-ch: return r, nil case <-ctx.Done(): return Result{}, ctx.Err() } } // ② 對沖請求(hedged request):先送一個,N 毫秒後沒回應才送第二個 // ★ 只在尾延遲異常時才付雙倍成本,比「總是送兩個」省得多★ 對沖請求是降低 p99 的實用手法(見 AI 系統設計框架 的分散式搜尋題)。
🧪 我實際套用的紀錄
- 2026-08-29:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 逾時了不代表對方停了。
select的 timeout case 只是「你不等了」——
下游的工作還在跑、資源還佔著。真正要停就必須傳ctx下去並讓對方檢查它。- ★
time.Ticker一定要Stop()(和time.After不同,Ticker 會持續發送,GC 收不掉)。- ★ 競速的 channel 一定要有
len(n)的緩衝,否則沒被讀走的那些 goroutine 會永遠卡在送出那一行 → 洩漏。select的多個就緒 case 是隨機選的(規格保證),不要依賴順序。
需要優先序就用巢狀 select(先試高優先的 +default)。context的Err()要區分:context.Canceled(上游取消)vscontext.DeadlineExceeded(逾時)——
前者通常不該記成錯誤,後者要(見 工具-Go錯誤該包還是該轉)。- 不要把
context存進 struct。它是請求範圍的值,應該當參數傳。- 逾時值要有依據。從 p99 latency 往上加一點,不是拍一個 30 秒(見 工具-服務容錯設計)。
🔗 相關工具
- 工具-靠通訊共享記憶體 —— channel 的語義表與 goroutine 洩漏
- 工具-服務容錯設計 —— 逾時、重試、斷路器的正確順序
- 工具-用race偵測器抓資料競爭 —— 並行正確性的驗證
- 觀察者模式 ——
select+default的背壓取捨- 裝飾者模式 —— middleware 裡 Retry 與 Timeout 的順序
- AI 系統設計框架 —— 對沖請求與尾延遲
- Go Blog 經典六篇
🎯 什麼情境該想到我
當你有一個「偶爾才壞、重跑又好了」的併發 bug 的時候。
⚙️ 怎麼用(步驟 / 公式)
1. 加一個旗標就好
go test -race ./... # 測試(最常用) go run -race main.go # 直接跑 go build -race ./cmd/x # 建置編譯器會對所有記憶體存取插樁,runtime 監看未同步的共享變數存取,發現就印警告。
2. ⭐ 它只抓「真的被跑到」的競爭
the race detector can detect race conditions only when they are actually triggered by running code
這是根本限制,決定了怎麼用它:
- 在測試裡用,讓測試盡量覆蓋併發路徑。
- 接進 CI(Go 團隊自己就是這樣做的)。
- 也可以在真實負載下跑,例如金絲雀部署跑一個
-race的實例。- 沒報錯不等於沒有 race,只代表這次沒跑到。
3. 讀報告
報告會告訴你哪兩個 goroutine、在哪個位址、分別做了什麼存取、各自的建立位置。順著兩邊的堆疊看,通常一眼就能看出少了哪個同步。
4. ★ 怎麼跑(三個層次)
go test -race ./... # ★ 最常用:測試時開 go build -race -o app . && ./app # 建置時開(壓測或 staging 環境) go run -race main.go # 開發時單次跑★ 關鍵:
-race只能偵測「實際執行到」的競態。
沒跑到那條路徑就抓不到 → 所以它的效果完全取決於你的測試覆蓋率與併發程度。讓它更容易抓到:
go test -race -count=10 ./... # ★ 重複跑,增加不同交錯順序的機會 go test -race -cpu=1,2,4,8 ./... # ★ 不同 GOMAXPROCS,改變排程行為func TestConcurrent(t *testing.T) { t.Parallel() // ★ 讓測試之間也並行 var wg sync.WaitGroup for i := 0; i < 100; i++ { // ★ 刻意製造併發壓力 wg.Add(1) go func() { defer wg.Done(); target.Do() }() } wg.Wait() }5. ★ 成本:為什麼不能一直開著
面向 影響 執行速度 ★ 慢 2–20 倍(官方說法約 10 倍量級) 記憶體 ★ 增加 5–10 倍 goroutine 上限 有數量限制(歷史上是 8192,會隨版本變) 平台 需要 cgo 支援的平台;Apple Silicon(darwin/arm64)已支援 ★ 實務配置:
- CI 的單元測試一律
-race(值得那點時間)- 整合測試與 E2E 也盡量開(併發路徑更多)
- 生產環境不開(成本太高),但可以在 staging 用
-race跑壓測6. ★ 四種最常見的資料競爭
① 迴圈變數(Go < 1.22)
for _, v := range items { go func() { use(v) }() // ★ 1.22 前:共用同一個 v }→ Go 1.22 起每輪新建變數,這個 bug 消失了(見 工具-靠通訊共享記憶體)。
但go.mod的 go 版本要 ≥ 1.22。② 未保護的 map
var cache = map[string]int{} go func() { cache["a"] = 1 }() // ★ 並行寫 map → runtime 直接 panicGo runtime 會主動偵測並
fatal error: concurrent map writes——這個連 recover 都攔不住。③ 以為「只是讀」就安全
if !initialized { // ★ 讀 initialized = true // ★ 寫 —— 兩個 goroutine 可能都進來 doInit() }→ 用
sync.Once(見 單例模式)。④ ★ 用 channel 傳遞「指標」,以為就安全了
ch <- &bigStruct // ★ 傳的是位址 // 送出方繼續改 bigStruct → 和接收方競態★ channel 保證的是「傳遞」的同步,不是「內容」的獨占。
傳指標之後就不要再碰它——這是「所有權轉移」的約定,編譯器不會幫你檢查。7. 讀懂 race 報告
WARNING: DATA RACE Write at 0x00c0000b4010 by goroutine 8: ← ★ 誰在寫、在哪一行 main.(*Counter).Inc() /app/counter.go:14 +0x3c Previous read at 0x00c0000b4010 by main goroutine: ← ★ 誰在讀 main.main() /app/main.go:22 +0x8c Goroutine 8 (running) created at: ← ★ 這個 goroutine 是誰開的 main.main() /app/main.go:20 +0x64★ 三段一起看:衝突的兩次存取 + goroutine 的建立點。
「created at」那一段最有用——它告訴你這個並行是從哪裡來的。🧪 我實際套用的紀錄
- 2026-08-29:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 沒報告 ≠ 沒有競態。它只偵測「這次執行實際發生的」存取。
race detector 是找 bug 的工具,不是正確性的證明。- ★ 要有併發的測試才抓得到。單執行緒跑過所有測試,race detector 一個都不會報。
→ 刻意寫並行壓力測試(見上)。- 不要用
sync/atomic來「消掉 race 警告」。atomic 保證的是單一操作的原子性,
不保證多個操作之間的不變式。atomic.Load+ 判斷 +atomic.Store仍然是競態。- ★ 競態不只是「值錯了」。Go 的記憶體模型下,資料競爭是未定義行為——
可能觀察到撕裂的值、編譯器重排、或完全違反直覺的結果。不要試圖推理「最壞也只是拿到舊值」。-race會改變時序,有時反而讓某些競態不出現(Heisenbug)。兩種都跑。- 第三方套件的競態也會被報出來——先確認是自己的還是別人的,再決定怎麼處理。
go vet不能取代它。vet 抓的是靜態的可疑模式(如 copylocks),競態要靠執行期偵測。🔗 相關工具
- 工具-靠通訊共享記憶體 —— ★ 從設計上避免競態,而不是事後抓
- 工具-select超時與競速 —— 並行模式
- 工具-Go表格測試與子測試 —— 怎麼寫出能觸發競態的測試
- 單例模式 ——
sync.Once取代「檢查再初始化」- 雜湊表 —— 為什麼 Go 的 map 並行寫會 panic
- 工具-系統化除錯 —— 難以重現的 bug 怎麼查
- Go Blog 經典六篇
⚠️ 查證結果(2026-08-29,逐篇對照 pkg.go.dev 現行文件)
| 文章 | 年份 | 判定 | 具體證據 |
|---|---|---|---|
| Defer, Panic, and Recover | 2010 | ✅ 完全成立 | defer 三規則與 panic/recover 語義從未變動 |
| Concurrency: timeouts | 2010 | ⚠️ 技巧成立、缺口大 | 完全沒有 context(現在跨 API 邊界的逾時與取消靠它) |
| Slices intro | 2011 | ⚠️ 機制成立、API 過時 | 範例用 ioutil.ReadFile,Go 1.16 起整包 io/ioutil 已棄用 |
| Error handling and Go | 2011 | ❌ 明顯過時,照做會錯 | 見下方 |
| Laws of Reflection | 2011 | ✅ 三法則成立 | 反射三法則與 settability 概念未變 |
| Race Detector | 2013 | ⚠️ 工具成立、平台清單過時 | 2013 說只支援 64-bit x86;現行已含 darwin/arm64 |
❌ Error handling 那篇為什麼是「照做會錯」
兩個具體點,都對照過現行文件:
- 它教你用 type assertion 辨識錯誤(
err.(*json.SyntaxError))。Go 1.13 起正解是errors.Is/errors.As,因為錯誤會被%w包裝,直接斷言會漏掉被包在裡面的錯誤。 - ⭐ 它教你用
net.Error.Temporary()決定要不要重試。現行net套件的原文是:
標準函式庫明講 Do not use this method。// Deprecated: Temporary errors are not well-defined. // Most "temporary" errors are timeouts, and the few exceptions are surprising. // Do not use this method. Temporary() bool
正確做法 → 工具-Go現代錯誤處理。
⚠️ 一個「連修正本身都過時了」的案例
Concurrency: timeouts 建議用 time.After。多年來社群的標準修正是「time.After 在迴圈裡會洩漏,改用 NewTimer + Stop」——這條修正現在也過時了。現行 time.After 文件:
“Before Go 1.23, this documentation warned that the underlying Timer would not be recovered by the garbage collector until the timer fired… As of Go 1.23, the garbage collector can recover unreferenced, unstopped timers. There is no reason to prefer NewTimer when After will do.”
教訓:查證舊文件時,連「大家都知道的那個修正」也要一起查。
🎯 為什麼存這篇 / 未來想拿它做什麼
- Go 是我的主力語言,這六篇是官方對「語言為什麼這樣運作」講得最清楚的地方。
- 查證結果本身就是資產:知道哪一篇不能信、不能信在哪一句,比重讀六篇更省時間。
- 用一頁地圖收六篇,而不是六頁——它們是同一組東西,分開放會讓「哪篇能信」這個最重要的比較消失。
🧰 這六篇給我的工具(連到 tools/ 工具卡)
- 工具-defer-panic-recover — 當我要確保清理一定會執行、或在想 panic 該不該外露的時候
- 工具-Go切片的共享與陷阱 — 當我 append 之後發現另一個切片的值也變了的時候
- 工具-Go現代錯誤處理 — 當我要包裝、辨識或合併錯誤的時候(取代那篇 2011 的做法)
- 工具-select超時與競速 — 當我要等最多 N 秒、或要取最快回應的時候
- 工具-Go反射三法則 — 當我要用
reflect卻搞不清楚為什麼改不動值的時候 - 工具-用race偵測器抓資料競爭 — 當我懷疑有 race 卻抓不到的時候
💬 原文摘錄
- panic 的邊界(這條慣例仍然是 Go 的標準做法):
“The convention in the Go libraries is that even when a package uses panic internally, its external API still presents explicit error return values.”
- 切片的記憶體陷阱:
“the returned []byte points into an array containing the entire file… the few useful bytes of the file keep the entire contents in memory.”
- 反射第三法則:
“To modify a reflection object, the value must be settable.”
- race detector 的根本限制:
“the race detector can detect race conditions only when they are actually triggered by running code”
🔗 相關
- Effective Go — 同樣是 2009–2011 的官方素材,同樣要分層讀
- Modern Go Guidelines — 現代語法對照表;這六篇的過時處多半在那裡有對應的現代寫法
- Google Go 風格指南 — 錯誤處理那章可當作 2011 那篇〈Error handling and Go〉的現代替代
- Go — 主題索引:16 張 Go 工具卡與四份來源的分工