雖然行程是分散式系統的建構單位,但實務顯示:作業系統提供的行程粒度太粗,不足以支撐分散式系統。改以「一個行程內多條控制執行緒」這種更細的粒度,不僅讓分散式應用更容易建構,效能也更好。本節說明執行緒在分散式系統中的角色,以及它們為何如此重要。

執行緒導論#

行程與執行緒的關係#

要理解執行緒的角色,得先理解行程是什麼。作業系統為了執行程式,會建立多個虛擬處理器(virtual processor),每個虛擬處理器執行一支不同的程式,並以**行程表(process table)**記錄各虛擬處理器的 CPU 暫存器值、記憶體映射、開啟的檔案、記帳資訊、權限等。**行程(process)**通常定義為執行中的程式——正在作業系統某個虛擬處理器上執行的程式。

關鍵在於:作業系統會刻意確保彼此獨立的行程無法惡意或不慎影響對方的正確性。換句話說,多個行程共用同一 CPU 與硬體資源這件事被做成透明的——這就是並行透明性(concurrency transparency),而且通常需要硬體支援才能落實這種隔離。

這種透明性的代價相當高:

  • 建立行程昂貴:每次建立行程,作業系統都得配置一個完全獨立的位址空間——把資料段清零、把程式複製進文字段、為暫存資料設好堆疊。
  • 行程切換昂貴:除了保存 CPU context(暫存器值、程式計數器、堆疊指標等),還得修改記憶體管理單元(MMU)的暫存器、令 TLB(translation lookaside buffer)等位址轉換快取失效;若記憶體不足,甚至得先在主記憶體與磁碟間交換(swap)行程。

**執行緒(thread)**和行程一樣執行自己的一段程式碼,但不同的是:如果並行透明性會造成效能損失,執行緒系統就不試圖提供它。執行緒系統只維護讓多條執行緒共享 CPU 所需的最少資訊——執行緒 context 往往只有 CPU context 加上少量管理資訊(例如記錄某執行緒正阻塞在 mutex 上,以免選它執行)。在單一行程內保護資料不被其他執行緒不當存取,完全交給應用程式開發者。

這種取捨有兩個重要含意:其一,多執行緒應用的效能幾乎不會比單執行緒版本差,多數情況反而更好;其二,執行緒之間沒有行程那樣的自動保護,開發多執行緒應用需要額外的心智投入——良好設計與保持簡單幫助很大,但實務上這個原則並未被普遍理解。

非分散式系統中的執行緒用途#

在傳統單機系統中,多執行緒行程已有多項好處:

  • 阻塞時整體不停擺:單執行緒行程一旦執行阻塞式系統呼叫,整個行程就被擋住。以試算表程式為例:使用者改動一個儲存格可能觸發一大串相依儲存格的重算;若只有單一控制執行緒,計算期間無法接受輸入、等待輸入時也無法計算。簡單的解法是至少兩條執行緒——一條處理使用者互動、一條更新試算表,甚至第三條在背景備份到磁碟。
  • 利用多處理器的平行性:每條執行緒指派給不同 CPU,共享資料放在共享主記憶體。設計得當時,這種平行性可以透明——程式在單處理器上照樣能跑,只是慢一些。隨著多處理器工作站便宜普及(常用來跑主從式應用的伺服器),這點日益重要。
  • 避免 IPC 的 context switch 開銷:大型應用常拆成多個合作的程式、各由獨立行程執行(UNIX 環境的典型做法),彼此以行程間通訊(interprocess communication, IPC)機制如(具名)管線、訊息佇列、共享記憶體段溝通。所有 IPC 機制的最大缺點是需要核心介入而帶來大量 context switch:先從 user mode 切到 kernel mode(改 MMU 映射、清 TLB),核心內做一次行程 context switch,再切回 user mode(又改一次 MMU、清一次 TLB)。改以執行緒建構應用,各部分之間完全用共享資料溝通,執行緒切換有時可全在 user space 完成,效能可能大幅改善。
  • 軟體工程理由:許多應用本來就更適合寫成一組合作的執行緒。例如文書處理器可用不同執行緒分別處理使用者輸入、拼字文法檢查、版面配置、索引生成等。

圖 3-1:IPC 造成的 context switching

執行緒的實作#

執行緒通常以**執行緒套件(thread package)**的形式提供:包含建立與銷毀執行緒的操作,以及 mutex、條件變數(condition variable)等同步變數的操作。實作方式基本上有兩種:完全在 user mode 執行的執行緒函式庫,或讓核心知悉並排程執行緒。

**使用者層級執行緒(user-level threads)**的優點:

  • 建立與銷毀便宜:所有執行緒管理資訊都在使用者位址空間,建立成本主要只是配置堆疊記憶體,銷毀主要是釋放堆疊。
  • 切換 context 只需少數指令:只要存回並重載 CPU 暫存器值即可,不必改記憶體映射、清 TLB、做 CPU 記帳。

使用者層級執行緒的重大缺陷:任何一條執行緒呼叫阻塞式系統呼叫,會立刻擋住其所屬的整個行程——連帶所有其他執行緒。而執行緒的價值恰恰在於把大型應用切成可同時邏輯執行的部分、阻塞 I/O 時其他部分還能繼續;對這類應用而言,純使用者層級執行緒幫不上忙。

核心層級執行緒可解決這個問題,但代價高昂:每個執行緒操作(建立、刪除、同步等)都得由核心執行、需要系統呼叫,切換執行緒 context 變得跟切換行程 context 一樣貴,執行緒相對行程的效能優勢大半消失。

解法在於混合形式——輕量級行程(lightweight process, LWP)

  • LWP 在單一(重量級)行程的 context 中執行,每個行程可有多個 LWP;系統同時提供完全實作在 user space 的使用者層級執行緒套件(含建立/銷毀與 mutex、條件變數等同步設施)。
  • 執行緒套件由多個 LWP 共享,每個 LWP 各自執行自己找到的(使用者層級)執行緒。把執行緒指派給 LWP 通常是隱含的、對程式設計師隱藏。
  • 運作方式:建立 LWP(透過系統呼叫)時,LWP 拿到自己的堆疊並被指示執行排程常式去找可跑的執行緒。執行緒表由所有 LWP 共享,以純 user space 的 mutex 保護——LWP 之間的同步完全不需核心支援。
  • 執行緒要阻塞在 mutex 或條件變數上時,自行做完記錄後呼叫排程常式,找到另一條可跑的執行緒就切換過去——這一切都在 user space 完成,對 LWP 而言就像普通程式碼。
  • 執行緒發出阻塞式系統呼叫時,執行從 user mode 進入 kernel mode,但仍在目前 LWP 的 context 中;當這個 LWP 無法再前進,作業系統可切換到另一個 LWP 繼續。

圖 3-2:結合核心層級輕量級行程與使用者層級執行緒

LWP 搭配使用者層級執行緒套件的優點:(1) 執行緒的建立、銷毀、同步都便宜、完全不需核心介入;(2) 只要行程有足夠的 LWP,阻塞式系統呼叫不會吊死整個行程;(3) 應用程式完全不需要知道 LWP 的存在,只看得到使用者層級執行緒;(4) 不同 LWP 可跑在不同 CPU 上,輕鬆用於多處理器環境且對應用完全隱藏。唯一的缺點是 LWP 本身的建立與銷毀跟核心執行緒一樣貴——所幸這種操作不常發生,且通常由作業系統全權控制。

延伸:排程器啟動(scheduler activations)

與 LWP 類似的另一種做法是排程器啟動(scheduler activations)(Anderson 等人,1991)。與 LWP 最本質的差異在於:執行緒阻塞在系統呼叫上時,由核心對執行緒套件做上呼(upcall),實質上是替它呼叫排程常式挑出下一條可跑的執行緒;執行緒解除阻塞時同樣如此。優點是省去核心對 LWP 的管理;缺點是上呼被認為不夠優雅——它違反了分層系統的結構原則(呼叫只該指向下一個較低層)。

分散式系統中的執行緒#

執行緒的重要性質是:能讓阻塞式系統呼叫不至於擋住整個行程。這使執行緒特別適合分散式系統——同時維護多條邏輯連線的通訊變得容易表達。以下分別從多執行緒客戶端與多執行緒伺服器來看。

多執行緒客戶端#

在廣域網路上運作的分散式系統若要有高度分散透明性,得設法掩蓋漫長的行程間訊息傳播時間——廣域網路的來回延遲動輒數百毫秒、甚至數秒。常用手法是:發起通訊後立刻去做別的事。

典型例子是網頁瀏覽器。一份網頁文件通常由一個 HTML 檔加上一堆圖片、圖示等組成,抓取每個元素都得建立 TCP/IP 連線、讀取進來的資料——兩者都是天生阻塞的操作,而長途通訊又讓每個操作都可能耗時很久。瀏覽器常先抓 HTML 頁面就開始顯示:文字先給使用者看(可捲動瀏覽),同時繼續抓取頁面的其他檔案(如圖片),抓到就顯示。使用者不必等整頁元件到齊。

把瀏覽器開發成多執行緒客戶端可大幅簡化這件事:主 HTML 檔一到手,就啟動多條執行緒分頭抓取其餘部分,每條執行緒各自建立連線拉資料,直接用標準(阻塞式)系統呼叫來寫——前提是阻塞不會吊死整個行程。每條執行緒的程式碼相同,而且簡單。

多執行緒瀏覽器還有另一個重要好處:若對同一台過載或緩慢的伺服器開多條連線,其實不會比逐一抓取快多少;但許多網站的伺服器已被複製(replicated)到多台機器——同站、同名、提供完全相同的文件集,請求進來時以 round-robin 或其他負載平衡技術轉發。多執行緒客戶端可以對不同副本分別建立連線、平行傳輸資料,讓整份網頁文件在遠比單一伺服器短的時間內完整顯示。這只有在客戶端能處理真正平行的輸入資料流時才可行——執行緒正是為此而生。

多執行緒伺服器#

多執行緒客戶端固然有其好處,但多執行緒在分散式系統中的主戰場在伺服器端。實務顯示,多執行緒不僅大幅簡化伺服器程式碼,也讓伺服器更容易利用平行性達到高效能——即使在單處理器系統上也是如此;如今多處理器電腦已是普及的一般用途工作站,更是如此。

以一個偶爾得阻塞等磁碟的檔案伺服器為例,比較三種組織方式:

  • 多執行緒(dispatcher/worker 模型):一條**分派者(dispatcher)執行緒從眾所周知的端點讀入檔案操作請求,檢視後挑一條閒置(阻塞中)的工作者(worker)**執行緒把請求交給它。工作者對本地檔案系統做阻塞式讀取;若因等磁碟而暫停,就換另一條執行緒上場——可能是分派者去收更多工作,也可能是另一條已就緒的工作者。
  • 單執行緒:主迴圈取得請求、檢視、做完才拿下一個。等磁碟時伺服器整個閒著,其他客戶端的請求無法處理;若伺服器跑在專用機器上(常見情況),CPU 也跟著閒置。結果是每秒能處理的請求數大減。
  • 有限狀態機(finite-state machine):若沒有執行緒可用又不能接受單執行緒的效能損失,第三種做法是把伺服器寫成一台大型有限狀態機:只有一條執行緒,請求進來能從快取滿足就直接處理;不能就送訊息給磁碟,但不阻塞——把當前請求的狀態記在表裡,接著取下一則訊息。下一則訊息可能是新工作,也可能是磁碟對先前操作的回覆;若是磁碟回覆,就從表中取回相關資訊繼續處理並回覆客戶端。這種方案必須使用非阻塞式的傳送與接收呼叫——「循序行程」模型不見了,每次收送訊息都得顯式保存與還原計算狀態,等於用困難的方式模擬執行緒與其堆疊。

圖 3-3:以 dispatcher/worker 模型組織的多執行緒伺服器

執行緒的價值就在這裡:既保留「循序行程+阻塞式系統呼叫」的簡單程式模型(例如用 RPC 跟磁碟溝通),又能取得平行性。三種模型的特性總結——多執行緒:有平行性、用阻塞式系統呼叫,好寫又高效;單執行緒:無平行性、阻塞式呼叫,簡單但犧牲效能;有限狀態機:有平行性、非阻塞式呼叫,高效但難寫。

圖 3-4:建構伺服器的三種方式