100 Day Engineer Challenge
Phase 05 — Concurrency(Day 41–50)
這份逐日教材依循內部規劃文件的範圍與排序展開1。
本檔案由兩個任務接力完成:本段(T-027)涵蓋 Day 41–45:Concurrency vs Parallelism / Process / Thread / Goroutine / Scheduler / Race condition / Mutex / RWMutex / Atomic / Channel;Day 46–50(Deadlock / Go 排程深入 G-M-P / Concurrency Patterns)由 T-028 接續寫在本檔案後半段,不另開新檔。
Day 41–45 的順序依 prerequisite chain2:Concurrency vs Parallelism 排第一,作為整個 Phase 的框架性區分——先分清「結構上可以交錯處理」與「物理上真的同時執行」不是同一件事,後面每一個知識點都是這個區分之下的具體實作方式。接著是 Process → Thread → Goroutine → Scheduler 這條鏈:不先懂 OS 層級的 Process 如何用獨立位址空間換取隔離、Thread 如何在同一個 Process 內共享記憶體並用較低成本達到並發,Goroutine「比 Thread 更輕量」這件事就說不清楚是輕在哪裡;Scheduler 排在 Goroutine 之後,因為要先知道有 Goroutine 這種執行單位存在,才能討論「誰負責決定哪個 Goroutine 現在該被執行」。最後是 Race condition / Mutex / RWMutex /Atomic / Channel 這組「共享狀態與同步」的知識點:Race condition 的定義本身就是「多個執行單位同時存取同一塊記憶體」,必須先確立 Goroutine/Thread 是「可能同時執行」的執行單位,Race 才有意義;Mutex/RWMutex/Atomic/Channel 則是解法,解法要在問題定義清楚之後才教。Deadlock/Livelock/Starvation(依賴 Mutex/鎖的概念已建立)與 Go 排程內部細節(G/M/P,依賴 Scheduler 基礎 + Channel)留給 T-028。
每個主題依分類標準要求的 5 段式內容撰寫3(Why / Mechanism / Trade-off / Failure mode / Backend 連結),Core Fundamentals 額外附一則陌生題示範,Supporting Topics 附一則可執行的練習。每天的 DoD 依「Concurrency 的特殊驗收」4七問(Where is shared state? / Where is race? / What synchronizes it? / What is the critical section? / What blocks? / What happens under contention? / Can it deadlock?),逐天回答其中與當天主題相關的部分,並附一段可以實際跑起來的 Go 練習與觀察到的結果——這是 Concurrency 這個 Phase 特有的驗收方式,不沿用 Phase 02/03 的 DSA 五欄格式,也不沿用 Phase 04 的 Query/EXPLAIN 格式。
Day 41 — Concurrency vs Parallelism + Process:先分清「同時處理」與「同時執行」
學習目標
看完今天內容後,能夠:
- 準確區分 Concurrency 與 Parallelism 的定義差異,並舉出「有 Concurrency 但沒有 Parallelism」與「兩者皆有」的具體例子。
- 解釋 Process 作為 OS 提供的隔離執行單位,說出它的記憶體隔離、獨立位址空間帶來的成本與好處。
- 用一支實際程式,觀察並證明兩個 Process 之間的記憶體確實互不可見。
教材大綱
Concurrency vs Parallelism
Core FundamentalsLv.4What:Concurrency(並發)是「同時處理多件事的結構」,指程式設計上具備可以交錯執行多個任務的能力,不代表這些任務真的在同一個時間點同時執行。Parallelism(平行)是「同時執行多件事的物理事實」,指多個任務確實在同一個時間點,分別在不同的實體運算單元(CPU core)上跑。單核 CPU 上的作業系統透過時間切片快速切換多個 Process/Thread 執行,這是 Concurrency(結構上交錯處理),但同一時刻只有一個任務真的在執行,不是 Parallelism。
沒有先分清楚,很容易把「我的程式開了很多 Goroutine」直接等同「我的程式變快了」——多個 Goroutine 只代表程式具備 Concurrency 的結構,能不能真的並行執行,取決於底層有沒有多顆實體核心可用,以及任務本身是不是 CPU-bound。這個區分決定了該用哪種手段解決什麼問題:I/O-bound 的任務(等網路、等磁碟)光靠 Concurrency 的結構就能大幅提升吞吐量(等待期間可以切去做別的事),但 CPU-bound 的任務(大量運算)只有真正的 Parallelism(用多核同時算)才能縮短總時間——把它拆成再多 Goroutine 塞進單核,總運算量不會變少,只是排隊順序變了。
對照兩個情境。情境 A:一個 web server 用單一 Goroutine 依序處理 3 個 request,每個都要等待 100ms 的資料庫查詢(I/O-bound)。如果拆成 3 個 Goroutine 並行送出,即使只有 1 顆 CPU 核心,3 個 Goroutine 在各自等待資料庫回應(I/O wait,不佔用 CPU)期間,CPU 可以切去處理其他工作,3 次查詢的等待時間會「重疊」,總耗時接近 100ms 而不是 300ms——這是 Concurrency 帶來的加速,全程只有 1 顆核心在跑,沒有發生 Parallelism。情境 B:把一個對 1000 萬筆資料做加總的 CPU-bound 運算拆成 4 個 Goroutine,各自算 250 萬筆再加總。如果只有 1 顆 CPU 核心(GOMAXPROCS=1),這 4 個 Goroutine 依然要排隊輪流使用同一顆核心運算,總 CPU 運算量不變,甚至可能因為多了排程切換開銷而變慢;只有在 GOMAXPROCS 設為 4 以上、且硬體真的有 4 顆以上實體核心時,4 個 Goroutine 才能真的同時佔用 4 顆核心運算,總耗時才會接近縮短到四分之一——這時才是 Parallelism 真正發生。
Concurrency 的設計(拆解任務、交錯執行)本身有成本——需要額外的協調機制(本 Phase 接下來要學的 Mutex/Channel)避免多個交錯執行的任務互相干擾;如果任務本身沒有等待(純 CPU 運算)又只有一顆核心,拆解反而只有協調成本、沒有加速效益。Parallelism 需要實體硬體資源(多核心)才能發生,且不是所有問題都能被拆成獨立可並行的子問題——有些計算步驟之間有嚴格的先後依賴,拆了也沒用。
Failure Modes:常見誤解是看到程式裡用了很多 Goroutine,就以為「這是平行運算,一定比較快」——如果任務是 CPU-bound 且 GOMAXPROCS被限制在 1(例如某些容器環境誤設資源限制),大量 Goroutine 只會增加排程開銷,實際吞吐量可能不升反降。另一個誤解是把「多執行緒程式」直接當作「自動安全地變快」,忽略了 Concurrency 結構一旦牽涉共享記憶體,就會引入 Day 44 才要學的 race condition 風險——Concurrency 帶來的是「結構上可以交錯」,不是「自動保證正確」。
Backend Applications:一個 Go web server 用 goroutine-per-request 模型(每個進來的 HTTP request 由一個新 Goroutine 處理)就是典型的 Concurrency 設計:多數 backend 工作是 I/O-bound(等資料庫、等下游 API、等磁碟),Goroutine 讓 server 可以在等待某個 request 的 I/O 時,切去處理另一個 request,大幅提高單機能同時服務的 request 數;而像圖片轉檔、批次資料運算這類 CPU-bound 工作,才需要認真考慮GOMAXPROCS 與實體核心數,思考的是 Parallelism 而不只是 Concurrency。
陌生題示範:情境——一台 4 核心的機器,GOMAXPROCS=4。程式 A 開 100 個 Goroutine,每個 Goroutine 只做「呼叫下游 API 並等待 200ms 回應」;程式 B 開 100 個 Goroutine,每個 Goroutine 做「對一個陣列做 100 萬次浮點數乘法」(純 CPU 運算,不等待任何 I/O)。請推導這兩個程式的總耗時分別大約是什麼量級,以及各自受什麼因素限制。推導:程式 A 是 I/O-bound,100 個 Goroutine 的等待期間彼此不佔用 CPU,Go runtime 可以讓它們的等待互相重疊,只要 4 顆核心加上 runtime 排程能撐住 100 個 Goroutine 在等待結束的瞬間各自被喚醒處理少量後續邏輯,總耗時會接近單一個 request 的等待時間(約 200ms 量級),不會是 100 × 200ms;程式 B 是 CPU-bound,100 個 Goroutine 的運算工作總量固定,只有 4 顆核心能真正同時執行,等於 100 份工作要排隊分配到 4 條執行序上,總耗時大約是「單一 Goroutine 運算時間 × 100 ÷ 4」,是實體核心數(Parallelism 的物理上限)在決定總時間,而不是 Goroutine 開得多不多。這個對照說明「Goroutine 數量」跟「實際變快的量級」中間,隔著一層「這個任務是 I/O-bound 還是 CPU-bound」的判斷。
Process
Supporting TopicsLv.3What:Process 是作業系統分配資源(獨立的虛擬位址空間、檔案描述符表、至少一個執行緒)的基本單位。每個 Process 有自己獨立的記憶體空間——Process A 沒辦法直接讀寫 Process B 的變數,兩者的記憶體位置編號(虛擬位址)即使數字相同,也對應到硬體上完全不同的實體記憶體。
如果所有程式共用同一塊記憶體空間,一個程式寫壞了某塊記憶體,可能直接讓另一個完全無關的程式崩潰——作業系統需要一種機制,讓多個程式可以同時在同一台機器上運行,卻不會互相踩到對方的記憶體,Process 的記憶體隔離就是這個機制的答案,是現代作業系統穩定性的基石之一。
每個 Process 由 OS kernel 維護一份 Process Control Block(PCB),記錄這個 Process 的狀態(running/waiting/ready)、暫存器值、記憶體位址空間對應表(page table)、開啟的檔案清單等。啟動一個新 Process 時,OS 會配置一塊新的虛擬位址空間,把可執行檔載入這塊空間,並在 page table 裡設定這塊虛擬位址空間對應到哪些實體記憶體——兩個 Process 的 page table 各自獨立,即使兩份程式碼裡都宣告了一個位於同一個虛擬位址的變數,它們分別對應到完全不同的實體記憶體位置,這正是隔離的來源。
Process 的隔離帶來安全性(一個 Process 壞掉不會直接污染別的 Process 記憶體),代價是:建立成本高——配置新的位址空間、page table、複製必要的 kernel 資料結構,比 Day 42 要學的 Thread/Goroutine 建立成本高出很多;Process 之間如果真的需要交換資料,不能直接共享變數,必須透過 OS 提供的 IPC(Inter-Process Communication)機制(例如管線、socket、共享記憶體區段),比同一個 Process 內兩個 Thread 直接讀寫同一個變數麻煩很多。
Failure Modes:常見誤解是以為「兩個 Process 執行同一份程式碼,就會共用同一份全域變數」——實際上即使是同一份可執行檔啟動的兩個 Process 實例,各自的全域變數也是分開的兩份記憶體,互不影響(例如同時開兩個瀏覽器分頁對應的獨立 renderer process,各自的 JS 全域變數不會互相看到對方)。
Backend Applications:常見的部署模式(例如用 systemd 或 Kubernetes 啟動多個相同服務的 Process/container)依賴的正是 Process 隔離——即使其中一個 instance 因為記憶體洩漏或 panic 崩潰,作業系統保證這不會直接破壞同機器上其他 Process 的記憶體,只要重啟這一個 Process 即可,這是水平擴展與容錯設計的物理前提。
用 Go 的 os/exec 啟動兩個子 Process,各自執行同一段「先讀取一個變數的初始值、加 1、印出結果」的小程式,觀察兩個子 Process 印出的結果各自從相同的初始值獨立計算、互不干擾(例如兩個都各自從 0 累加到 5 並各自印出 5,而不是互相污染出非預期數字);把程式碼與實際執行輸出附在 receipt,驗證「Process 之間記憶體互相不可見」這個結論不是紙上談兵。
Day 41 過關標準(DoD)—— Concurrency 特殊驗收格式
``text 七問(第 25 節)在本日的相關部分:Where is shared state? 在 Process 這一層,答案是「預設沒有」——這正是 Process 隔離存在的意義:兩個 Process 各自的記憶體互不可見,沒有共用的可寫記憶體,因此也就沒有 race 的問題(唯一例外是主動要求 OS 建立的 shared memory segment,屬於進階 IPC,不在今天範圍)。Where is race? 今天不適用——Race condition 的前提是「多個執行單位同時存取同一塊可寫記憶體」,Process 之間沒有這個前提。Implementation: 完成 Topic 2 的練習(os/exec啟動兩個子 Process,各自操作同名但不同記憶體的變數)。Verify: 執行程式,記錄兩個子 Process 各自印出的最終值,確認彼此獨立、沒有互相污染。Conclusion: 用自己的話寫一段,解釋「為什麼兩個執行同一份程式碼的 Process,印出的結果不會互相污染」,並回答:如果把今天的兩個子 Process 換成 Day 42 要學的兩個 Thread(同一個 Process 內),這個「互不污染」的保證還成立嗎?為什麼(提示:Thread 共享同一個 Process 的記憶體位址空間)。``
Day 42 — Thread vs Goroutine:誰在共享記憶體、誰的建立成本更低
學習目標
看完今天內容後,能夠:
- 解釋 Thread 作為「同一個 Process 內」的執行單位,說出它與 Process 在記憶體共享上的關鍵差異。
- 解釋 Goroutine 為什麼比 OS Thread 輕量(初始 Stack 大小、建立/排程成本),並用實際 benchmark 佐證。
- 正確指出「兩個 Goroutine 存取同一個變數」在什麼情況下已經構成 Day 44 要學的 race condition 前提條件。
教材大綱
Thread
Supporting TopicsLv.3What:Thread 是 Process 內部的執行單位——一個 Process 至少有一個 Thread(Main Thread),也可以建立多個 Thread。同一個 Process 底下的所有 Thread 共享同一份記憶體位址空間(同一份全域變數、heap 上配置的物件都能互相讀寫),但每個 Thread 有自己獨立的 Stack(存放函式呼叫的區域變數、返回位址)與程式計數器(記錄目前執行到哪一行指令)。
如果一個 Process 內部想同時做多件事(例如一個伺服器同時服務多個連線),Process 本身建立成本太高(Day 41 提過),Thread 提供了更輕量的方案:在同一份已經配置好的記憶體空間裡,多開幾條各自獨立執行、但看得到同一份記憶體的執行序列。
建立一個新 Thread 時,OS 不需要重新配置整個位址空間(沿用 Process 既有的),只需要配置一塊新的 Stack 空間(通常幾百 KB 到幾 MB,依系統預設值),並在 kernel 的排程結構裡登記這條新的執行序列。之後 OS 排程器會在多個 Thread(可能屬於不同 Process)之間切換 CPU 執行權——每次切換到不同 Thread 執行,都要保存目前 Thread 的暫存器狀態、載入下一個 Thread 的暫存器狀態,這個動作稱為Context Switch(Day 43 展開);如果新舊 Thread 屬於不同 Process,還要額外切換 page table 的對應,成本更高。
Thread 比 Process 建立快、切換快(尤其同 Process 內),且可以直接共享記憶體、不需要 IPC,方便多個執行單位協作處理同一份資料;代價是失去了 Process 的隔離保護——同一 Process 內任何一個 Thread 寫壞了共享記憶體的內容,會直接影響同 Process 內其他 Thread,而且多個 Thread 同時讀寫同一塊記憶體,如果沒有協調機制,會產生 Day 44 要學的 race condition。
Failure Modes:常見誤解是把「Thread 比較輕量」跟「可以無限開很多個」畫上等號——每個 OS Thread 仍然要吃掉獨立的 Stack 記憶體(即使幾百 KB,開幾萬個也會累積到 GB 等級),且 OS kernel 排程大量 Thread 的 context switch 開銷會隨數量上升,這正是接下來要學的「為什麼需要 Goroutine 這種更輕量的替代方案」的動機來源。
Backend Applications:傳統(非 Go)的 thread-per-connection 伺服器模型(例如早期 Java/C 的多執行緒伺服器)就是直接用 OS Thread 服務每個連線——同時服務數千個連線意味著要維持數千個 OS Thread,Stack 記憶體與 context switch 開銷成為擴展的主要瓶頸,這正是 Goroutine 這類使用者層級輕量執行單位被設計出來的背景。
用 Go 的 sync.WaitGroup 建立 1,000 個 Goroutine,各自只做一個空操作後結束,量測總耗時;若你的環境方便呼叫作業系統層級的 Thread(例如透過 cgo 呼叫 pthread_create,或用另一種語言如 Java 的 Thread 類別)跑同樣「建立 1,000 個、各自空操作後結束」的對照組,一併記錄耗時;若無法建立真正的 OS Thread 對照組,改為記錄 Go 官方文件或 runtime 原始碼裡對 Goroutine 初始 Stack 大小(2KB)與常見 OS Thread 預設 Stack 大小(例如 Linux pthread 常見預設 8MB)的具體數字對照,並說明這個數字差距對「能同時開多少個」的影響。
Goroutine
Core FundamentalsLv.4What:Goroutine 是 Go runtime(不是 OS kernel)自己管理的執行單位,比 OS Thread 輕量很多——初始 Stack 只有 2KB(依需要動態成長/收縮,不像 OS Thread 通常一開始就配置固定的幾百 KB 到幾 MB),而且 Goroutine 的建立、銷毀、切換都發生在使用者空間,不需要每次都陷入 kernel(進行系統呼叫),成本遠低於 OS Thread。
Day 41 已經說明 Concurrency 結構對 I/O-bound 的 backend 工作很有價值——但如果每個「可以交錯執行的任務」都要開一個 OS Thread,數千個並發連線就要數千個 OS Thread,記憶體與 context switch 成本會壓垮系統;Go 的解法是在 OS Thread 之上再疊一層使用者層級的排程(Day 43 展開的 M:N 模型),讓「開一個可以並發執行的任務」的成本從「開一個 OS Thread」降到「配置一塊 2KB 的 Stack 加上一筆 Go runtime 內部的紀錄」,量級差了兩三個數量級,讓「開幾萬個 Goroutine」在 Go 裡是常見且可行的做法,而「開幾萬個 OS Thread」通常不可行。
一個 Goroutine 由 go 關鍵字加上一個函式呼叫建立時(例如 go handleRequest(conn)),Go runtime 只需要配置一塊小的 Stack(2KB 起,若函式呼叫深度增加、Stack 不夠用,runtime 會自動配置更大的 Stack 並搬移內容,這個成長機制讓 Goroutine 不需要一開始就預留大量記憶體),並把這個新的執行單位交給 Go runtime 自己的排程器(Day 43 展開),由排程器決定何時把它安排到某個 OS Thread 上實際執行。多個 Goroutine 共用同一個 Process 的記憶體位址空間(跟 Thread 一樣),彼此可以直接讀寫同一份全域變數或 heap 上的物件。
Goroutine 用「使用者層級排程、極小初始 Stack」換來「建立/切換成本遠低於 OS Thread」,代價是:Go runtime 自己要負責把 Goroutine 對應到有限的 OS Thread 上(Day 43 的排程細節),這種底層調度的複雜度被 runtime 藏起來,換取開發者可以幾乎不假思索地開大量 Goroutine。
Failure Modes:常見誤解是以為「Goroutine 是完全免費的」——雖然比 Thread 輕量很多,但每個 Goroutine 仍然佔用真實記憶體(Stack)與排程開銷;在一個處理大量短生命週期任務的迴圈裡,不加控制地為每個任務開一個 Goroutine(沒有 worker pool 限制併發數),仍可能在極端資料量下累積出可觀的記憶體與排程壓力,這是 T-028 Patterns(Worker Pool)要解決的問題。
Backend Applications:Go 標準函式庫的 net/http 伺服器預設就是「每個進來的 request 由一個新 Goroutine 處理」——這正是善用 Goroutine 低成本的直接體現:一個能同時處理數千個並發連線的 Go 伺服器,背後可能只用了個位數到十位數的 OS Thread(由 runtime 排程器自動管理),不需要開發者手動管理 Thread Pool。
陌生題示範:情境——用 go test -bench 分別 benchmark「建立 10,000 個 OS Thread(各自執行空函式後結束)」與「建立 10,000 個 Goroutine(各自執行同一個空函式後結束,用 sync.WaitGroup 等待)」兩種寫法,你會預期兩者的總耗時與記憶體佔用差距落在什麼量級?推導:OS Thread 建立牽涉系統呼叫(陷入 kernel)、配置較大的 Stack(虛擬位址空間常見達 MB 級,雖然不會立即全部佔用實體記憶體,但仍佔用位址空間與部分實際使用的分頁),10,000 個大約會在秒級甚至更久、虛擬位址空間佔用達 GB 級;Goroutine 建立在使用者空間完成,不陷入 kernel,初始 Stack 只有 2KB,10,000 個 Goroutine 的建立與排程通常在幾十毫秒內完成、實體記憶體佔用在數十 MB 量級。這個實測差距(通常是 1–2 個數量級以上的時間差、記憶體佔用差距更大)正是 Go 語言在高並發 backend 場景被廣泛採用的核心原因之一。
寫一支 Go 程式,用 sync.WaitGroup 建立 100,000 個 Goroutine,各自把一個透過參數傳入的區域變數加 1 後結束,用time.Now() 量測總耗時;附上實際跑出的耗時數字,並用今天的 Mechanism 段落解釋為什麼這個量級的 Goroutine 數量在多數機器上都能在 1 秒內完成。
Day 42 過關標準(DoD)—— Concurrency 特殊驗收格式
``text 七問(第 25 節)在本日的相關部分:Where is shared state? 同一個 Process 內的所有 Thread/Goroutine 共享同一份記憶體位址空間——全域變數、透過指標/reference 傳遞的 heap 物件,都是所有 Thread/Goroutine 看得到、寫得到的共享狀態;與 Day 41 的 Process 隔離恰好相反。Where is race? 今天先只指出前提條件已經成立:只要兩個以上的 Goroutine 會存取同一個共享變數,且至少一個是寫入,理論上就具備 race 的前提;今天的練習刻意讓每個 Goroutine 只碰自己的區域變數(傳入參數),還沒有真正製造出 race——實際示範與修正見 Day 44。Implementation: 完成 Topic 1 的 Thread/Goroutine 建立成本對照練習,以及 Topic 2 的 100,000 個 Goroutine 練習。Verify: 記錄實測的 100,000 個 Goroutine 總耗時,並與 Topic 2 陌生題推導的量級(毫秒到低個位數秒級)對照,若你的機器結果落在這個量級內視為驗證通過。Conclusion: 用自己的話解釋 Goroutine 與 Thread 在建立成本上量級差在哪裡,並回答:如果把今天練習改成「100,000 個 Goroutine 全部對同一個全域變數做加 1」(不透過各自的區域變數),你預期最終這個全域變數的值會不會剛好等於 100,000?為什麼(先寫下你的預測,Day 44 會實際驗證並解釋)。``
Day 43 — Scheduler:為什麼 Goroutine 需要自己的排程器
學習目標
看完今天內容後,能夠:
- 解釋 OS 如何用 Preemptive Scheduling(搶佔式排程)在多個 Thread/Process 之間分配 CPU 時間,並說出 Context Switch 的具體成本來源。
- 解釋 blocking system call 如何拖累「一個 OS Thread 對應一個任務」的模型,並說出 Go runtime 的 M:N 排程如何在概念層級緩解這個問題(G/M/P 內部實作細節屬於 T-028 的範圍)。
- 給定一個具體情境(大量 Goroutine 中有一個卡在一次很慢的 blocking 操作),推論其他 Goroutine 是否會被拖累、為什麼。
教材大綱
OS Scheduler 與 Context Switch
Core FundamentalsLv.4What:OS Scheduler 是作業系統核心裡負責決定「下一個時間片段,CPU 要執行哪一個 Thread」的元件。現代作業系統普遍採用 Preemptive Scheduling(搶佔式排程):Scheduler 可以在一個 Thread 還沒主動讓出 CPU 的情況下,強制中斷它、把 CPU 讓給另一個 Thread(通常依賴硬體計時器定期觸發中斷),確保沒有任何一個 Thread 能無限期霸占 CPU。
如果排程完全依賴 Thread 自己主動讓出 CPU(Cooperative Scheduling,合作式排程),一個寫錯的無窮迴圈(忘記讓出 CPU)就會讓整台機器上其他所有 Thread 都拿不到執行機會——這在多工作業系統上是不可接受的風險。Preemptive Scheduling 把「什麼時候該換下一個 Thread」的決定權收回作業系統手上,用硬體計時器強制介入,保證公平性與系統可用性不會因為單一 Thread 的行為而完全崩潰。
Scheduler 維護一份「可執行(ready)」的 Thread 清單,依某種演算法(例如 Linux 的 Completely Fair Scheduler,依據每個 Thread 已經用掉的 CPU 時間排優先序)決定下一個要執行的 Thread。當計時器中斷觸發(或目前執行的 Thread 主動阻塞,例如發出一次 blocking I/O 系統呼叫在等待資料回來),CPU 執行一次Context Switch:把目前 Thread 的完整暫存器狀態(包含程式計數器,記錄執行到哪一行)存進這個 Thread 的 kernel 資料結構,再把下一個要執行的 Thread 先前存好的暫存器狀態載入 CPU 暫存器,讓 CPU 從那個 Thread 上次中斷的地方繼續執行。這個切換動作本身(存/取暫存器、可能還要切換 page table、清空部分 CPU cache)需要消耗真實的 CPU 時間(微秒級),這是「開太多 Thread」除了記憶體開銷之外的第二個成本來源——Thread 數量遠超過實體核心數時,Scheduler 花在 Context Switch 上的時間佔比會明顯上升,實際用來執行真正工作的時間反而變少。
Preemptive Scheduling 保證了公平性與系統韌性,代價是每次強制切換都要付出 Context Switch 的固定成本,且切換的時機不受目前執行的 Thread 控制,這也是為什麼「Thread 數量遠多於核心數」的系統,吞吐量不會隨 Thread 數量線性成長,反而在超過某個門檻後開始下降(Context Switch 開銷侵蝕掉實際工作時間)。
Failure Modes:一個常見的失效場景是 blocking system call 拖累整個 OS Thread——如果一個 Thread 呼叫了一個 blocking I/O(例如同步讀取一個很慢的檔案/網路連線),這個 Thread 在等待期間會被 Scheduler 標記為阻塞狀態,讓出 CPU 給別的 ready Thread;但如果程式設計是「一個 Thread 對應處理一個任務」(thread-per-task 模型),這個被阻塞的 Thread 在等待結束前完全無法處理任何其他任務——如果同時有數千個任務都各自卡在慢速 I/O 上,就需要數千個 Thread 才能讓其他任務不受影響,這正是 Day 42 Thread 建立成本問題的根源,也是 Goroutine 排程模型要解決的核心痛點。
Backend Applications:診斷「CPU 使用率不高,但系統吞吐量卻上不去」這類問題時,Context Switch 次數是常被忽略的觀察指標(Linux 上可用 vmstat 或 pidstat -w 觀察)——如果 Context Switch 次數異常高,代表系統裡有遠超過核心數的可執行單位在互搶 CPU,即使每個單位做的事都不多,切換開銷本身就可能是瓶頸,這時解法通常不是「加更多 Thread」,而是限制併發數量(例如用 Worker Pool,T-028 展開)。
陌生題示範:情境——一台 8 核心的機器,跑一個 thread-per-connection 的伺服器,目前同時有 5,000 個連線、也就是 5,000 個 OS Thread 都處於 ready 狀態等著被排到 CPU 上執行一小段運算。請推導:把連線數從 5,000 降到 500(例如透過連線池限制併發數),對整體吞吐量的影響會是什麼方向,並說出理論根據。推導:8 顆核心同一時刻最多只能真正執行 8 個 Thread,其餘 Thread 不論是 5,000 個還是 500 個,都要排隊等待 Scheduler 分配 CPU 時間片——但 Thread 數量越多,Scheduler 需要維護的 ready 清單越大、每次決定「下一個換誰」的排程開銷也越高,且每次切換都要付出前面 Mechanism 段落描述的固定成本(存/取暫存器等);當 Thread 數量從 5,000 降到 500,雖然仍遠超過 8 顆核心,但 Scheduler 需要處理的排程決策複雜度會下降,理論上能把更高比例的 CPU 時間花在「執行真正的工作」而不是「決定接下來執行誰」,整體吞吐量預期會提升——這正是為什麼即使 CPU 核心數不變,限制併發數量(例如用連線池、Worker Pool,T-028 展開)本身就能改善吞吐量,而不是直觀以為「連線數變少=能同時服務的客戶變少=更差」。
Goroutine Scheduler(概念層級:M:N 模型)
Core FundamentalsLv.4What:Go runtime 內建一個使用者層級的排程器,把數量可能遠超過 CPU 核心數的 Goroutine(M 個),排程到數量有限、通常對應 CPU 核心數的 OS Thread(N 個)上執行——這稱為 M:N 排程模型,相對於「一個任務對應一個 OS Thread」的 1:1 模型。(Go 排程器內部如何用 G/M/P 三種結構具體實作這個排程、Goroutine 之間如何被搬移,屬於 T-028「Go 排程深入」的範圍,今天只建立「為什麼需要 M:N、它解決了 Topic 1 的什麼問題」這個概念層級的理解。)
Topic 1 已經指出 thread-per-task 模型在任務數遠超過核心數、且任務常常需要等待 I/O 時的兩個問題:Thread 建立/記憶體成本高、Context Switch 開銷高。M:N 排程用「多個 Goroutine 共用少數幾個 OS Thread」解決這兩個問題——Goroutine 之間的切換(哪一個 Goroutine 目前佔用某個 OS Thread 執行)由 Go runtime 自己在使用者空間完成,不需要每次都陷入 kernel 觸發真正的 OS Context Switch,成本遠低於 Topic 1 描述的 OS 層級切換。
Mechanism(概念層級):當一個 Goroutine 執行到會阻塞的操作(例如等待 Channel、等待網路 I/O),Go runtime 的排程器會偵測到這個 Goroutine 目前無法繼續執行,把它移出目前佔用的 OS Thread,換上另一個 ready 的 Goroutine 繼續用這個 OS Thread 執行——原本卡住的 Goroutine 不會浪費掉整個 OS Thread(不像 Topic 1 的 thread-per-task 模型),OS Thread 本身可以持續保持忙碌、服務其他 Goroutine。對於真正的 blocking system call(Go runtime 無法用非阻塞方式接管的少數情況),runtime 有機制偵測到某個 OS Thread 被卡住太久,額外啟用一個新的 OS Thread 頂替,讓其他 Goroutine 不被這一個卡住的系統呼叫拖累——這保證了即使某個 Goroutine 卡在系統呼叫,其他 Goroutine 仍能被排到別的 OS Thread 繼續跑。
M:N 排程把「很多任務只需要少量 OS Thread」的好處帶給開發者,代價是排程邏輯的複雜度從 kernel 移到 Go runtime 自己身上維護,且 Goroutine 之間如果做的是純 CPU 運算(沒有機會被排程器偵測到「正在等待」而讓出),仍然要跟其他 Goroutine 公平分時使用有限的 OS Thread——M:N 排程解決的是「I/O 等待浪費 Thread」的問題,不是「憑空生出更多 CPU 運算能力」,這呼應 Day 41 Concurrency vs Parallelism 的區分:M:N 排程本身是 Concurrency 層級的優化,Parallelism 仍然受限於實體核心數(GOMAXPROCS)。
Failure Modes:一個常見誤解是以為「Goroutine 排程完全免除了 Context Switch 成本」——使用者層級的 Goroutine 切換確實比 OS 層級便宜很多,但不是零成本,仍然涉及保存/還原少量狀態;如果程式建立了數百萬個同時等待、彼此又互相依賴喚醒的 Goroutine,排程器需要管理的資料結構規模上升,排程本身的開銷仍會累積成可觀測的成本,「Goroutine 很輕量」不等於「無限多個 Goroutine 沒有代價」。
Backend Applications:Go 程式可以用 runtime.GOMAXPROCS(n)設定同時能真正並行執行 Goroutine 的 OS Thread(對應 CPU 核心)數量上限——這個設定值決定的是 Parallelism 的物理上限,跟你開了多少個 Goroutine(Concurrency 的結構)是兩件獨立的事;診斷 Go 服務效能問題時,GOMAXPROCS 設定是否符合容器實際可用的 CPU 核心數(容器化環境常見的坑:容器限制了 CPU quota,但 Go runtime 預設抓的是宿主機的核心數)是常被忽略、卻直接影響 Parallelism 上限的設定。
陌生題示範:情境——一個 Go 服務同時處理 5,000 個並發連線,其中一個連線觸發了一段呼叫 cgo(呼叫 C 函式庫)的邏輯,這段 C 函式庫內部執行一次會阻塞 3 秒的同步系統呼叫,且 Go runtime 沒有辦法用非阻塞方式接管這類 cgo 呼叫。請推導這 3 秒內,其他 4,999 個連線的 Goroutine 是否會被拖累、為什麼。推導:依今天 Mechanism 段落,Go runtime 偵測到某個 OS Thread 被這個 cgo 呼叫卡住超過門檻時,會額外啟用一個新的 OS Thread,讓排程器可以把其他 ready 的 Goroutine 搬到新的 OS Thread 上繼續執行,因此其他 4,999 個連線的 Goroutine理論上不會因為這一個卡住 3 秒的 cgo 呼叫而整體停擺——但代價是短時間內 OS Thread 數量會多出至少 1 個(額外開的那個),如果同時有大量這類 blocking cgo 呼叫同時發生,OS Thread 數量可能會不受控制地上升,重新引入 Topic 1 描述的 Thread 建立/Context Switch 成本問題,這是為什麼「盡量避免在 Goroutine 裡呼叫會長時間阻塞的 cgo/系統呼叫」是 Go 服務效能調校中常見的建議。
Day 43 過關標準(DoD)—— Concurrency 特殊驗收格式
``text 七問(第 25 節)在本日的相關部分:What blocks? 一個 Goroutine 執行到等待 I/O、等待 Channel 等操作時會被排程器標記為不可執行,讓出目前佔用的 OS Thread;今天聚焦在「什麼情況下這個讓出會不會連帶拖累其他 Goroutine」。What happens under contention? 當 Goroutine 數量遠超過 GOMAXPROCS 設定的 OS Thread 數量時,多個 ready 的 Goroutine 要排隊等待被排到某個 OS Thread 上執行——這是「CPU 資源」層級的 contention,跟 Day 44 要學的「同一個共享變數」層級 contention 是不同層次的概念,今天先建立這個區分。Implementation:寫一支 Go 程式,開啟遠多於 runtime.NumCPU() 的 Goroutine(例如 10 倍),每個 Goroutine 執行一段純 CPU 運算(例如對一個固定範圍做質數判斷,不含任何 I/O 等待),用 time.Now() 量測在 GOMAXPROCS=1 與 GOMAXPROCS=runtime.NumCPU() 兩種設定下的總耗時差異。Verify: 記錄兩種 GOMAXPROCS 設定下的總耗時,確認 GOMAXPROCS=runtime.NumCPU()明顯快於 GOMAXPROCS=1(差異量級應接近核心數的倍數,因為這是純 CPU-bound 工作,真正受益於 Parallelism)。Conclusion: 用自己的話解釋這個實測結果如何驗證 Day 41「Concurrency 結構不等於 Parallelism」的區分,並回答:如果把今天的純 CPU 運算換成「每個 Goroutine 都先 sleep 100ms 再結束」(I/O-bound 情境的簡化模擬),你預期 GOMAXPROCS=1 跟 GOMAXPROCS=NumCPU() 兩者的總耗時差異還會像今天這麼大嗎?為什麼(提示:sleep 期間不佔用 CPU,回頭參考 Day 41 陌生題的程式 A)。``
Day 44 — Race Condition + Mutex / RWMutex / Atomic:共享狀態的問題與三種解法
學習目標
看完今天內容後,能夠:
- 用實際程式碼重現一次 race condition(含用
go run -race偵測),並精確指出 shared state、critical section 在哪。 - 用 Mutex 修正上述 race,解釋 Mutex 如何透過「同一時間只允許一個 Goroutine 進入」保護 critical section。
- 判斷什麼情境該用 RWMutex 而非一般 Mutex、什麼情境可以用更輕量的 Atomic 操作取代整個鎖,並各自寫出對應程式碼。
教材大綱
Race Condition
Core FundamentalsLv.4What:Race Condition(競爭條件)是指兩個以上的執行單位(Thread/Goroutine)同時存取同一塊共享記憶體,且至少一個是寫入操作,而最終結果依賴於這些存取實際發生的時間順序——如果程式的正確性會因為執行順序不同而變出不同結果,就代表這段程式存在 race condition。
Day 42 已經指出「同一個 Process 內的 Goroutine 共享記憶體」是 backend 開發能高效協作處理同一份資料的基礎,但這個共享同時是風險的來源——如果沒有協調機制,多個 Goroutine 對同一個變數做看似簡單的操作(例如遞增一個計數器),實際上可能因為執行順序交錯而讓最終結果不正確,且這種錯誤通常不會每次都發生(依賴 Goroutine 被排程的實際時間點),使它成為最難重現、最難除錯的一類 bug。
一句看似原子的整數遞增操作,實際上在機器層級通常拆成至少三個步驟:(1) 從記憶體讀出目前的值到 CPU 暫存器,(2) 把暫存器的值加 1,(3) 把暫存器的新值寫回記憶體。假設兩個 Goroutine A、B 同時執行這個遞增,初始值為 0:如果 A 先完整執行完三個步驟(讀 0 → 加成 1 → 寫回 1),B 才開始執行,最終值為 2,這是正確結果;但如果 A 執行完步驟 (1)(讀到 0)之後,還沒寫回,排程器就切換去執行 B,B 也讀到 0、加成 1、寫回 1,接著切回 A,A 用它步驟(1) 讀到的舊值 0 加 1 得到 1,寫回 1——兩個 Goroutine 各自執行了一次遞增,最終值卻只從 0 變成 1,而不是預期的 2,其中一次遞增被憑空遺失了。這段「從讀取到寫回」之間可能被其他 Goroutine 交錯進來的程式碼區間,稱為 Critical Section(臨界區)——今天例子裡的遞增操作(背後那三個機器層級步驟)就是 critical section。
Trade-off / Failure Modes:Race condition 的可怕之處在於它不保證每次都出錯——上面的交錯情境需要排程器剛好在特定時間點切換,在 Goroutine 數量少、運算量小的情況下可能很少發生,讓開發者誤以為程式沒問題,卻在高併發、高負載的生產環境下才大量出現不一致的結果,這正是 race condition 難以用一般測試流程抓到的原因。Go 工具鏈內建-race flag(go run -race main.go、go test -race),會在執行期間插入額外的偵測邏輯,追蹤每一次記憶體存取是否可能與其他 Goroutine 的存取產生未同步的交錯,一旦偵測到就會印出明確的 race 報告(包含發生 race 的程式碼行號與涉及的 Goroutine),是排查/預防 race condition 最直接有效的工具,應該養成在測試流程中固定加上-race 的習慣。
Backend Applications:一個常見的生產事故模式是「多個 request 的 Goroutine 同時對記憶體內的計數器(例如簡易版的 rate limiter 計數、暫存的統計數字)做遞增,沒有加鎖,導致計數不準確」——這正是本 Phase 驗收目標(concurrency-safe rate limiter)要解決的核心問題:一個 rate limiter 如果內部計數器有 race,會讓限流失準(可能允許超過設定上限的請求通過,或反過來誤擋合法請求)。
陌生題示範:情境——一段程式碼開 1,000 個 Goroutine,每個都對同一個全域整數變數執行一次遞增,主 Goroutine 用 sync.WaitGroup等待全部完成後印出這個變數的值。請推導這個程式執行多次,印出的值是否每次都是 1,000,並指出 shared state、race 發生的位置、critical section 分別是什麼。推導:這個全域變數是被 1,000 個 Goroutine 共同讀寫的 shared state;race 發生在任兩個 Goroutine 的遞增操作被排程器交錯執行、其中一個的寫回覆蓋了另一個尚未寫回的中間結果時;critical section 是「讀取 → 加一 → 寫回」這三步驟合起來的區間。由於排程順序在每次執行時可能不同,這個程式印出的最終值不保證每次都是 1,000,通常會是一個小於等於 1,000、且每次執行可能不同的數字(遺失的次數取決於實際發生交錯的頻率,機器負載、GOMAXPROCS 設定都會影響),這正是 Day 42 結尾預告的答案:全域變數版本的 100,000 個 Goroutine 遞增練習,最終值幾乎必然小於 100,000。
Mutex
Core FundamentalsLv.4What:Mutex(Mutual Exclusion,互斥鎖)是一種同步機制,保證同一時間最多只有一個 Goroutine 能夠進入被它保護的 Critical Section;其他想進入的 Goroutine 會被阻塞(blocked),排隊等待目前持有鎖的 Goroutine 釋放鎖之後,才能有一個等待中的 Goroutine 被喚醒、取得鎖繼續執行。Go 的 sync.Mutex 提供 Lock() 與Unlock() 兩個方法。
Topic 1 展示的 race 之所以發生,根源是「讀取 → 加一 →寫回」這三步驟中間可能被其他 Goroutine 插入執行;Mutex 的解法是把這整段步驟包在 Lock()/Unlock() 之間,強制規定一次只能有一個 Goroutine 在做這件事,讓 Critical Section 對外表現得像是一個不可被打斷的原子操作。
宣告 var mu sync.Mutex 與一個共享的 counter變數,increment() 函式內先呼叫 mu.Lock(),接著執行counter++,最後呼叫 mu.Unlock()。當 Goroutine A 呼叫mu.Lock() 成功取得鎖,開始執行 counter++;此時若 Goroutine B 也呼叫 mu.Lock(),因為鎖已經被 A 持有,B 會被阻塞(進入等待佇列),直到 A 執行完 counter++ 並呼叫 mu.Unlock() 釋放鎖,Go runtime 才會喚醒等待佇列中的某一個 Goroutine(例如 B),讓它取得鎖繼續執行自己的 counter++。因為任何時刻最多只有一個 Goroutine 在執行「讀取 → 加一 → 寫回」,不會再發生兩個 Goroutine 同時讀到同一個舊值的情況,1,000 個 Goroutine 各自呼叫一次 increment(),最終counter 保證等於 1,000。
Mutex 用「強制序列化 Critical Section」換取正確性,代價是失去了原本 Goroutine 並發執行的優勢——被 Mutex 保護的這段程式碼,同一時間只有一個 Goroutine 能執行,變成事實上的序列執行;如果 Critical Section 範圍抓得太大(例如把整個函式、包含不需要保護的耗時運算都包進 Lock()/Unlock() 之間),會讓大量 Goroutine 排隊等待,嚴重限制吞吐量。正確的做法是讓 Critical Section 盡量小、只包住真正需要保護的那幾行共享記憶體存取。
Failure Modes:最常見的錯誤是 Lock() 之後某個路徑(例如提早return 或 panic)忘記呼叫對應的 Unlock(),導致這個鎖永遠不會被釋放,後續任何想取得這個鎖的 Goroutine 都會永久阻塞——Go 的慣用寫法是 mu.Lock() 後緊接著 defer mu.Unlock(),用 defer 保證不管函式從哪個路徑返回都一定會執行 Unlock()。另一個常見錯誤是「鎖的粒度不一致」——例如兩個不同函式都存取同一個共享變數,卻只有其中一個函式有 Lock() 保護,另一個直接讀寫,Mutex 的保護是針對「所有存取路徑都要一致地上鎖」才有效,漏掉任何一個路徑,race 依然存在。
Backend Applications:一個記憶體內的 rate limiter(例如固定視窗計數器)如果要在多個 Goroutine 同時處理的請求之間共享同一份計數狀態,計數的遞增與判斷是否超過門檻這段邏輯,必須用 Mutex(或接下來要學的 Atomic)保護,否則在高併發下會像 Topic 1 陌生題一樣遺失遞增次數,讓限流的門檻形同虛設。
陌生題示範:情境——把 Topic 1 陌生題的 1,000 個 Goroutine 遞增練習,改成用 sync.Mutex 保護(mu.Lock() → 遞增 → mu.Unlock()),但其中不小心漏寫了一個地方:主 Goroutine 在所有子 Goroutine 都Wait() 完之後,沒有加鎖就直接讀取並印出這個變數的值。請推導:這個「讀取」動作本身有沒有 race 風險,並說明為什麼。推導:雖然所有會寫入這個變數的 1,000 個 Goroutine 都已經透過 sync.WaitGroup確認執行完畢(意味著它們的寫入與 Unlock() 都已經發生,不會再有新的寫入),此時主 Goroutine 的讀取確實不會跟任何「正在進行中」的寫入交錯——但這個安全性是建立在「WaitGroup.Wait() 保證所有寫入者都已經完成」這個額外的同步保證之上,而不是 Mutex 本身給的保證;如果今天的情境改成「還有背景 Goroutine 持續在遞增、主 Goroutine 隨時可能穿插著讀取」,同一個不加鎖的讀取就會直接構成 race(讀取也是一種記憶體存取,Mutex 的保護必須涵蓋所有存取路徑,包含只讀的路徑,見本節 Failure Modes)。這說明「目前這次沒事」不等於「這個寫法安全」——安全性取決於是否有其他機制(例如 WaitGroup)保證了讀寫不會交錯,不能只看單次執行結果反推程式碼正確。
RWMutex
Supporting TopicsLv.3What:sync.RWMutex 是 Mutex 的一種變形,區分兩種鎖:Lock()/Unlock()(寫鎖,Write Lock)維持跟一般 Mutex 一樣「同一時間最多一個 Goroutine」的互斥語意;RLock()/RUnlock()(讀鎖,Read Lock)允許多個 Goroutine 同時持有讀鎖並行讀取,只要沒有任何 Goroutine 持有寫鎖。當有 Goroutine 持有寫鎖時,任何想取得讀鎖或寫鎖的 Goroutine 都要等待。
一般 Mutex 對讀取跟寫入一視同仁,同一時間只允許一個 Goroutine 存取,即使多個 Goroutine 都只是要讀取、彼此並不會互相干擾(多個 Goroutine 同時讀同一個值不會有 race,因為沒有人在寫)。如果一份共享資料的存取模式是「讀多寫少」(例如一份很少更新、但被大量請求讀取的設定快取),用一般 Mutex 會讓大量原本可以並行的讀取操作被迫排隊,浪費了併發的潛力;RWMutex 針對這種場景,讓多個讀取者可以同時進行,只有真正的寫入才需要獨佔。
實務範例——一份記憶體內的設定快取,多個 Goroutine(處理不同 request)頻繁呼叫 getConfig(key) 讀取設定值,內部用mu.RLock()/mu.RUnlock() 包住讀取那份 map[string]string;一個背景 Goroutine 每隔一段時間呼叫 updateConfig(newConfig) 整份替換這個 map,內部用 mu.Lock()/mu.Unlock() 包住替換動作。多個getConfig 呼叫可以同時持有讀鎖並行執行,只有 updateConfig呼叫 Lock() 時才會讓所有讀取者與其他寫入者等待,等這次更新完成釋放寫鎖後,讀取者才能繼續並行讀取。
RWMutex 在讀多寫少的場景能大幅提升吞吐量(讀取不再互相排隊),但內部維護「有多少個讀鎖持有者、有沒有寫鎖等待」的簿記成本比一般 Mutex 高,在讀寫比例接近,或鎖持有時間本身很短的場景,RWMutex 的額外開銷可能反而讓它比一般 Mutex 慢;RWMutex 適用與否取決於實際的讀寫比例,不是任何情境都優於一般 Mutex。
Failure Modes:常見誤解是以為 RWMutex 能讓「讀取者」跟「寫入者」同時進行——實際上寫鎖依然是完全互斥的,只要有一個 Goroutine 持有寫鎖,包含讀鎖在內的所有其他請求都要等待;另一個常見問題是「寫鎖飢餓」(write starvation)——如果讀取請求非常頻繁、幾乎不間斷,等待寫鎖的 Goroutine 可能長時間排不到機會(Go 的 sync.RWMutex會在有寫鎖等待時,阻止新的讀鎖繼續插隊,緩解但不完全消除這個問題)。
實作上面的設定快取範例,開 100 個 Goroutine 併發呼叫getConfig、同時另開 1 個背景 Goroutine 每 10ms 呼叫一次updateConfig,跑 1 秒後結束;用 go run -race 確認沒有偵測到 race,並記錄程式正常結束、沒有 panic 或 deadlock。
Atomic
Supporting TopicsLv.3What:sync/atomic 套件提供一組原子操作(例如atomic.AddInt64、atomic.LoadInt64、atomic.CompareAndSwapInt64),直接由 CPU 硬體層級保證「讀取-修改-寫回」這類操作作為單一、不可被其他執行單位插入的整體完成,不需要像 Mutex 一樣透過「阻塞其他 Goroutine」來達成互斥。
Topic 1 的計數器問題,如果只是單純對一個整數做遞增,不需要 Mutex 這種「讓其他 Goroutine 完全排隊等待」的重量級解法——CPU 本身就提供了針對簡單數值操作的原子指令,直接使用這些硬體層級的原子操作,比透過 Mutex 阻塞/喚醒 Goroutine(牽涉 Go runtime 排程器的介入)成本更低,在單純數值計數這類場景是更輕量的選擇。
把 Topic 1 的例子改成用 var counter int64 搭配atomic.AddInt64(&counter, 1) 取代 mu.Lock(); counter++;mu.Unlock()。atomic.AddInt64 直接對應到 CPU 提供的一條原子加法指令,讀取、加一、寫回這三個步驟在硬體層級被保證成一個不可分割的整體,即使 1,000 個 Goroutine 同時呼叫,也不會發生 Topic 1 描述的「兩個 Goroutine 讀到同一個舊值」的交錯情況——不需要 Lock()/Unlock(),也就沒有 Goroutine 會因為等待鎖而被阻塞。
Atomic 操作只能保護單一個簡單數值的簡單操作(遞增、比較後交換、讀取、寫入),沒辦法像 Mutex 一樣保護一段任意複雜、牽涉多個變數的程式邏輯(例如「檢查一個 map 裡的值,符合條件才更新另一個變數」這種跨越多個步驟、多個變數的複合操作,Atomic 做不到,必須用 Mutex);換來的好處是在它能處理的簡單場景下,效能通常優於 Mutex(沒有阻塞/喚醒 Goroutine 的排程開銷)。
Failure Modes:常見誤解是把 Atomic 當成萬用的無鎖方案,試圖用多個獨立的 Atomic 操作拼湊出一個本質上需要整體一致性的複合邏輯(例如分別對兩個 Atomic 變數做更新,期待兩者看起來像是同時發生)——每個 Atomic 操作本身是原子的,但兩個獨立的 Atomic 操作之間仍然可能被其他 Goroutine 插入執行,整體上不構成一個大的 Critical Section,這種情況必須用 Mutex 把兩個變數的更新包在同一個鎖之內,才能保證一致性。
Backend Applications:Rate Limiter 內部的請求計數器、簡單的請求數/錯誤數統計指標(metrics),是 Atomic 最典型的應用場景——只需要保護單一數值的遞增/讀取,用 Atomic 比 Mutex 更輕量,是本 Phase 驗收目標(concurrency-safe rate limiter)實作時的自然選擇之一。
把 Topic 1 陌生題的 1,000 個 Goroutine 遞增練習分別用(a)沒有保護、(b)sync.Mutex、(c)atomic.AddInt64 三種版本各跑一次,記錄三者最終印出的 counter 值,並用 go run -race分別檢查:(a)應偵測到 race,(b)(c)不應偵測到 race;額外用time.Now() 對比(b)與(c)在 100,000 次遞增規模下的耗時差異。
Day 44 過關標準(DoD)—— Concurrency 特殊驗收格式(完整涵蓋七問)
``text Implementation: 完成 Topic 1、Topic 3、Topic 4 的三個練習(race 重現 + RWMutex 設定快取 + 三版本 counter 對比),程式碼與實際執行輸出(含 go run -race 的完整輸出)附在 receipt。七問(第 25 節)逐項回答(以 Topic 1 的 1,000-Goroutine counter 為例):Where is shared state? 全域變數 counter(或 Topic 3 的 config map)。Where is race? counter 遞增展開後「讀取目前值 → 加一 → 寫回」這三個機器層級步驟之間,可能被其他 Goroutine 的同一段步驟交錯插入。What synchronizes it? 未加保護版本:無——這正是它會 race 的原因;Mutex 版本:sync.Mutex 的 Lock/Unlock;Atomic 版本:CPU 提供的原子加法指令(透過 atomic.AddInt64)。What is the critical section? Mutex 版本裡 mu.Lock() 與 mu.Unlock() 之間的 counter++;Atomic 版本裡整條 atomic.AddInt64 呼叫本身就是硬體保證的最小不可分割單位,語意上等同一個極小的 critical section。What blocks? Mutex 版本:想要 Lock() 但鎖已被別人持有的 Goroutine 會被阻塞,進入等待佇列;Atomic 版本:沒有 Goroutine 會被阻塞(硬體層級直接完成,不需要排隊等待鎖)。What happens under contention? Mutex 版本:大量 Goroutine 同時搶同一個鎖時,等待佇列變長,吞吐量下降為近似序列執行;Atomic 版本:硬體層級仍會序列化實際寫入記憶體匯流排的動作,但沒有 Goroutine 排程器介入的額外開銷,通常在高併發下比 Mutex 更快(今天練習的 (b)(c) 耗時對比應能觀察到這個差異)。Can it deadlock? 今天的單一 Mutex 情境不會 deadlock(沒有互相等待對方持有的鎖);但如果兩個 Goroutine 各自需要同時取得兩把不同的鎖、卻用相反順序索取(Goroutine A 先鎖 mu1 再鎖 mu2,Goroutine B 先鎖 mu2 再鎖 mu1),就可能互相等待對方已持有的鎖——這正是 T-028 Day 46 要深入的 Deadlock,今天先指出「今天的情境不會,但條件一變就會」這個邊界。Verify: go run -race 對未加保護版本應印出明確的 DATA RACE 報告(記錄報告裡指出的行號是否正好對應遞增操作);Mutex 與 Atomic 版本應該不觸發任何 race 報告,且最終 counter 值在多次重跑下都穩定等於預期次數。Conclusion: 用自己的話總結三種版本(無保護/Mutex/Atomic)在正確性與效能上的取捨,並回答:如果今天的共享狀態不是一個簡單整數,而是「同時要更新兩個相關聯的變數,且兩者必須保持一致」(例如轉帳時同時扣一個帳戶、加另一個帳戶),Atomic 還能勝任嗎?為什麼(提示:見 Topic 4 的 Failure Modes)。``
Day 45 — Channel:「不要用共享記憶體來溝通,用溝通來共享記憶體」
學習目標
看完今天內容後,能夠:
- 解釋 Channel 作為 Go 的一級公民同步/通訊機制,說出它跟 Mutex 保護共享變數在思維模型上的根本差異。
- 正確判斷 unbuffered channel 與 buffered channel 在「什麼時候會阻塞」上的差異,並用程式碼驗證。
- 重現並解釋一個典型的 Go deadlock(
fatal error: all goroutines are asleep - deadlock!)成因,為 T-028 Day 46 的 Deadlock 深入鋪墊。
教材大綱
Channel
Core FundamentalsLv.4What:Channel 是 Go 語言內建的型別化管道,讓一個 Goroutine 可以透過 ch <- value 把值送進 Channel,另一個 Goroutine 透過value := <-ch 把值取出來——本質上是一個由 Go runtime 內部保護(自帶同步機制,使用者不需要另外加 Mutex)的執行緒安全佇列。Channel 分兩種:Unbuffered Channel(make(chan T),容量為 0)與 Buffered Channel(make(chan T, n),容量為 n)。
Day 44 的 Mutex/Atomic 解法都是「保護一份共享記憶體,讓多個 Goroutine 輪流安全地讀寫它」——這個思維模型的核心是「先有共享的資料,再想辦法保護它」。Channel 提供另一種思維模型:Go 語言的名言「Don't communicate by sharing memory; share memory by communicating」——與其讓多個 Goroutine 直接共享同一份記憶體再加鎖保護,不如讓資料的所有權在 Goroutine 之間透過傳遞轉移,任何時刻只有一個 Goroutine 真正擁有並操作這份資料,從根本上避免了「多個 Goroutine 同時讀寫同一塊記憶體」的前提,也就不需要 Day 44 的鎖。
Mechanism(阻塞語意):Unbuffered Channel 的發送(ch <-value)會阻塞,直到剛好有另一個 Goroutine 執行對應的接收(<-ch)——兩邊必須配對同時就緒,這個配對發生的瞬間,Go runtime 保證發送方在這之前寫入的所有記憶體操作,對接收方在這之後執行的程式碼都可見(這個「傳遞即同步點」的保證稱為 happens-before 關係,是 Channel 能取代 Mutex 的底層原因:傳遞本身就隱含了一次同步)。Buffered Channel(容量 n)的發送只有在緩衝區已滿(已經有 n 個值還沒被取走)時才會阻塞;接收只有在緩衝區為空時才會阻塞——緩衝區未滿/非空時,發送/接收可以立即完成,不需要另一端剛好同時就緒。
Unbuffered Channel 提供最強的同步保證(發送與接收必然是同一時刻的配對事件),但代價是發送方與接收方的執行進度被緊密綁在一起,其中一方沒準備好,另一方就要等待;Buffered Channel 讓發送方在緩衝區未滿時可以「丟了就走」不用等接收方,解耦了兩者的執行節奏,代價是失去了 Unbuffered Channel「傳遞瞬間必然配對」的強同步時機保證,且緩衝區大小需要仔細評估——太小起不了解耦作用,太大則可能讓上游持續產生資料、下游來不及消化而累積大量記憶體。
Failure Modes(Deadlock 預告):如果對一個 Unbuffered Channel 執行 ch <- value,但整個程式裡沒有任何其他 Goroutine 會執行對應的 <-ch,這次發送會永遠阻塞下去;如果這是主 Goroutine(main 函式)唯一在做的事,且沒有其他 Goroutine 能繼續推進程式狀態,Go runtime 會偵測到所有 Goroutine 都處於等待狀態、沒有任何一個能被喚醒,直接讓程式以 fatal error: all goroutines are asleep - deadlock! 崩潰——這是本 Phase 對 Deadlock 概念的第一次具體示範(完整的 Deadlock 成因分類、如何預防,留給 T-028 Day 46 系統性展開,今天只示範這一種最簡單、最常見的成因:Channel 的發送/接收沒有配對到)。
Backend Applications:Worker Pool 模式(T-028 Patterns 會系統展開)的核心就是用一個 Channel 當作任務佇列——多個「生產者」Goroutine 把任務送進 Channel,固定數量的「worker」Goroutine 從同一個 Channel 取出任務執行;Channel 的緩衝容量與 worker 數量的搭配,直接決定了這個系統在任務量暴增時是「讓任務排隊等待」還是「阻塞上游生產速度」,是設計高吞吐 backend pipeline 時的核心考量。
陌生題示範:情境——一個 unbuffered channel,主 Goroutine 依序執行兩次發送(ch <- 1 接著 ch <- 2),且完全沒有啟動任何其他 Goroutine 去接收。請推導這段程式的執行結果,並指出這符合今天七問裡的哪一項。推導:第一次發送執行時,因為是 unbuffered channel,需要另一個 Goroutine 同時執行接收才能完成配對——但程式裡沒有任何其他 Goroutine 存在(也沒有人會去接收),這次發送會永遠阻塞;主 Goroutine 是程式裡唯一的執行單位,一旦它永久阻塞,Go runtime 偵測到沒有任何 Goroutine 處於可繼續執行的狀態,會直接觸發fatal error: all goroutines are asleep - deadlock! 並終止程式——連第二次發送都不會被執行到。這對應七問裡的 Can it deadlock?:答案是會,而且不需要兩個互相競爭的鎖就能發生——只要有一端的 Channel 操作永遠等不到配對的另一端,就是最簡單的一種 deadlock。
練習(收斂本週練習,為 T-028 的完整 rate limiter 驗收鋪路):實作一個簡化版的並發安全計數式 rate limiter 雛型——用一個 buffered channel(容量等於允許的請求上限)模擬固定數量的 token,每次請求先嘗試從 channel 取一個 token(用 select 搭配 default 分支做非阻塞嘗試)才允許通過,否則視為被限流拒絕;額外開一個背景 Goroutine,每隔固定時間把 token 補回 channel(模擬時間視窗重置)。用至少 50 個併發 Goroutine 模擬請求,記錄通過與被拒絕的請求數,並用 go run -race 確認沒有 race。(完整、正式的 rate limiter 實作與四個驗收問題回答,屬於 Phase 05 整體驗收,由 T-028 Day 50 收尾時完成;今天的雛型是建立 Channel 作為同步工具的直接應用經驗。)
Day 45 過關標準(DoD)—— Concurrency 特殊驗收格式(完整涵蓋七問)
``text Implementation: 完成 Topic 1 陌生題的 deadlock 重現(附完整程式碼與實際跑出的 fatal error: all goroutines are asleep - deadlock!輸出),以及練習的 channel-based token rate limiter 雛型(附程式碼與至少一次執行記錄:通過數/拒絕數)。七問(第 25 節)逐項回答(以 token rate limiter 為例):Where is shared state? buffered channel 本身內部維護的緩衝區(目前剩餘幾個 token)——這份狀態由 Go runtime 內部管理,不是使用者自己宣告的變數。Where is race? 若不透過 channel、改成用一個一般 int 變數手動遞減/遞增 token 數,多個 Goroutine 同時存取就會回到 Day 44 的 race 問題;今天刻意用 channel 取代手動計數,把這個風險轉移給 Go runtime 內部已經處理好同步的實作。What synchronizes it? channel 的 send/receive 操作本身,由 Go runtime 保證這些操作是執行緒安全的。What is the critical section? 語意上等同於「從 channel 取出一個 token」這個單一操作,範圍比 Day 44 手動 Mutex 版本更小、更不容易不小心把不該保護的邏輯包進去。What blocks? 若 channel 已空,非阻塞式的 select+default 設計讓請求不會被阻塞,而是立即判定為被限流拒絕——這是刻意的設計選擇,對照 Topic 1 陌生題裡「不用 select,直接接收會怎樣」的差異。What happens under contention? 大量 Goroutine 同時嘗試從同一個 channel 取 token 時,Go runtime 保證同一個 token 只會被其中一個 Goroutine 成功取走,其餘會在 select 的 default 分支被判定為暫時沒有 token 可用。Can it deadlock? 今天用 select+default 的非阻塞寫法,理論上不會因為 channel 操作本身卡死(沒有任何一個 Goroutine 會無限期等待);但若把練習改成不用 select,直接對一個已滿的 channel 做阻塞式發送(例如補 token 的背景 Goroutine 在 channel 已滿時還硬要塞),就可能重現今天 Topic 1 陌生題那種 deadlock。Verify: 兩段程式碼分別執行成功——deadlock 重現腳本應穩定觸發 fatal error(附完整終端輸出);rate limiter 雛型應能穩定執行完畢、不觸發-race 警告、且通過數不超過設定的 token 上限。Conclusion: 用自己的話總結 Day 44(Mutex/Atomic 保護共享變數)與 Day 45(Channel 傳遞資料所有權)這兩種思維模型的核心差異,並回答:如果你要設計一個「多個 Goroutine 各自累加一個總數,最後要精確拿到總和」的場景,你會選 Day 44 的 Atomic,還是今天的 Channel(例如每個 Goroutine 算完把自己的部分和送進一個 channel,由一個 Goroutine 統一加總)?各自的理由是什麼(提示:兩者都能正確,重點在於除了正確性,還有什麼考量會影響選擇,例如程式碼可讀性、要不要額外處理結果匯總邏輯)。``
Day 41–45 到此結束,涵蓋 Concurrency vs Parallelism、Process、Thread、Goroutine、Scheduler(OS 層級與 Go runtime 概念層級)、Race condition、Mutex、RWMutex、Atomic、Channel 全部知識點,並用「先建立問題(Race condition)、再教三種解法(Mutex/RWMutex/Atomic)、最後教一種不同思維的解法(Channel)」的順序,逐步搭出 Concurrency Reasoning Model 的骨架。Day 46–50(Deadlock/Livelock/Starvation 深入、Go 排程器 G/M/P 內部機制、Worker Pool/Fan-out/Fan-in/Pipeline/Semaphore/Rate Limiter 等 Patterns,以及本 Phase 驗收目標「實作一個 concurrency-safe rate limiter」的完整交付)由 T-028 接續,會直接建立在今天「Critical Section 需要被同步保護、Channel 的傳遞即同步」這個心智模型之上。
Day 46 — Deadlock / Livelock / Starvation:三種「卡住但成因不同」的失敗模式
學習目標
看完今天內容後,能夠:
- 用 Coffman 四條件判斷一段程式碼是否具備形成 Deadlock 的所有條件,並用統一鎖順序修正。
- 準確區分 Deadlock(阻塞、無進展)、Livelock(不阻塞但同樣無進展、持續重試繞圈)、Starvation(特定執行單位長期拿不到資源)三者的核心差異。
- 回頭對照 Phase 04 Day 40 的 transaction 死結案例,指出今天的 Deadlock 與那裡是同一個現象在不同資源型態(DB row lock vs in-process mutex)上的重現。
教材大綱
Deadlock
Core FundamentalsLv.4What:Deadlock(死結)是兩個(或以上)執行單位各自持有至少一項資源、同時在等待對方持有的另一項資源,形成循環等待,導致沒有任何一方能繼續往下執行。與 Phase 04 Day 40 教過的 transaction 死結是同一個現象在不同資源型態上的重現:那裡是兩個 transaction 交叉鎖兩個帳戶列,這裡是兩個 Goroutine 交叉鎖兩個 sync.Mutex。
之所以要拆成四個充分必要條件(Coffman conditions)理解,是因為預防 Deadlock 的每一種方法,本質上都是設法破壞其中一個條件——不理解這四條,容易把「多加一層鎖」當成萬靈丹,反而因為鎖的持有時間變長而提高死結發生的機率(呼應 Day 40 Trade-off 段落「鎖持有時間越長,越可能跟另一個 transaction 的鎖需求交錯形成循環」)。
四個條件同時成立時,一定存在死結風險:(1) Mutual Exclusion(互斥)——資源一次只能被一個執行單位持有,Mutex 本身就是互斥的具體實現;(2) Hold and Wait(占有並等待)——一個執行單位已經持有至少一項資源,同時還在等待取得額外資源;(3) No Preemption(不可搶占)——已分配出去的資源不能被強制收回,只能由持有者自己釋放,sync.Mutex 沒有逾時或強制收回機制,正好符合這一條;(4) Circular Wait(循環等待)——存在一個執行單位的環狀鏈,每個都在等待鏈上下一個所持有的資源。用 Day 44 結尾已經預告的例子具體重現:Goroutine A 先 mu1.Lock() 再嘗試mu2.Lock();Goroutine B 先 mu2.Lock() 再嘗試 mu1.Lock(),用 time.Sleep 強制兩者交錯執行到「各自已經拿到第一把鎖、正要搶第二把」的臨界點。若 main Goroutine 是透過 sync.WaitGroup.Wait()等待這兩個 Goroutine 結束,Go runtime 會偵測到 main 與這兩個 Goroutine 全部處於不可能再被喚醒的狀態,直接印出 fatal error: all goroutines are asleep - deadlock! 並終止程式。
預防死結最常見的做法是「破壞 Circular Wait」——統一所有會同時取得多把鎖的地方的取鎖順序,與 Day 40 轉帳案例的修法完全一致(永遠先鎖 id 較小的資源),成本是需要在整個程式碼庫維持一致的鎖排序紀律,規模變大後容易被新加入的程式碼悄悄破壞;另一種做法是「破壞 Hold and Wait」——嘗試一次性取得所有需要的鎖(Go 沒有原生的多鎖 atomic acquire,需要自己用 TryLock 全部嘗試、任何一個失敗就全部釋放重來),成本是重試邏輯更複雜,且可能引入下面 Topic 2 的 Livelock。
Failure Modes:Go runtime 的死結偵測只在「整個程式裡所有 Goroutine 都不可能再被喚醒」時才會觸發 fatal error;如果程式裡還有其他 Goroutine 仍在正常運行(例如一個背景心跳 Goroutine),即使兩個 Goroutine 彼此死結,runtime 不會偵測到、也不會報錯,程式只會看起來「部分功能卡住但整體沒崩潰」,比直接 fatal error 更難排查——這是許多線上死結問題排查困難的根本原因,通常需要靠kill -QUIT(dump 所有 Goroutine 堆疊)或 go tool pprof 的 goroutine profile 人工比對哪些 Goroutine 卡在 Lock() 上。
Backend Applications:Phase 04 Day 40 的轉帳死結案例——兩個 transaction 交叉鎖兩個帳戶列形成的死結,跟今天兩個 Goroutine 交叉鎖兩個 mutex 是同一個 Circular Wait 現象,只是資源換成 DB row lock;Day 40 當時給的修法(統一先鎖 id 較小的帳戶)與今天的修法(統一鎖的取得順序)是同一個原則的兩次應用。在 backend 系統裡,需要同時持有多個內部鎖的場景(例如一個連線池同時管理多個資源池)必須建立並遵守全域一致的鎖排序規則,否則規模變大後幾乎必然會在某個新加的路徑上意外形成 circular wait。
陌生題示範:情境——一個簡化版的「三個哲學家用餐」:3 個 Goroutine(P0/P1/P2)圍成一圈,每個都需要同時拿到左右兩支叉子(用 3 個 *sync.Mutex 表示,fork[i] 與 fork[(i+1)%3])才能「吃飯」,每個 Pi 都先 Lock(fork[i]) 再 Lock(fork[(i+1)%3])。請推導這個設計是否會 Deadlock,並給出修正。推導:P0 先鎖fork[0] 再要 fork[1];P1 先鎖 fork[1] 再要 fork[2];P2 先鎖fork[2] 再要 fork[0]——若三者幾乎同時各自成功鎖住自己的第一支叉子,P0 等 P1 放 fork[1]、P1 等 P2 放 fork[2]、P2 等 P0 放fork[0],形成三方循環等待,四個 Coffman 條件全部成立,會 Deadlock。修正:比照 Day 40 與本節 Mechanism 段落的作法,打破 Circular Wait——讓其中一個(例如編號最大的 P2)反過來先鎖fork[(i+1)%3] 再鎖 fork[i],即 P2 變成先鎖 fork[0] 再鎖fork[2];這樣 P2 會先跟 P0 競爭 fork[0],不會出現「所有人都先鎖自己那一支、再等下一個人放手」的對稱結構,循環等待的條件被打破,其中一方會先贏得 fork[0] 並順利吃到飯。
Livelock
Supporting TopicsLv.3What:Livelock 是執行單位彼此都沒有被阻塞(仍在持續執行、消耗 CPU),但因為彼此的行為持續互相干擾,導致誰都無法真正完成工作、系統整體沒有任何進展——與 Deadlock 的差別在於:Deadlock 是「卡住不動」,Livelock 是「一直在動但原地打轉」。
常見成因是「禮讓式」的衝突迴避策略被兩邊同時觸發:一個 Goroutine 發現資源被占用就主動放棄、稍後重試;如果兩個 Goroutine 的重試時機因為對稱的邏輯持續同步,會形成誰都搶不到、但也不是真的 block 的僵局——理解 Livelock 的重點是知道「避免死結的 retry 策略如果設計不當,反而會製造 Livelock」,這正是 Topic 1 提到的「破壞 Hold and Wait」解法(TryLock + 重試)本身潛藏的副作用風險。
用 Go 1.18+ 的 sync.Mutex.TryLock() 實作示範:兩個 Goroutine 都想同時拿到 mu1、mu2,為了避免 Topic 1 的 Deadlock,兩者都改用「TryLock 其中一把,失敗就放棄已經拿到的那把、sleep 固定時間後從頭重來」的策略;如果兩者的 sleep 時間設成完全相同的固定值(例如都 sleep 10ms),兩者很可能持續在同一個節奏上重新嘗試、重新衝突、重新放棄,形成活躍但無進展的循環(實際重跑時用計數器統計「兩者都成功完成」這件事發生前重試了幾次,能觀察到次數異常地高、甚至長時間不會發生)。
避免 Livelock 最直接的方法是替每個執行單位的重試等待時間加入隨機抖動(Jitter,例如 sleep(base + rand(0, base))),讓兩邊的重試節奏被打散、不再持續同步衝突;代價是重試邏輯的最壞情況延遲變得不確定(可能運氣不好連續幾次都撞在一起),但期望值上會快速收斂出至少一方成功。
Failure Modes:Livelock 比 Deadlock 更難被發現,因為 CPU 使用率、Goroutine 數量、log 都顯示「系統在動」,容易被誤判成「只是比較慢」而非真正卡死;診斷時的關鍵信號是「重試次數/失敗次數持續飆升,但成功次數長期不動」,而不是單純看 CPU 或 Goroutine 數量。
Backend Applications:分散式系統裡常見的 Livelock 變體是「兩個節點互相謙讓導致誰都不當 leader」(例如兩邊都主動退出 leader 選舉以避免衝突,退出後發現對方也退出了,又同時重新參選)——這跟今天兩個 Goroutine 互相謙讓釋放鎖是同一種模式在不同規模上的重現,通常靠加入隨機化的重試延遲或明確的優先權規則打破對稱性來解決。
實作上述 Mechanism 描述的兩個 Goroutine + TryLock互相謙讓版本,先跑一次固定 sleep 時間(觀察長時間無法兩者皆完成),再改成加入 rand.Intn 產生的隨機抖動,比較兩個版本各自跑 1,000 次「兩者皆完成」所需的平均嘗試次數,記錄結果證明隨機化確實能打破 Livelock。
Starvation
Supporting TopicsLv.3What:Starvation(飢餓)是某個特定執行單位長期(理論上可能永遠)無法取得它需要的資源,即使資源本身並沒有被永久占用、系統整體也在持續運作——重點在「長期偏袒某一方、另一方持續被排擠」,不像 Deadlock/Livelock 是「全部卡住/全部空轉」。
Starvation 通常源自資源分配策略偏向某種「貪婪但公平感差」的規則(例如誰先搶到就先給、不考慮等待時間);理解它的重點是知道「沒有死結、系統仍在正常處理請求」不等於「系統對所有使用者都公平」,這在 backend 系統裡直接對應到某些請求被系統性地拖到超時。
回顧 Day 44 RWMutex 已經提到的具體案例——如果讀鎖的請求持續不斷進來,一個等待寫鎖的 Goroutine 在「讀鎖優先」的實作下可能永遠等不到所有讀鎖釋放的空檔,這就是 Starvation 的一個具體實例(Day 44 當時提到「寫鎖優先」是常見的緩解設計,但緩解不等於消除:只要讀鎖請求的到達速率夠高、間隔夠密,理論上飢餓仍然可能發生)。Go 的 sync.Mutex 本身也內建對 Starvation 的處理:一般情況下用 barging 模式(新來的 Goroutine 可以直接插隊搶鎖,吞吐量較高),但如果某個等待者已經排隊超過 1 毫秒仍未拿到鎖,Mutex 會切換成 starvation mode,改成嚴格 FIFO 交接(鎖直接交給排隊最久的 Goroutine,不允許插隊),直到等待隊列清空或最後一個等待者等待時間低於 1 毫秒才切回 barging 模式——這是 Go runtime 自己為了避免 Starvation 內建的公平性保證,不需要開發者自己實作。
嚴格 FIFO(公平鎖)保證不會有人被飢餓,但犧牲整體吞吐量(即使某個 Goroutine 明明可以立刻拿到鎖執行完,也要排隊等前面的人);barging 模式吞吐量高,但在高競爭下可能讓某些 Goroutine 長期排在後面。Go Mutex 的兩段式設計(平常 barging、偵測到飢餓風險才切換成 FIFO)是這個 trade-off 的一種折衷解法。
Failure Modes:常見誤判是把「平均延遲正常」當成「沒有 Starvation 的證據」——Starvation 通常只影響一小部分請求(被持續排擠的那些),平均值會被大多數正常請求拉低,必須看 p99/p999 這類尾端分位數或「最長等待時間」才容易發現某些請求被系統性地餓死。
Backend Applications:請求優先權隊列設計不當(例如永遠優先處理「新進」請求)是 backend 系統裡 Starvation 最常見的來源之一——一個大量湧入的高優先權請求流,可能讓低優先權請求永遠排不到,設計時通常需要加入「等待時間加權」(等越久優先權越高,即 aging 機制)來保證有限時間內一定會被服務,這跟 Go Mutex 內建的 starvation mode 是同一個思路的不同實作層級。
寫一支程式,開 N 個(例如 20 個)Goroutine 持續用 barging 方式競爭同一個 sync.Mutex(拿到鎖後立刻做極短操作再釋放、馬上重新競爭),另外開 1 個「慢」Goroutine 偶爾(例如每 200ms)才嘗試競爭一次同一個鎖;記錄這個慢 Goroutine 從「開始嘗試」到「實際拿到鎖」平均要等多久,對比 20 個快 Goroutine 彼此之間的平均等待時間,觀察是否有明顯不對稱(因為 Go 1.9+ 的 sync.Mutex內建 starvation mode,預期不會看到無限期飢餓,但仍可能觀察到等待時間不成比例的差異;記錄實際數字)。
Day 46 過關標準(DoD)—— Concurrency 特殊驗收格式(完整涵蓋七問)
``text Implementation: 完成 Topic 1 陌生題的三哲學家死結重現(附完整程式碼與實際 fatal error 輸出)與修正後不死結的重跑結果;Topic 2 Livelock 固定 sleep vs 隨機抖動版本的 1,000 次平均嘗試次數對比;Topic 3 Starvation 的等待時間不對稱測量。七問(第 25 節)逐項回答(以 Topic 1 三哲學家為例):Where is shared state? 3 個*sync.Mutex(fork[0..2])代表的三支叉子。Where is race? 今天不是 race——三個 Goroutine 對同一支叉子的存取本身已經被 Mutex 正確保護,問題不是資料被髒讀髒寫,而是鎖的取得順序造成的循環等待。What synchronizes it? 3 個獨立的 sync.Mutex 各自保護對應的叉子。What is the critical section? 每個 Pi「同時持有左右兩支叉子」的這段區間。What blocks? Lock() 已經被別人持有的那支叉子時會阻塞等待。What happens under contention? 修正前:三者幾乎同時各自鎖住第一支叉子後,全部卡在等第二支叉子,形成永久 contention(誰都不會贏);修正後(P2 反轉順序):P2 會跟 P0 在 fork[0] 上直接競爭,其中一個立刻贏得該鎖並能順利拿到兩支叉子完成後釋放,contention 變成暫時的、會被解決的排隊,不再是永久循環。Can it deadlock? 修正前會(四個 Coffman 條件全部成立,附實際 fatal error 輸出為證);修正後不會(Circular Wait 條件被打破)。Verify: 修正前程式應穩定觸發 fatal error: all goroutines are asleep - deadlock!;修正後應能穩定完成三者的用餐(附多次重跑結果,不應出現任何一次卡死)。Conclusion: 用自己的話總結 Deadlock/Livelock/Starvation 三者的核心差異(阻塞與否、有無進展),並回答:如果要在「統一鎖順序(破壞 Circular Wait)」跟「TryLock + 隨機抖動重試(避免 Hold and Wait 長期持有)」兩種策略之間選一個來預防死結,你會怎麼選、為什麼(提示:想一想哪一種策略比較不會不小心製造出 Topic 2 的 Livelock)。``
Day 47 — Go 排程器深入(G/M/P)+ Context / Cancellation
學習目標
看完今天內容後,能夠:
- 用 G/M/P 模型具體解釋 Day 43 只在概念層級帶過的「Goroutine 怎麼被排到 OS Thread 上」,說出 G/M/P 三種結構各自的角色。
- 用
GODEBUG=schedtrace或runtime/pprof觀察實際的排程行為。 - 用
context.Context正確傳遞取消信號,避免 Goroutine 洩漏。
教材大綱
G / M / P
Core FundamentalsLv.4What:Go runtime 排程器內部用三種結構具體實作 M:N 排程:G(Goroutine 的縮寫,代表一個 Goroutine 本身,包含它的執行堆疊、程式計數器、狀態等);M(Machine,代表一個真正的 OS Thread,是唯一能實際執行機器指令的東西);P(Processor,代表「允許執行 Go 程式碼的排程上下文」,數量由 GOMAXPROCS 決定,每個 P 維護一個本地 Run Queue)。一個 M 必須先「綁定」一個 P 才能從 P 的本地 Run Queue 取出 G 來執行——這個「必須持有 P 才能跑 Go 程式碼」的設計是理解 G/M/P 模型的關鍵:Go 程式碼的並行度上限不是取決於有幾個 M,而是取決於 GOMAXPROCS 設定的 P 的數量。
Day 43 只到「M:N 模型」的概念層級,沒有解釋「為什麼不是 M 個 Goroutine 直接排隊等 N 個 OS Thread 這麼簡單」——真正原因是如果只有一個全域共享的 Run Queue(所有 OS Thread 都從同一個佇列拿任務),每次取任務都需要對這個全域佇列加鎖,在核心數一多時這個全域鎖本身就會變成瓶頸;P 的存在把 Run Queue 拆成每個 P 各自獨立的本地佇列,讓大多數時候 M 不需要跟其他 M 競爭同一把鎖就能拿到下一個要執行的 G,這是 Go 排程器能撐住數十萬 Goroutine 仍保持高吞吐量的關鍵設計。
當一個 G 被建立(go func(){...}()),優先被放進目前執行它的 M 所綁定的 P 的本地 Run Queue;M 會不斷從自己綁定的 P 的本地 Run Queue 取下一個 G 來執行。當某個 P 的本地 Run Queue 空了,排程器會依序嘗試:(1) 從全域 Run Queue(Global Run Queue,GRQ)拿一批;(2) 如果 GRQ 也是空的,隨機挑另一個 P,從它的本地 Run Queue「偷」一半的 G 過來執行——這稱為 Work Stealing,是 Go 排程器在「P 之間負載不均」時自我平衡的機制。當一個 G 執行到會阻塞的系統呼叫(例如一段 Go runtime 無法用非阻塞方式接管的同步系統呼叫),執行它的 M 會連同這個 G 一起被系統呼叫卡住,此時排程器會把這個 M 手上的 P 釋放出來,交給另一個(新建立或原本閒置的)M 繼續處理這個 P 本地 Run Queue 裡的其他 G——這正是 Day 43 陌生題「cgo 呼叫卡住 3 秒,其他連線不受拖累」的具體實作機制:拖累的只是那個特定的 M,P 被交接給別的 M 之後,其他 G 完全不受影響。對於網路 I/O 這類 Go runtime 可以接管的操作,情況又不一樣:G 不會真的阻塞 M,而是被 Netpoller(內部整合了 epoll/kqueue 等 OS 提供的非阻塞 I/O 機制)接手,M 會被釋放去執行 P 本地 Run Queue 裡的下一個 G,等到網路資料真正就緒,Netpoller 才把原本等待的 G 重新標記為 runnable、放回某個 P 的 Run Queue——這是為什麼「Goroutine 阻塞在網路 I/O」完全不會浪費掉一個 OS Thread,跟阻塞系統呼叫(需要額外的 M 頂替)在實作上是不同的兩條路徑。
本地 Run Queue 減少了鎖競爭、提升了吞吐量,但代價是可能出現「某幾個 P 很忙、其他 P 很閒」的負載不均,需要 Work Stealing 這個額外機制來補償——Work Stealing 本身也有成本(要找一個隨機的目標 P、鎖住它的 Run Queue 搬移一半的 G),只是這個成本只在負載真的不均時才會發生,不影響大多數時候的正常路徑。系統呼叫阻塞時「釋放 P 給新 M」的設計保證了公平性,但代價是 M 的數量在大量並發阻塞系統呼叫時可能快速上升(呼應 Day 43 Failure Modes 提到的風險),Go runtime 雖然會限制 M 的總數上限(預設 10,000,可用debug.SetMaxThreads 調整),但過多的 M 本身也會帶來排程與記憶體開銷。
Failure Modes:如果 GOMAXPROCS 在容器化環境裡被設成宿主機的完整核心數,而容器實際的 CPU quota 遠低於此(常見的 Kubernetes CPU limit 場景),會有遠多於實際可並行執行數量的 P 在競爭實際上更少的 CPU 時間,導致大量 CPU throttling——這是 Day 43 Backend Applications 提過的坑,這裡可以更精確解釋成因:P 的數量本身不受 CPU quota 限制,只有 GOMAXPROCS 這個數字決定要建立幾個 P,如果沒有額外套件(例如 automaxprocs)根據容器實際 CPU quota 調整GOMAXPROCS,就會一直建立過多 P,讓排程器誤以為有更多可用的並行資源。
Backend Applications:診斷 Go 服務的排程問題時,GODEBUG=schedtrace=1000 環境變數可以讓 runtime 每 1 秒印出一次排程器內部統計(包含目前的 G/M/P 數量、每個 P 的 Run Queue 長度),runtime/pprof 的 goroutine profile 可以列出目前所有 G 各自卡在哪一行程式碼——兩者是排查「服務為什麼變慢、是不是 Goroutine 洩漏或排程失衡」最直接的工具,比單純看 CPU 使用率更能定位問題根源。
陌生題示範:情境——一個 Go 服務用 runtime.LockOSThread()把某個 G 永久綁定到它當下正在執行的 M 上(這個 M 不會再被拿去執行其他 G),這個 G 接著執行一段長達 10 秒、不涉及任何系統呼叫的純 CPU 迴圈。請推導:(1) 這 10 秒內,原本這個 M 綁定的 P 會發生什麼事;(2) 其他 Goroutine 是否會被拖累。推導:LockOSThread() 保證這個 G 只能在這個特定 M 上執行,但不影響 P 的可用性——G 執行純 CPU 運算時不會主動讓出,Go 排程器只能在有限的「安全點」(例如函式呼叫邊界、GC 相關的搶占點)才能介入搶占;假設這段純 CPU 迴圈內部完全沒有函式呼叫,在 Go 1.14 之前的協作式排程下這個 G 會一直占用它的 P 直到迴圈結束,P 上原本排隊的其他 G 會被卡住不能執行;但 Go 1.14 引入了非同步搶占(基於訊號,可以在幾乎任意指令邊界強制中斷執行超過 10ms 的 G),所以現代 Go 版本下,即使是純 CPU 死迴圈也會在約 10ms 內被強制搶占,把 P 讓給其他排隊的 G——但注意LockOSThread 綁定的 M 本身仍然專屬這個 G,不會被拿去執行別人:搶占後這個 G 會被暫停、P 會先服務其他 G,等排程器認為公平時才把這個 G 重新排回去繼續跑(繼續綁在同一個 M 上)。
Context / Cancellation
Supporting TopicsLv.3What:context.Context 是 Go 標準函式庫提供的一個介面,用來在一串有父子關係的 Goroutine 之間傳遞「取消信號」、「截止時間」、以及少量的請求範圍資料。最常用的建構方式是 context.WithCancel(parent)(回傳一個可以手動呼叫取消的 cancel 函式)、context.WithTimeout(parent, d)(時間到自動取消)、context.WithDeadline(parent, t)(指定絕對時間點自動取消)——三者都回傳一個衍生自 parent 的新 Context,一旦 parent 被取消,衍生出來的所有子 Context 都會跟著被取消(取消信號沿著樹狀結構往下傳播,不能反向)。
一個 HTTP 請求的處理常常會分裂出多個 Goroutine(例如同時呼叫三個下游服務),如果最外層的 HTTP 請求已經被客戶端取消(連線斷開)或已經超過設定的 timeout,理論上所有還在執行的下游呼叫都應該盡快停止、釋放資源,而不是繼續跑到自然結束——沒有一個統一的取消信號傳遞機制,開發者只能各自土法煉鋼(例如自己傳一個chan struct{}),Context 把這件事標準化,成為 Go 生態系統函式簽名的慣例(幾乎所有 I/O 相關的函式,第一個參數都是 ctx context.Context)。
Context 介面提供 Done() <-chan struct{} 方法,回傳一個 channel——這個 channel 在 Context 被取消(不論是手動呼叫 cancel、超過 timeout、或 parent 被取消)時會被關閉(Day 45 已經教過關閉的 channel 會讓所有接收方立刻收到零值,不會阻塞)。任何長時間執行的 Goroutine,只要在它的主迴圈或阻塞操作旁邊用 select同時監聽 ctx.Done(),就能在取消發生的瞬間感知到並提前結束,而不是等到自己自然執行完:select { case <-ctx.Done(): return ctx.Err(); case result := <-workCh: ... }。
Context 讓取消信號的傳遞變得統一、標準化,但代價是每一層呼叫鏈都需要顯式接收並往下傳遞 ctx 參數(Go 團隊刻意不把 Context 塞進 Goroutine 的隱式狀態,而是要求顯式傳遞,這是設計上「顯式優於隱式」的取捨——好處是任何一段程式碼只看函式簽名就知道它是否支援取消,缺點是所有函式簽名都被迫多一個參數)。
Failure Modes:最常見的 Goroutine 洩漏成因就是「啟動了一個 Goroutine 做長時間工作,卻沒有讓它監聽任何取消信號」——即使外層請求早已經結束、沒有人再關心這個 Goroutine 的結果,它仍然會繼續占用記憶體與排程資源直到自然跑完(如果它在等一個永遠不會有人送值的 channel,甚至會永遠卡住,成為永久洩漏);另一個常見錯誤是把context.Background()(永不取消的根 Context)到處亂用,取代了原本該往下傳遞的、真正會被取消的請求 Context,導致取消信號在某一層被「斷開」,看起來支援取消實際上完全沒用。
Backend Applications:HTTP Server 框架(例如 net/http)會自動幫每個 http.Request 建立一個 Context,客戶端斷線或 handler 回傳後會自動取消這個 Context——在 handler 內部呼叫下游服務、查詢資料庫時,把 r.Context() 一路往下傳,是讓整條呼叫鏈能對客戶端斷線快速反應、及早釋放資源(不做無意義的運算)最直接的做法。
寫一個 worker 函式,接收 ctx context.Context 與一個任務 channel,在一個 for-select 迴圈裡同時監聽 ctx.Done() 與任務 channel;啟動這個 worker 後,故意不送任何任務、直接用context.WithTimeout(context.Background(), 2*time.Second) 產生的 Context(2 秒後自動取消),觀察 worker 是否能在 2 秒時印出「收到取消信號,結束」並正常返回(不需要外部強制 kill);接著寫一個「沒有監聽 ctx.Done()」的反例 worker(只監聽任務 channel),觀察同樣的取消場景下這個 worker 是否會永遠卡住(用runtime.NumGoroutine() 在測試前後印出數量,證明這個 worker 洩漏成了一個永遠存在的 Goroutine)。
Worker Pool 概念(銜接明日 Patterns)
Supporting TopicsLv.3What:Worker Pool 是「固定數量的 Goroutine(worker)從同一個任務 channel 持續取出任務執行」的結構——生產者只需要把任務丟進 channel,不需要關心由哪個 worker、什麼時候處理,channel 本身(Day 45 教過的「傳遞即所有權轉移」)保證每個任務只會被其中一個 worker 拿走。
如果任務量暴增時「來一個任務就開一個 Goroutine 處理」,Goroutine 數量會沒有上限地跟著任務量線性成長,可能耗盡記憶體或讓排程器需要管理的 G 數量失控(呼應 Day 43 Failure Modes「數百萬 Goroutine 仍有代價」);Worker Pool 用固定數量的 worker 把「並發執行的上限」明確控制住,任務量暴增時多出來的任務會在 channel 裡排隊等待,而不是無限制地開新 Goroutine。
Mechanism(簡述,明日展開):建立一個 chan Task,啟動 N 個 worker Goroutine,每個都執行 for task := range taskCh {process(task) }(Day 45 range channel 語法:channel 被關閉且清空後,range 迴圈自動結束);生產者把任務送進 taskCh,全部送完後關閉 channel,所有 worker 會在處理完剩餘任務後自然結束。
Backend Applications:資料庫連線池、圖片處理服務的並發上限控制,都是 Worker Pool 的實際應用——資料庫連線數量本身有限(下游資源上限),把「同時執行的查詢數量」用固定數量的 worker 鎖死在連線數以內,是避免打爆下游最直接的做法。
Day 47 過關標準(DoD)—— Concurrency 特殊驗收格式
``text Implementation: 完成 Topic 1 陌生題的 LockOSThread + 純 CPU 迴圈非同步搶占重現(附 go version 確認為 1.14+、GODEBUG=schedtrace=1000 的實際輸出片段,觀察 P 數量與 Run Queue 長度變化);Topic 2 的 worker + ctx.Done() 正確版本與反例洩漏版本對比(附 runtime.NumGoroutine() 前後數字)。七問(第 25 節)在本日相關部分:Where is shared state? 今天沒有傳統意義的共享變數 race 問題,而是「P 的本地 Run Queue」這個排程器內部維護的狀態——它決定了哪些 G 會被哪個 M 執行。Where is race? 今天的 G/M/P 排程器內部狀態(各 P 的本地 Run Queue、全域 Run Queue)完全由 Go runtime 自己管理,使用者程式碼不會直接讀寫這些結構,因此不會像 Day 44 那樣暴露出使用者層級的 race;真正可能被使用者不小心製造出來的 race 反而在 Context/Cancellation 這塊——例如某個原本假設「收到取消後就不會再被存取」的共享變數,在 ctx.Done() 觸發之後仍持續被別的 Goroutine 讀寫,若沒有額外保護,這仍然是傳統定義的 race,只是今天的練習寫法(select 同時監聽 ctx.Done() 後立刻 return)刻意避開了這個情境。What synchronizes it? 排程器內部的 Run Queue 存取由 runtime 自己的內部鎖(per-P 鎖 + 全域鎖,使用者看不到也不需要碰)保護;Context 的取消信號傳遞則是靠 channel 關閉的 happens-before 語意(對一個 channel 執行 close() 這個動作,happens-before 任何後續從這個 channel 收到零值的操作,這是 Go memory model 的保證)——只要 cancel() 被呼叫過,之後任何 Goroutine 讀到 ctx.Done() 已關閉,都能安全地看到取消已發生,不需要額外的鎖。What is the critical section? 對使用者程式碼而言,今天沒有傳統意義上「需要自己上鎖保護」的 critical section——排程器的內部狀態不對使用者暴露;cancel() 本身被設計成可以安全地被多個 Goroutine 同時呼叫多次(第一次呼叫才真正生效,後續呼叫是 no-op,這個冪等性由 runtime 內部保證,使用者不需要自己包一層鎖確保「只呼叫一次」)。What blocks? G 執行阻塞系統呼叫時連帶卡住當下的 M(P 會被釋放交給別的 M);G 等待 ctx.Done() 或任務 channel 時本身被掛起,不占用 M(Netpoller/channel 等待機制會在事件發生時把它標記回 runnable)。What happens under contention?當 P 數量(GOMAXPROCS)小於實際 runnable 的 G 數量時,多餘的 G 要在本地 Run Queue 或全域 Run Queue 排隊;本地佇列耗盡時會觸發 Work Stealing 去偷別的 P 的任務,這是「CPU 排程層級」的 contention,跟 Day 44「共享變數層級」的 contention 是不同抽象層。Can it deadlock? 今天的 Worker Pool 與 Context 機制本身不引入新的死結風險(沒有互相持有的鎖),但如果 Worker Pool 的任務處理內部又去等待另一個已經耗盡的 Worker Pool(例如 worker A 在等 worker B pool 處理完才能繼續,而 B pool 所有 worker 都在等 A pool),會形成跟 Day 46 類似的資源等待循環,只是資源換成「worker pool 的處理容量」而非 mutex。Verify: schedtrace 輸出應能觀察到 LockOSThread 那段純 CPU 運算期間,其他 Goroutine 沒有被永久卡住(約 10ms 級別的搶占間隔);有監聽 ctx.Done() 的 worker 應能在 timeout 觸發的瞬間(附時間戳記)結束,NumGoroutine() 應該在其後回到啟動前的數量;沒有監聽的反例版本,NumGoroutine() 應該在測試結束後仍然比啟動前多 1(洩漏的那個 worker)。Conclusion: 用自己的話總結 G/M/P 三者各自的角色,並回答:如果一個服務同時有大量 Goroutine 卡在阻塞系統呼叫(不是網路 I/O),你預期 M 的數量會怎麼變化?這跟單純增加 GOMAXPROCS 能不能解決這個服務變慢的問題(提示:GOMAXPROCS 只決定 P 的數量,P 不是這裡的瓶頸)。``
Day 48 — Concurrency Patterns(一):Worker Pool(完整版)/ Fan-out / Fan-in
學習目標
看完今天內容後,能夠:
- 完整實作一個 Worker Pool(含結果收集與錯誤處理),並解釋它跟 Day 47 簡述版本的差異。
- 用 Fan-out 把一份工作拆給多個 Goroutine 平行處理,再用 Fan-in 把結果收攏回單一 channel。
- 判斷什麼情境該用 Worker Pool(任務持續送入)vs Fan-out/Fan-in(一次性拆分固定的工作量)。
教材大綱
Worker Pool(完整版)
Supporting TopicsLv.3What:在 Day 47 簡述的基礎上,補上「如何收集每個任務的處理結果、如何處理某個任務失敗」這兩個實務上一定會遇到的問題。
Day 47 的簡述版本只處理「任務怎麼被分配」,沒有處理「結果怎麼收回來」——真實場景幾乎一定需要知道每個任務的處理結果(成功值或錯誤),單純的 for task := range taskCh { process(task)} 沒有回傳管道,今天要補上這個缺口。
任務 channel chan Task 搭配結果 channel chan Result,每個 worker 執行 for task := range taskCh { resultCh <-process(task) };需要一個獨立的 Goroutine 用 sync.WaitGroup確認所有 worker 都完成後才 close(resultCh),讓消費結果的一方能用 range 正常結束(如果結果 channel 由多個 worker 各自關閉,會導致 close of closed channel panic——這是 Worker Pool 最常見的實作錯誤)。
worker 數量設太多,等同於對下游資源開了太大的並發窗口(可能打爆資料庫連線數等);設太少則任務堆積在 taskCh 裡,處理延遲上升——worker 數量通常要根據下游資源的承受上限決定,而非越多越好。
Failure Modes:上述「多個 worker 各自 close 同一個結果 channel」的 panic;另一個常見錯誤是任務處理失敗時直接 return 而不把錯誤寫進結果,導致消費端誤以為該任務成功、少了一筆結果又不知道發生了什麼。
Backend Applications:批次處理任務(例如「重新計算 N 個使用者的統計報表」)的常見做法。
實作一個 Worker Pool,任務是「對一個整數計算是否為質數」,用 5 個 worker 處理 1,000 個任務,用一個獨立 Goroutine 配合 sync.WaitGroup 正確地在所有 worker 結束後關閉結果 channel,主 Goroutine 用 range 收集全部 1,000 筆結果並統計質數總數;額外測試如果任務處理函式對某些輸入會回傳 error,主 Goroutine 能否正確統計出「成功 N 筆、失敗 M 筆」。
Fan-out / Fan-in
Supporting TopicsLv.3What:Fan-out 是把同一個輸入 channel 交給多個 Goroutine 同時讀取、平行處理(「扇出」到多個 worker);Fan-in 是反過來,把多個 channel 的輸出匯合進同一個 channel(「扇入」成單一資料流)。兩者常常成對出現:先 Fan-out 平行處理,再 Fan-in 把結果收攏。
一次性的固定工作量(例如「把這 100 筆資料都算過一遍」)如果只用一個 Goroutine 依序處理,完全沒有利用到多核心;Fan-out 讓固定工作量能平行分散到多個 Goroutine,Fan-in 則負責把分散的結果重新匯集成單一資料流供下游消費,兩者合起來解決「平行處理+收攏結果」這個常見需求。
Fan-out 很簡單——多個 Goroutine 對同一個 channel 執行 for v := range inputCh,Go runtime 保證每個值只會被其中一個 Goroutine 拿到(跟 Day 47 Worker Pool 的 taskCh 本質是同一件事,Fan-out 是從「資料流分配」的角度描述同樣的機制)。Fan-in 則需要一個額外的合併 Goroutine:為每個來源 channel 各起一個 Goroutine,把讀到的值轉送進同一個共享的輸出 channel,並用sync.WaitGroup 追蹤所有來源都讀完(channel 關閉)後,由專門的 Goroutine 在 wg.Wait() 完成後才關閉共享輸出 channel。
Fan-out/Fan-in 能讓「一份大工作」平行處理到多個 CPU 核心,代價是結果的到達順序不再保證跟輸入順序一致(多個 worker 平行處理,誰先完成誰的結果先進到 Fan-in 的輸出 channel)——如果下游需要保序,必須額外替每個結果附上原始索引再重新排列。
Failure Modes:Fan-in 合併 Goroutine 如果忘記用 WaitGroup 等所有來源關閉就直接關閉共享輸出 channel,會導致還在傳值的來源 Goroutine 對已關閉的 channel 送值而 panic(send on closed channel)。
Backend Applications:需要同時查詢多個下游服務再合併結果的場景(例如一次讀取使用者的訂單、庫存、推薦三個微服務的資料再組成一個回應)——Fan-out 同時發起三個請求,Fan-in 等三者都回來後合併,比依序呼叫(一個個等)快得多,是 Day 47 Backend Applications 提到「平行發起多個下游呼叫」的具體實作方式。
實作 Fan-out/Fan-in:一個輸入 channel 送入 100 個整數,用 4 個 Goroutine 平行做「計算平方」的 Fan-out 處理,再用 Fan-in 把 4 條結果流匯合進一個輸出 channel;主 Goroutine range 這個輸出 channel 收集全部 100 筆結果,驗證總數正確、且用 go run -race確認沒有 race(合併邏輯本身若有共享計數器要小心保護)。
Day 48 過關標準(DoD)—— Concurrency 特殊驗收格式
``text Implementation: 完成 Worker Pool(質數判斷,5 worker/1,000 任務,含成功/失敗統計)與 Fan-out/Fan-in(4 worker 平方運算,100 筆資料)兩個練習,附程式碼與執行結果(含 go run -race 輸出)。七問(第 25 節)在本日相關部分:Where is shared state? Worker Pool 的 sync.WaitGroup 內部計數器(由 Go runtime 保護);Fan-in 合併時若用共享計數器統計已處理筆數,則該計數器是 shared state。Where is race?若 Fan-in 合併時讓多個來源 Goroutine 各自直接對同一個計數器做 count++(複合操作,非 atomic),就會是 race,跟 Day 44 的複合操作展開問題一樣;本日練習刻意不這樣寫——統計交給消費端唯一的 Goroutine 在 range 輸出 channel 時累加,只有一個 Goroutine 存取這個計數器,因此不存在 race,這也是 go run -race 應該零警告的原因。What synchronizes it? WaitGroup.Add/Done/Wait 三個方法本身是並發安全的;channel 的 send/receive 如 Day 45 已教過由 runtime 保證安全。What is the critical section? 如果採用「多個 worker 各自累加同一個計數器」的錯誤寫法,critical section 就是那個計數器的讀取-遞增-寫回三步驟(同 Day 44 atomic 章節對 critical section 的定義);本日練習採用的正確寫法把統計收斂到單一消費端 Goroutine 執行,等於刻意讓這個 critical section 只剩一個 Goroutine 會進入,不需要額外上鎖。What blocks? worker 在 taskCh/inputCh 為空時的 range 會阻塞等待下一個值或 channel 關閉;Fan-in 的合併 Goroutine 在 wg.Wait() 處會阻塞直到所有來源 Goroutine 都完成。What happens under contention? 5 個 worker 同時 range 同一個 taskCh,Go runtime 保證每個任務恰好被一個 worker 取走,不會重複處理也不會遺漏;worker 數量若少於任務到達速率,taskCh 會持續堆積。Can it deadlock? 若結果 channel 的 buffer 設為 0(unbuffered)且消費端比生產端慢很多,理論上不會真的 deadlock(unbuffered channel 的阻塞會等到對方就緒,只是變慢,不是循環等待);但如果錯誤地讓多個 worker 各自嘗試 close(resultCh),程式會 panic 而非 deadlock——今天要能分辨「panic(程式設計錯誤)」與「deadlock(循環等待)」是兩種不同的失敗模式。Verify: 質數判斷結果需與暴力驗證一致(用另一個獨立函式重新判斷相同 1,000 個數字,比對總數一致);Fan-out/Fan-in 的 100 筆平方結果需與預期值逐一比對正確,且 -race 無警告。Conclusion: 用自己的話解釋 Worker Pool 與 Fan-out/Fan-in 的核心差異(持續消費 vs 一次性拆分固定工作量),並回答:如果任務處理時間長短差異很大(有些任務 1ms 完成、有些要 1 秒),Fan-out 用「固定數量 Goroutine 平均分配任務」的簡單實作,會不會讓整體完成時間被少數慢任務拖累?為什麼(提示:想一想任務是怎麼被分配到哪個 Goroutine 執行的,如果是 Worker Pool 模式又會有什麼不同)。``
Day 49 — Concurrency Patterns(二):Pipeline / Semaphore / Rate Limiter(第 2 次出現:單機 concurrency-safe 實作)
學習目標
看完今天內容後,能夠:
- 用多個階段的 channel 串接實作 Pipeline,解釋每個階段之間如何解耦。
- 用 Semaphore 限制同時執行的並發數量上限,並說明它與 Worker Pool 的差異。
- 完整實作一個單機 concurrency-safe 的 Rate Limiter,銜接 Day 50 的 Phase 收尾驗收。
教材大綱
Pipeline
Supporting TopicsLv.3What:Pipeline 是把一連串處理步驟各自放進一個 Goroutine,前一階段的輸出 channel 當作下一階段的輸入 channel 串接起來,資料像流水線一樣依序流過每個階段——每個階段可以是 1 個或多個(Fan-out)Goroutine。
如果把所有處理步驟寫在同一個 Goroutine 裡依序執行,前一筆資料的第 2 步驟要等第 1 步驟完全做完才能開始,且沒辦法讓不同階段各自平行處理(例如階段 1 在處理第 5 筆資料的同時,階段 2 可能已經在處理第 3 筆);Pipeline 用 channel 把階段解耦,讓每個階段依照自己的速度處理,只要 channel 不空就能持續往下送。
stage1 := gen(nums) 回傳一個 channel,stage2 :=square(stage1) 接收 stage1 的 channel、對每個值做處理後送進自己回傳的新 channel,串接下去 stage3 := filter(stage2);每個階段函式內部都是 for v := range in { out <- process(v) } 然後close(out)(前一階段送完並關閉,下一階段的 range 才會正常結束,一路傳導下去,最後一個階段關閉時,消費端的 range 也會結束)。
Pipeline 讓不同階段可以平行處理不同筆資料(提升總吞吐量),但整體處理速度受限於最慢的那個階段(跟硬體 Pipeline 的「木桶效應」是同一個概念);如果某階段特別慢,可以對那個階段單獨做 Fan-out(多開幾個 Goroutine 平行跑那一階段)來消化速度差異。
Failure Modes:如果某個階段忘記在處理完所有輸入後關閉自己的輸出 channel,下一階段的 range 會永遠等待、永遠不會自然結束(也不會立刻 panic,是一種「看起來還在跑、實際上已經不會再有新資料」的隱性卡住,跟 Livelock 的「忙碌」不同,更接近部分 Deadlock:這個階段之後的所有 Goroutine 都在等一個不會來的關閉信號)。
Backend Applications:日誌處理管線(讀取原始 log → 解析 →過濾 → 寫入資料庫)是 Pipeline 的典型應用,每個階段職責單一,方便針對瓶頸階段單獨調整並發數。
實作一個 3 階段 Pipeline:階段 1 產生 1 到 1000 的整數,階段 2 把每個數平方,階段 3 過濾出偶數結果,最終由主 Goroutine 收集數量並驗證正確;額外把階段 2 改成 Fan-out 成 3 個 Goroutine 平行處理,驗證結果集合仍然正確(順序可能改變,但值的集合應一致)。
Semaphore
Supporting TopicsLv.3What:Semaphore(信號量)是限制「同時能有多少個執行單位在做某件事」的通用機制,用 Go 實作最簡單的方式是一個容量為 N 的 buffered channel:進入受限區域前先送一個值進 channel(若已滿則阻塞等待),離開時再從 channel 取走一個值釋放名額。
有些資源的並發上限跟「要處理的任務總數」無關,而是跟「下游能承受的並發數」有關(例如某個第三方 API 限制每秒最多 10 個並發請求)——Worker Pool 的 worker 數量綁定的是「處理任務的 Goroutine 數」,但如果同一批任務裡有些步驟需要呼叫這個有並發限制的 API、有些不需要,直接把整個 Worker Pool 縮小到 10 個 worker 會不必要地拖慢不涉及 API 呼叫的部分;Semaphore 讓你只對「需要限制的那個特定操作」設並發上限,其他部分仍可以用更多 Goroutine 平行處理。
sem := make(chan struct{}, 10);要執行受限操作前sem <- struct{}{}(channel 未滿立即成功,已滿則阻塞直到有名額釋放),操作完成後 <-sem 釋放一個名額。這跟 Day 45 教的 buffered channel「發送只有緩衝區滿了才阻塞」的語意完全吻合,Semaphore 本質上是 buffered channel 的一種應用模式,不是新的底層機制。
實作簡單(不需要額外的第三方套件),但只提供「計數」語意,沒有 Mutex 那種「保護特定資料」的語意,也沒有優先權控制(跟 Day 46 Starvation 討論過的 barging/FIFO 是完全不同層次的問題,Semaphore 本身不保證公平)。
Failure Modes:忘記在操作完成後釋放名額(<-sem 漏寫,尤其是在有 error 提前 return 的路徑上忘了釋放)會造成名額持續減少,最終所有 Goroutine 都卡在等待取得名額,且沒有任何名額會再被釋放——這是一種因為忘記清理資源而製造出來的類 Deadlock 情境,用defer func(){ <-sem }() 在拿到名額後立刻註冊可以避免這個問題。
Backend Applications:對有並發限制的第三方 API、或需要限制同時開啟的檔案數/DB 連線數的場景,Semaphore 是最輕量的解法。
實作一個 Semaphore(容量 5),模擬 20 個 Goroutine 都要呼叫一個「模擬外部 API」的函式(內部只是 sleep 100ms),確認同一時間最多只有 5 個 Goroutine 在執行這個模擬呼叫(可以用一個受 Mutex/Atomic 保護的計數器記錄「目前正在呼叫中的數量」,驗證這個數字從未超過 5)。
Rate Limiter(第 2 次出現:單機 concurrency-safe 實作)
Core FundamentalsLv.4Rate Limiting 在整個課程第 2 次出現——Phase 01 Day 9(Reliability 層級)只教了「為什麼需要限流、超過限制要回 429 + Retry-After」的概念層級,沒有涉及任何演算法或並發實作細節;今天要補上那裡刻意留白的部分:單機上如何用 Mutex/Atomic 正確、安全地實作一個真正會被多個 Goroutine 同時呼叫的限流器(多節點共享限流狀態的更深層問題,留給 Phase 07 Day 61–70 的 System Design 再處理1)。
What:Token Bucket 是最常見的限流演算法——維護一個容量固定(例如 100)的「桶」,桶裡裝著 token,每次請求要通過必須先拿到一個 token(拿不到就拒絕),桶裡的 token 會以固定速率(例如每秒補 10 個)持續補充、補滿就停止(不會超過桶的容量)。這個設計允許短時間的突發流量(只要桶裡還有累積的 token,可以瞬間用掉一大批),同時長期平均速率被補充速率限制住。
相較於簡單的「固定時間窗口內最多 N 次請求」(Fixed Window),Token Bucket 能處理「窗口邊界效應」——Fixed Window 在兩個窗口交界處可能讓請求量瞬間達到 2N(前一個窗口末尾 N 次 + 後一個窗口開頭 N 次擠在很短時間內),Token Bucket 用連續補充 token 的方式,天然不會有這種邊界突刺問題。
用 int64 記錄目前桶內 token 數(atomic.LoadInt64/atomic.AddInt64 保護,呼應 Day 44 Atomic「單一簡單數值的原子操作場景」),每次請求先嘗試 atomic.AddInt64(&tokens, -1),若結果 < 0 表示沒有足夠 token,要把剛才扣掉的 1 加回去(atomic.AddInt64(&tokens, 1))並回報限流拒絕;另開一個背景 Goroutine,每隔固定間隔(例如 100ms 補 1 個 token,等同每秒 10 個)用 atomic.CompareAndSwapInt64 確認目前值未達桶容量上限時才 AddInt64 補 1(避免超過容量)。這裡刻意用 Atomic 而非 Mutex,是因為整個操作只牽涉單一個 int64 數值的讀取/加減/比較(Day 44 Atomic 的 Backend Applications 段落已經點出「Rate Limiter 內部的請求計數器」正是 Atomic 的典型場景);如果限流規則更複雜(例如要同時維護多個使用者各自的桶、且要在同一次操作裡檢查 + 扣減 + 記錄時間戳三件事的一致性),就會需要 Mutex 包住整個複合操作(呼應 Day 44 Atomic Failure Modes「多個獨立 Atomic 操作拼不出複合一致性」)。
Token Bucket 允許突發流量,好處是不會誤傷正常但短時間內比較密集的合法使用模式;代價是無法完全防止「瞬間衝高到桶容量上限」這種短暫的高峰(如果下游資源連這個瞬間高峰都撐不住,需要額外的 Semaphore 限制真正同時執行中的數量,而不只是限制「通過」的速率——這是 Rate Limiter 跟 Semaphore 經常被一起使用、但解決的是不同問題的原因:Rate Limiter 限制「單位時間內允許通過幾次」,Semaphore 限制「同時間允許幾個在執行中」)。
Failure Modes:如果補充 token 的背景 Goroutine 因為某種原因(例如 panic 後沒有 recover、或者被外部 cancel 但沒有正確處理)意外停止,桶的 token 會被持續消耗到 0 之後就再也不會恢復,之後所有請求都會被誤判為超過限制——這是 Rate Limiter 實作裡「背景補充機制本身的健康度」必須被監控的原因,通常需要額外的機制偵測這個背景 Goroutine 是否還活著(例如定期心跳)。
Backend Applications:API Gateway、單一服務實例內部對下游資源的自我保護,都會用到這種單機版 Rate Limiter;當服務水平擴展成多個實例時,各自維護獨立的單機限流器會導致「總限流量 = 單機限制 × 實例數」而非預期的全局限制,這正是 Phase 07 會處理的「多節點如何共享同一份限流狀態」的問題(今天先把單機版做對、做安全,是那個更難問題的必要前置)。
陌生題示範:情境——如果把上面 Mechanism 改成先做 if atomic.LoadInt64(&tokens) > 0 { atomic.AddInt64(&tokens, -1);allow() }(先 Load 檢查,檢查通過了才 Add 扣減,兩個操作分開),而不是先 AddInt64(-1)、結果 < 0 才加回去,請推導這段程式碼在高並發下是否仍然正確。推導:Load 與 AddInt64 是兩個各自獨立的原子操作,但兩者之間沒有被包成一個整體的 critical section——1,000 個 Goroutine 同時執行到 Load 時,可能全部都讀到「目前還有 1 個 token」(因為沒有人真的扣減,Load 不會改變狀態),全部都判斷通過檢查、全部都接著執行 AddInt64(-1),導致 token 數被扣成負值、且遠超過 1 個請求被放行,這正是 Day 44 Atomic Failure Modes 描述的「多個獨立 Atomic 操作拼湊複合邏輯,中間會被其他 Goroutine 插入」的具體重現(Check-Then-Act 的經典 race pattern,跟 Day 44 Topic 1 counter race condition 的本質完全相同,只是這次錯誤地以為用了 Atomic 就天然安全)。正確做法就是 Mechanism 段落描述的「先扣、扣完發現不夠再補回去」(順序反過來),讓扣減這個動作本身就是檢查是否成功的依據,不需要額外的 Load 步驟介入。
Day 49 過關標準(DoD)—— Concurrency 特殊驗收格式
``text Implementation: 完成 Pipeline(3 階段 + 其中一階段 Fan-out 成 3 Goroutine)、Semaphore(容量 5,20 Goroutine 模擬 API 呼叫,驗證同時執行數從未超過 5)、Rate Limiter Token Bucket(附 Topic 3 陌生題的 race 重現:先 Load 後 Add 版本用大量並發測試觀察 token 被扣成負值或超發的證據;以及修正後先 Add 後回補版本的正確性驗證)。七問(第 25 節)在本日相關部分(以 Rate Limiter 為例):Where is shared state? int64 tokens 計數器。Where is race? 陌生題的 Load-then-Add 版本:Load 與 AddInt64 之間沒有被綁成單一 critical section,多個 Goroutine 可以同時通過 Load 檢查。What synchronizes it? 正確版本:單一次 atomic.AddInt64 本身的原子性(配合「扣完發現不夠再補回去」的順序設計,讓扣減動作本身兼任檢查)。What is the critical section? 正確版本裡語意上等同一整次「AddInt64(-1) + 視結果決定是否補回」這個邏輯單元,但因為補回的判斷只需要看 AddInt64 自己的回傳值(不需要額外再 Load 一次),實際上不需要額外的鎖就能維持這個邏輯單元的一致性。What blocks?今天的 Rate Limiter 採用非阻塞設計(超過限制直接拒絕,不等待);Semaphore 版本裡,名額用盡時會阻塞直到有 Goroutine 釋放名額;Pipeline 的每個階段在輸入 channel 空/輸出 channel 滿時會阻塞。What happens under contention? Rate Limiter 高並發下大量 Goroutine 同時執行 AddInt64,硬體層級序列化實際的扣減順序,但不會阻塞任何一個 Goroutine(跟 Day 44 Atomic 版本 counter 的 contention 行為一致);Semaphore 高並發下第 6 個以後的 Goroutine 會持續排隊等待前面釋放。Can it deadlock? 今天三個 Pattern 都不引入鎖的互相持有,不會 Deadlock;但 Semaphore 若忘記釋放名額(Failure Modes 提到的漏寫 defer),會造成類似死結的永久等待(本質是資源洩漏而非循環等待,需要區分:Deadlock 是循環等待已存在的資源,這裡是資源被永久拿走不釋放,兩者外部現象相似但成因不同)。Verify: Pipeline 最終結果集合需與預期一致;Semaphore 的「同時執行數」計數器需在多次重跑下從未超過 5;Rate Limiter 錯誤版本需能重現超發(記錄實際觀察到 token 被扣至負值或允許通過次數超過預期上限的證據),正確版本需在相同並發壓力下不超發。Conclusion: 用自己的話解釋為什麼「看起來都用了 Atomic」不保證沒有 race(陌生題的 Check-Then-Act 陷阱),並回答:明天(Day 50)要交付完整的 concurrency-safe rate limiter,你會選今天的 Token Bucket 演算法還是 Fixed Window?為什麼(提示:想一想 Mechanism 段落提到的窗口邊界突刺問題)。``
- 依 00-knowledge-dependency-graph.md 第 3.4 節的深度遞增表格 ↩
Day 50 — Phase 05 收尾 Capstone:完整 Concurrency-Safe Rate Limiter
學習目標
看完今天內容後,能夠:
- 整合 Day 41–49 全部知識點,交付一個完整、可執行、通過並發壓力測試的 concurrency-safe Rate Limiter。
- 具體回答驗收要求的 race/lock/contention/throughput 四個問題。
- 回顧整個 Phase 05,建立完整的 Concurrency Reasoning Model。
Capstone 任務規格
實作一個具備以下能力的 Rate Limiter package,對應第 9 章驗收1「實作一個 concurrency-safe rate limiter」:
- 用 Day 49 的 Token Bucket 演算法(每秒補充速率、桶容量皆可設定)。
- 對外暴露
Allow() bool方法,供任意數量的 Goroutine 並發呼叫。 - 內部用 Day 44/Day 49 已驗證過的 Atomic 方式實作(先扣減、扣完發現不夠再補回去的順序),通過
go run -race零警告。 - 用至少 500 個並發 Goroutine 同時打這個限流器(模擬高並發請求),統計實際通過數量應該落在「桶容量 + 壓測總時長內理論補充量」的合理範圍內(不應該明顯超發,也不應該因為實作錯誤漏放行)。
- 額外附一個「沒有正確處理並發」的錯誤版本(例如故意用一般 int 搭配沒有保護的
++/--)做對照,展示go run -race會偵測到的具體差異,佐證今天的正確版本確實解決了這個問題。
驗收四問(本日 Capstone 專屬,逐題需有實測數字或具體程式碼引用支撐)
Race:這個 Rate Limiter 哪裡可能有 race、今天怎麼避免的?——需要具體點出 shared state(token 計數器),說明如果用一般變數搭配非原子的讀取-修改-寫回會怎樣(回顧 Day 44 Topic 1 的經典 race),並說明今天用 Atomic 為什麼能避免(單一數值操作的硬體原子性),附錯誤對照版本的 -race 實際輸出作為證據。
Lock:這個實作有沒有用到鎖(Mutex)?為什麼選擇用/不用?——今天的正確版本刻意只用 Atomic、不用 Mutex(因為只需要保護單一 token 計數器這個簡單操作,回顧 Day 44 Atomic vs Mutex 的 trade-off);需要額外回答:如果限流器要擴充成「針對不同 API Key 各自維護獨立配額」,會不會需要引入 Mutex(例如保護一個map[string]*int64 的讀寫,map 本身的並發讀寫需要額外保護,不像單一 int64 可以直接用 atomic)?
Contention:500 個 Goroutine 同時打這個限流器,contention 具體長什麼樣、吞吐量會怎麼變化?——需要附實際壓測數據:並發數從 50/100/500 遞增時,單位時間內實際能處理的 Allow() 呼叫次數(QPS)是否隨並發數上升而持續上升,還是在某個點開始持平甚至下降(硬體層級對同一個 int64 的原子寫入終究需要序列化,理論上會有上限)。
Throughput:這個實作的吞吐量上限大概在哪、被什麼限制住?——需要給出實際測量數字(例如「單機在測試機器上,Allow() 呼叫本身的吞吐上限約每秒 X 次」),並解釋這個上限主要來自 CPU 對同一個記憶體位置做原子寫入時的硬體層級序列化(Day 44 Atomic Mechanism 段落已經提過「硬體層級仍會序列化實際寫入記憶體匯流排的動作」),不是 Go runtime 排程器的限制(呼應 Day 47 G/M/P:這裡的瓶頸跟 P 的數量、Goroutine 排程無關,是更底層的記憶體/CPU 快取一致性協定限制)。
Day 50 過關標準(DoD)—— Concurrency 特殊驗收格式(Phase 05 收尾 Capstone,完整實作一次)
```text Implementation: 完整 Rate Limiter package(Allow() 方法、背景補充 Goroutine、可設定容量與補充速率),附 500 並發壓測程式碼與實際執行結果(通過數/拒絕數、耗時、go run -race 輸出);附錯誤對照版本(無保護的 int++/--)與其 -race 偵測報告。
標準七問(第 25 節)完整回答:Where is shared state? int64 token 計數器(跨越所有呼叫 Allow() 的 Goroutine 與背景補充 Goroutine 共享)。Where is race? 錯誤對照版本:token--這類複合操作展開後的讀取-修改-寫回三步驟會被其他 Goroutine 交錯;正確版本:不存在傳統 race,因為所有存取都通過 atomic 操作完成。What synchronizes it? atomic.AddInt64/CompareAndSwapInt64 的硬體原子性。What is the critical section? 語意上是「檢查並扣減一個 token」這個邏輯單元,但如 Day 49 所述,靠「先扣減、扣完發現不夠再補回去」的順序設計不需要額外的鎖包住這個單元。What blocks? Allow() 本身不阻塞(超限直接回傳 false);只有背景補充 Goroutine 按固定間隔 sleep。What happens under contention? 見下方 Contention 專答,附實際 QPS 數字。Can it deadlock? 不會——整個實作沒有任何 Mutex.Lock()、沒有互相持有的資源,只有原子操作與固定間隔的 sleep。
驗收四問(逐題需附實測數字或具體程式碼引用):Race——見上方「驗收四問」Race 段落,附錯誤版本的 -race 輸出。Lock——見上方 Lock 段落,附是否需要擴充成 map 的分析。Contention——附 50/100/500 並發的 QPS 實測表格。Throughput——附實測 QPS 上限數字與硬體層級序列化的解釋。
Verify: 500 並發壓測重跑至少 3 次,通過數量應穩定落在合理範圍內(附 3 次數據);-race 在正確版本上零警告,在錯誤對照版本上應穩定偵測到 DATA RACE。
Conclusion: 回顧 Day 41–50 整個 Phase,用自己的話寫一段總結:Concurrency Reasoning Model(七問)如何貫穿了從 Race Condition 的發現、Mutex/Atomic/Channel 三種解法、Deadlock/Livelock/Starvation 三種失敗模式、到 G/M/P 排程器與六種 Pattern 的整個學習路徑;並回答:如果要教一個完全沒學過 Concurrency 的人,你會用今天 Rate Limiter 的哪一個環節,作為解釋「為什麼並發程式設計需要特別小心」的第一個例子?為什麼。```
Day 46–50 到此結束,Phase 05(Day 41–50)全部完成,涵蓋 Deadlock/Livelock/Starvation、Go 排程深入(G/M/P/Goroutine scheduling/Context/Cancellation/Worker pool)、六種 Concurrency Pattern(Worker Pool/Fan-out/Fan-in/Pipeline/Semaphore/Rate Limiter),並用 Day 50 的完整 concurrency-safe rate limiter 實作對應本 Phase 在第 9 章訂下的驗收目標2。Day 46 的 Deadlock 回頭引用了 Phase 04 Day 40 的 transaction 死結案例(前向教學+後向複習的要求3),Day 49 的 Rate Limiter 明確標註這是第 2 次出現(第 3.4 節深度遞增表格),T-031(Phase 07 Day 61–65)的 Rate Limiter System Design 題型將是第 3 次、也是最深一次出現,會直接建立在今天單機 Token Bucket 實作之上,延伸到多節點共享限流狀態的問題。