Async


🎵 Program、Process、Thread

想像你是一位準備開店的廚師,桌上攤開一本厚重的菜譜——這就是你的 Program(程式碼集合)。它記錄了每道菜的做法、調味的比例、上菜的順序……但這些指示仍停留在紙上,尚未進入現實。它像是還未走進世界的夢,只存在於設計之中。
Image

當你真正開張營業、招呼客人,這份菜譜就被實體化成了一間 Process(處理程序)—— 一間運作中的餐廳。這間餐廳開始佔據空間(記憶體)、使用瓦斯爐(CPU)、冰箱(硬碟),並啟動了你的夢想。每一間正在運作的餐廳,都是一個獨立的 Process,就像你電腦上同時開著 Word、Chrome 和 Spotify,一家店煮咖哩、一家店沖咖啡,彼此不會互相干擾。。
Image

但餐廳開起來只是第一步,真正讓整個流程流動的,是餐廳裡的「人」—— 這些人,正是 Thread(執行緒)。一家只有一位大廚的小餐館,也許只能一張一張煎魚、一道一道上菜(單執行緒);但一家人潮洶湧的熱炒店,就需要多位大廚同時開火、服務生穿梭上菜(多執行緒),讓整個店面井然有序地高速運作。
Image
Image

在一個開發工具(像 Visual Studio)裡,一個 Thread 負責讓你輸入程式碼,一個 Thread 在背景自動補全,一個 Thread 則默默幫你檢查語法錯誤。這些 Thread 如同一場無聲的合奏,讓我們的程式體驗更流暢、效率更高。



🎵 當世界還只能一條路

Image

在計算機的早期時代,程式的執行方式非常單純,就像一位職人,一次只能處理一件事。這種模型稱為 單一任務(Single Tasking) 或 線性執行(Sequential Execution)。

想像這位職人開了一家只有他一人的餐廳,他會專心地切菜、煮湯、上菜,但這些事必須一件一件來——切菜沒完,湯就不能開始煮,湯沒煮完,客人就只能等著挨餓。

這種方式的好處是行為可預測、邏輯簡單,對初學者或早期系統來說特別友善。然而缺點也很明顯:

任何一個步驟卡住(如等待輸入、磁碟存取),整間餐廳就陷入停擺。

所有任務只能排隊等待,效率極低。

舉例來說,當你按下「列印」鍵,整個作業系統會乖乖等印表機印完文件,才能繼續幫你開啟 Word 文件或播放音樂。那是一個一切都要等的時代,也是一個「同步的純粹年代」。



🎵 餐廳開多間了,但廚師還是只有一個

Image

隨著科技進步,系統開發者開始覺得:「既然只能一間餐廳處理所有事太慢,不如多開幾家餐廳吧!」

這就是 Process(處理程序) 的誕生。每個 Process 就像一間獨立餐廳,擁有:

  • 自己的廚房(記憶體空間)
  • 自己的設備與員工(變數與執行環境)
  • 自己的顧客(任務)

這種設計最大的好處是穩定性大幅提升。即使某間餐廳著火了(Process 當掉),其他餐廳依舊照常營運,互不影響。

Image

但問題仍未解決:城市裡的廚師數量沒變,依舊只有一位(單核心 CPU)。當某間餐廳(例如 Process 3)陷入無限迴圈,這位唯一的廚師會被困在那裡無法脫身,導致整個城市的其他餐廳也停擺 —— 系統彷彿「凍結」。

即使其他餐廳只是短暫閒置(如程式在等待磁碟 I/O),這位廚師也不能輕易跳去支援,因為 Process 的排班方式是以整間餐廳為單位,切換成本高、彈性低。CPU 的時間無法被有效運用,導致浪費。



🎵 終於,員工來了—— Thread 的誕生

Image

執行緒是作業系統用來虛擬化 CPU 的概念。每間餐廳(Process)終於可以不只靠一位廚師了,開始請來多位員工(Thread)分工合作:

  • 一位專心備料
  • 一位負責煮湯
  • 一位負責上菜

這些員工共享同一間廚房(同一個記憶體空間),但各自有自己的筆記本與待辦清單(獨立的呼叫堆疊與區域變數),可以同時執行不同任務。這讓餐廳的整體效率提升數倍。

每個 Process 包含:

  • 一份記憶體空間(類似整間廚房的配置)
  • 一個以上的 Thread(也就是員工們)

而每個 Thread 內部還包含:

  • Stack(堆疊):紀錄從 main 開始到目前所有函數呼叫的路徑
  • 暫存器狀態:記錄目前 CPU 執行的狀態(如 Program Counter, Stack Pointer 等)

也因此,每個 Thread 雖然能存取相同的資源(如共用 Object),但它們的局部變數彼此獨立,互不干擾。

作業系統會使用排程器(Scheduler)將 CPU 的執行時間切成一段段的「時間片(Time Slice)」,並在每段結束後,切換至下一個 Thread。這樣的切換極快,以至於人類的感知上,會以為電腦是在「同時」處理很多任務,其實只是輪流快速執行罷了。

需要強調的是:Thread 並不是切割出一塊硬體資源「專屬使用」,而是共享同一顆 CPU,透過時間分配機制來達到多工效果。就像只有一口爐子的廚房,所有廚師都輪流使用它,只是每人都排了極短的時間段,看起來像是大家同時在煮飯。

這也是我們常說「一條執行緒」的由來 —— 它是一段獨立的程式碼序列(code sequence),在 CPU 的時間裡有屬於自己的一段舞台。



🎵 換人上場的代價:執行緒的切換成本

Image

雖然執行緒的出現讓整體系統更靈活,但在「換人上場」的瞬間,其實暗藏了不少代價——這就是所謂的 上下文切換(Context Switch)。

從人類角度來說,我們每天也在做類似的切換:回家寫程式前,你可能正忙著回主管的訊息、思考晚餐要吃什麼。當你轉身坐下來面對鍵盤,腦袋是不是需要一段時間才能進入「開發模式」?這種「切換情境」的疲憊,就是 Context Switch 的真實寫照。

CPU 的角色輪替

對於只有一顆 CPU 的電腦來說,同一時間只能執行一件事情。當作業系統同時載入多個程式,每個程式裡又有多個執行緒在等待「上場」,這時就必須靠 排程器(Scheduler) 切割 CPU 的時間。

這就像一座只有一個舞台的小劇場,而每個執行緒都是候場的演員。每個人只能輪流上台,表演幾秒鐘後,就必須讓出舞台給下一位。這樣的切換過程就叫做 Context Switch。

每一次 Context Switch,系統都要進行三個步驟:

  • 存檔:把目前執行緒的暫存器資料(如程式計數器、堆疊指標等)保存下來,像是演員記下自己演到哪一句台詞。
  • 挑人:由排程器選出下一位要上台的執行緒,如果這位演員來自另一部戲(另一個 Process),還要換佈景(切換虛擬位址空間)。
  • 載入:把新執行緒的暫存器資料載入,讓 CPU 可以無縫接續新的任務。

Context Switch 並非無痛,它會帶來以下幾種資源損耗:

  • CPU 時間消耗,每次保存與恢復暫存器、更新排程資料,都是額外的 CPU 工作,等於花掉寶貴的運算時間在「換人」上。
  • 快取失效(Cache Miss),CPU 有快取記憶體來加速存取,但執行緒一換,原本載入的資料可能馬上就沒用了,只能重新載入新資料,效能反而降低。
  • 記憶體訪問延遲,不同 Process 間的切換涉及到虛擬記憶體管理,例如頁表(Page Table)與 TLB 的更新,這會造成更多的記憶體存取延遲。
  • 指令管線刷新(Pipeline Flush),現代 CPU 使用管線化(Pipeline)技術來提升執行效率。但每次切換 Thread 時,原本排好的指令隊伍得清空,重新安排,導致效率下降。
  • 系統管理開銷,作業系統還得額外維護執行緒的狀態、排程計數器、優先權等系統資料結構,這些也都是成本。
  • 記憶體額外負擔,每個執行緒都需要獨立的堆疊與暫存空間來儲存它的「上下文」,這會吃掉不少記憶體。

不只是執行效能,執行緒數量的多寡也影響 .NET 的垃圾回收(GC) 機制。在進行資源回收時,CLR 必須暫停所有執行緒(Stop the World),等回收結束後再讓它們恢復運作。Thread 越多,越難同步停下、又越慢能重啟。
同樣情況也發生在除錯時:當你設定中斷點,整個應用程式的所有 Thread 都會被暫停,直到你按下「繼續」或「單步執行」,這些執行緒才會再次「醒來」。

太多 Thread,就像請了太多員工卻讓他們輪流站崗、互相打斷,反而讓餐廳變得混亂。好用的不是 Thread 多,而是分配得宜、配合默契。



🎵 當 Thread 遇上外來者:非託管 DLL 的插手

在我們設計多執行緒系統時,光考慮 .NET 世界內部的行為還不夠。有時候,一些「外來者」的行動,也會影響整體效率——這些外來者,就是 Unmanaged DLL(非託管動態連結庫)。

甚麼是 unmanaged DLL ?

Image

簡單來說,Unmanaged DLL 就像是一位外包廠商:你可以叫它來幫忙某些工作(如影像處理、硬體驅動、系統呼叫),但它不住在你家(不受 .NET 管理),你也無法輕易規範它的生活作息(記憶體管理、錯誤處理等)。

這些 DLL 多半是使用 C、C++ 等原生語言撰寫,直接操作底層資源,與作業系統互動密切。相較之下,Managed DLL(受管程式庫) 是由 .NET CLR 管理的一等公民,會自動處理記憶體分配與垃圾回收,像是你家的家人,規則一致、互動流暢。

Image

傳統的 .NET Framework

每當程式 創建或銷毀執行緒時,CLR 會主動通知所有已載入的 Unmanaged DLL:「嘿,我要加一個新員工(Thread)囉!」或者「這位員工要離職了!」

這聽起來很貼心,讓 DLL 有機會做初始化或清理的動作,例如:

  • 分配這條執行緒要使用的原生資源
  • 登記 Thread Local Storage(執行緒區域儲存)
  • 釋放或歸還所佔記憶體

但也因此,每一次執行緒的生命周期都可能帶來額外的隱藏成本。這就像你每請一位外包廠商來幫忙,都必須多花一段時間辦入廠證、做教育訓練、設定帳號密碼,離開時還要交接、回收門禁卡。

因此若在高頻創建與銷毀 Thread 的應用中(例如即時影像處理、交易撮合系統),大量與 Unmanaged DLL 交互,這些額外的進出場流程可能造成效能瓶頸,甚至非預期的行為。尤其當這些 DLL 寫得不夠穩定,還可能造成

  • 記憶體洩漏(未釋放資源)
  • 程式崩潰(未處理例外)
  • 執行緒被鎖住(未釋放鎖定)

.NET Core 中的改善

.NET Core 改用更輕量的 Thread 建立機制,不再預設廣播 Thread 建立與終結事件給所有 Unmanaged DLL。除非某個 DLL 明確要求這些通知(例如註冊 TLS 回調),否則 CLR 會避免這些開銷。

這樣的設計帶來幾個好處:

  • 減少 Thread 建立/銷毀 的成本
  • 提升高頻 Thread 操作的效能穩定性
  • 避免不必要的 native 層干擾與風險

如果正在開發的是 .NET Core 或 .NET 6+ 的應用,尤其又在意效能、Thread 池行為或 P/Invoke 穩定性,這樣的改善會讓你更安心地使用多執行緒架構,而不必太擔心與 Unmanaged DLL 的互動成本。

但如果還在跑傳統 .NET Framework(特別是 Windows Forms、WPF 桌面應用),那就需要特別注意 Thread 與 native DLL 的互動模式,甚至考慮改用 Thread Pool 或 Task-based 架構以避免高頻 Thread 操作。



🎵 共享資源沒有被妥善同步處理

共享資源是什麼?

  • 一筆資料(例如某筆訂單、座位狀態)
  • 一段記憶體(像共用的快取陣列)
  • 一個硬體裝置(例如印表機、檔案寫入)
  • 一個全域變數或物件

甚至是「系統的鎖資源本身」

當系統有了多個 Thread,一切看似井然有序、效率飛快,但只要一不小心,就會變成一場混亂的現場。這些混亂,我們統稱為同步問題(Synchronization Problems)。這些問題源自一個核心事實:多個執行緒試圖同時存取同一份共享資源,卻沒有適當協調。結果就像幾個員工搶著用一台影印機 —— 不是印出錯內容、就是永遠卡在佇列。

同步問題常見的四大類型如下,我們以一個熟悉的場景 —— 訂票系統 來說明。

Image

Race Condition(競態條件)

執行緒 1 和執行緒 2 同時查詢「座位 A1 是否可訂」,兩者皆發現「尚未被預訂」,於是幾乎同時發出訂位請求。

結果?座位 A1 被重複預訂,兩人都收到「訂票成功」的通知,實際卻只有一張椅子。

這就是典型的競態條件:誰快誰搶先,但快的可能不是對的。若沒有加鎖機制來保護查詢與預訂之間的空窗期,資料就可能出現不一致。

Deadlock(死鎖)

假設使用者要一次預訂「座位 A1 + B1 套票」,系統設計會對這兩個座位加鎖以避免重複預訂。

執行緒 2 成功鎖定 A1,接著等待 B1。同時,執行緒 3 成功鎖定 B1,卻也正在等待 A1。

於是,兩者互相等待對方釋放鎖定,誰也動不了,訂票流程卡住,像兩個人在門口讓來讓去,一讓就是永遠。

Starvation(飢餓)

系統有一條排程規則:優先處理取消訂票的請求。

結果執行緒 3(取消請求)被不斷插隊處理,而執行緒 2(普通預訂)則一直在等待排隊。久而久之,預訂請求始終得不到執行機會,這就是所謂的「飢餓問題」—— 雖然沒有死鎖,但某些執行緒就是被系統冷落了。

Priority Inversion(優先級反轉)

執行緒 2(高優先級)負責訂票,它需要一份共享資源(例如票券資料表)。不巧的是,這份資源正被執行緒 3(低優先級)使用中。照理說應該等它用完就讓給高優先級的執行緒。但這時,執行緒 1(中優先級)不小心插隊進來,佔用了大量 CPU,導致低優先級的執行緒一直無法釋放資源,高優先級的訂票也只能乾等。

多執行緒可以讓系統飛快運行,但也像交通燈壞掉的十字路口,大家都在前進,卻也都在撞車邊緣徘徊。

解決同步問題,靠的是設計良好的鎖定策略(如 Mutex、Semaphore)、資料保護機制(如 lock 區塊、atomic 操作),以及對流程優先權的細緻規劃。



🎵 Multi-processing vs Multi-threading

假設目標同時處理成千上萬個使用者請求

Multi-threading 做法:

一個主執行緒監聽請求,每收到一筆就從 Thread Pool 派一個 Thread 處理

記憶體共用,請求上下文易於共享

須注意 thread 數不能太多,否則上下文切換變重

Multi-processing 做法(如 Nginx 的 worker process 模型):

啟動多個 worker process,每個都有獨立的處理邏輯與記憶體空間

高隔離性,穩定性高,但彼此不能共享快取,會增加記憶體

決策維度 選多執行緒 選多進程
資源共享需求高 ❌(隔離)
任務互相影響風險高(崩潰要分開)
啟動速度要求高 ✅(Thread 啟動快) ❌(Process 重)
系統安全性需求高 ❌(容易踩到別人的記憶體) ✅(隔離清楚)
記憶體敏感(資源有限) ❌(每個 Process 都吃記憶體)
要發揮多核心效能 ✅(可部分發揮) ✅(每個 Process 各跑核心)

API Web Server

✅ Multi-threading)+ 非同步(Asynchronous I/O)

雖然Multi-Processing)可以用來跑 Web Server,但大部分的高效能 Web Server 架構會傾向這樣的設計:

  • 一個 Process 負責接收請求,管理主流程
  • 在該 Process 裡用多個執行緒(Thread)來同時處理多個請求
  • 並進一步結合 async/await(非同步 I/O)來降低資源消耗
考慮面向 說明
資源共享需求 每個請求都可能查詢資料庫、存取共用快取(如 Redis),用 Thread 不需額外 IPC
記憶體成本 Thread 共用記憶體,比 Process 省空間、建立成本也低
回應速度需求 使用 Thread + 非同步 I/O,能有效提升高併發處理能力(例如 ASP.NET Core、Node.js)
Thread Pool 技術成熟 現代 Runtime(例如 .NET、Java、Python)都有 Thread Pool 支援,能自動重用 Thread,效能高且避免開銷

Chrome 使用 Multi-Process 架構?

防止單一頁面崩潰拖垮整個瀏覽器,如果你還記得早年的 IE 或 Firefox,當其中一個分頁當掉(或某個 JavaScript 無限迴圈),整個瀏覽器會整個凍住。

這是因為:

  • 傳統瀏覽器使用單一 Process + 多 Thread 架構
  • 所有頁面(分頁)都在同一個記憶體空間裡
  • 一旦某個分頁發生記憶體錯誤、崩潰、資源飆高
  • 整個 Process 就掛了,導致所有頁面同時消失

另外這樣做可以強化安全沙箱機制(Sandbox),Chrome 的「每個頁面是獨立 Process」搭配沙箱設計,讓 JavaScript 只能在自己的 Process 裡跑,每個分頁都不能直接操作系統資源(檔案、硬體),想跨分頁、存其他頁資料 → 必須透過 Chrome 的主程序做 IPC(跨進程通訊),這樣即使有惡意程式碼注入(XSS)、外部漏洞利用,也會被限制在該 Process 內,無法影響整體系統或其他分頁。這是「Multi-Process + Least Privilege」的安全設計



🎵 參考資料

huanlintalk
莫力全 Kyle Mo
Program,Process,Thread
青耀隨筆談



🎵 結語

當我們瀏覽一個網頁、點擊一次送出、播放一首音樂,這一切的流暢與即時背後,是 CPU 為你安排的微小輪班;是 Thread 排隊上場的秩序與混亂;是 Process 在不同世界間的隔離與協調。

開發者,就像城市的規劃者,決定了這座城市是高牆林立、進程隔離的 Chrome 世界,還是記憶共用、效率至上的 API Server。

Multi-threading 與 Multi-Process 不是對立,而是不同價值的取捨 —— 是效能?還是穩定?是記憶體共享?還是風險隔離? 因為程式不只在處理資料,它們也在處理時間,而這些時間 —— 正是你我生命的切片。