JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

使用者執行緒、核心執行緒,以及兩者之間的模型

你已經知道執行緒是什麼,也知道並行不等於平行。現在來認識執行緒可以被管理的兩個地方——安靜地藏在你的程式裡,或大方地交由核心打理——以及把它們連接起來的三種方式,每一種都有自己誠實的取捨。

誰來保管執行緒的待辦清單?

在第 1 篇導覽裡,你認識了執行緒,它是行程內部的一條控制流,共享程式碼、資料與堆積,卻保有自己的堆疊、暫存器與程式計數器。在第 2 篇裡,你學到同時跑好幾條執行緒能得到並行,而只有真正的核心才能給你平行。有一個問題被悄悄跳過了:到底是誰在追蹤這些執行緒、決定每一條何時執行?答案是有兩位可能的記帳人,而你選哪一位,幾乎會改變你的執行緒一切的行為。

第一位記帳人是住在你自己程式裡、位於使用者空間的一個函式庫——這些是使用者層級執行緒。想像一位經理自己保有一張私人待辦清單,安靜地在自己桌前的各項工作之間切換,從不告訴總公司他正做哪一項。總公司(也就是核心)只看到一位忙碌的員工。這個函式庫自己維護一張小小的執行緒表,每條都有存好的堆疊與暫存器,當一條該讓位給另一條時,它自己在使用者模式下完成交換,完全不用系統呼叫。因為一般的切換從不進入核心,建立與切換這些執行緒都快得驚人、也便宜得驚人。

第二位記帳人是作業系統本身——這些是核心層級執行緒。現在每位員工都在總公司登記有案,總公司分配座位、決定誰何時上工。核心為每條執行緒保有一筆記錄,它的排程器在決定每個 CPU 核心接下來跑什麼時,是從「執行緒」之間挑選,而不只是從「行程」之間挑選。代價是建立執行緒、在執行緒之間切換、以及讓它們同步,全都要透過系統呼叫進入核心,所以每一項操作都比使用者層級執行緒純粹在行程內的切換來得重。今天幾乎每個通用作業系統(Linux、Windows、macOS)都提供核心層級執行緒,作為對使用者開放的 API 所建立的基礎。

決定一切的兩項成本

在模型講得通之前,你得先把兩項對立的成本刻進骨子裡。使用者層級的切換之所以便宜,是因為它待在核心之外:函式庫只是存下一條執行緒的暫存器、載入下一條,就像你在同一本書裡從一個書籤翻到另一個——沒有進入核心的上下文切換、不跨越使用者與核心的界線。核心層級的切換則比較貴,因為每一項執行緒操作都是一次系統呼叫:一趟跨越那條界線的旅程,由核心存取與還原狀態。到目前為止,使用者層級執行緒看起來在速度上是乾淨的勝利。

但便宜藏著兩個陷阱,兩個都源自同一個事實:核心無法排程它看不見的執行緒。第一,平行。如果核心把你整個行程看成一個可排程的東西,它就永遠只能一次把它放上一個 CPU 核心——所以一百條使用者層級執行緒仍然擠在單一核心上輪流跑。沒錯,那是並行,但永遠不是真正的平行,不管機器有多少核心都一樣。第二,阻塞。如果任何一條使用者層級執行緒做了會阻塞的系統呼叫(例如一次緩慢的磁碟讀取),核心會把整個行程停住,因為在它看來沒有別的可跑——於是其他每一條執行緒都跟著凍結,即使它們明明還有有用的工作可做。

把它們連接起來的三種方式

一個模型,不過就是「多少條使用者執行緒對映到多少條核心執行緒」的規則。多對一模型把許多使用者執行緒灌進單一一條核心執行緒上——就像一整組團隊共用辦公室裡的一條電話線。在這些使用者執行緒之間切換全在使用者空間完成,又快又便宜,但這個模型完整地承襲了使用者層級的兩個陷阱:永遠只能用一個核心,而且一個阻塞呼叫就讓所有人停住。它在通用用途上大致被棄用,主要存活在純使用者層級執行緒與最簡單的綠色執行緒函式庫底下。

一對一模型走到相反的極端:每條使用者執行緒都拿到屬於自己的一條核心執行緒,一對一配對——每位員工各有一條專屬電話線。因為核心個別看見每一條執行緒,同一行程的兩條執行緒可以在同一瞬間跑在兩個不同的核心上(真正的平行!),而當一條執行緒因緩慢的讀取而阻塞時,核心只要去跑另一條,其餘照常進行。正是這種直接性,使它成為 Linux、Windows 與大多數現代系統採用的模型。代價就是那張帳單:建立一條使用者執行緒現在意味著建立一條核心執行緒,這是比較重的核心操作,而每條核心執行緒都消耗核心資源,所以系統常常會限制你能建立多少條。為每個微小任務各建一條執行緒很浪費——這正是下一篇的執行緒池所要解決的問題。

  MANY-TO-ONE            ONE-TO-ONE              MANY-TO-MANY

  U  U  U  U            U   U   U   U           U U U U U U U U
   \ | | /              |   |   |   |            \ \ \ | / / / /
    \| |/               |   |   |   |             \ \ \|/ / / /
     (K)                K   K   K   K              K    K    K
      |                 |   |   |   |              |    |    |
   [1 core only]     [runs on many cores]    [many U on a few K]

  U = user thread    K = kernel thread (what the OS can schedule)
三種多工模型。數一數核心執行緒(K)的數量:那正是作業系統能安排到核心上的東西。多對一只有一條,所以它永遠用不到第二個核心。

多對多:兩全其美,以及它為何困難

多對多模型(也稱 M 對 N)試圖保留兩個極端各自的好處。想像一個客服中心有五十位專員,卻只有八條對外電話線:一位主管在線路空出來時把專員多工地安排到那些線上,於是公司得到五十位員工的反應力,卻不必負擔五十條線。這裡,一個使用者空間的排程器把上千條便宜的使用者執行緒多工地安排到一池數量適中的核心執行緒上——例如每個 CPU 核心一條。你想建多少條使用者執行緒就建多少、能跨多個核心執行、又能避免整個行程凍結:當一條使用者執行緒在核心裡阻塞時,執行期就在另一條核心執行緒上去跑別的使用者執行緒。一個常見的變體,兩層模型,甚至讓你在需要時把某條特定使用者執行緒直接釘到它自己一條核心執行緒上。

這裡我們必須誠實,因為教科書上的圖像和真實世界並不一致。紙面上,多對多是理想。實務上,它真的很難實作得好:使用者空間排程器與核心排程器之間所需的協作錯綜複雜,有好幾個作業系統做了它、與它搏鬥、最後又改回單純的一對一。所以這個模型在現實裡最大的成功根本不在核心裡——它活在現代語言執行期中,把一個 M 對 N 的排程器疊在普通的一對一核心執行緒之上。Go 的 goroutine 就是著名的例子,常常用少少幾條核心執行緒同時應付數十萬個任務。你會在下一篇導覽認識那些輕量執行緒。

你真正會碰到的 API:pthreads

你很少會親手挑選模型;你會去用一個執行緒函式庫,而模型就是那個函式庫與你的作業系統在底下提供的東西。Unix 類系統上最重要的函式庫是 pthreads(POSIX 執行緒)。就像每台車都把方向盤和踏板放在相同的位置,pthreads 固定了一組共通的函式名稱與行為,讓同一個用到執行緒的 C 程式能在 Linux、macOS 與各種 BSD 上編譯並執行。它是一份規格,而不是某個特定實作——每個作業系統都出貨自己遵守它的函式庫。在 Linux 上,一條 pthread 一對一地對映到一條核心執行緒,所以它一開箱就給你真正的平行。

核心的呼叫並不多、也容易學。你呼叫 pthread_create() 來啟動一條新執行緒去跑你指定的函式;稍後呼叫 pthread_join() 來等那條執行緒結束並取回它的結果;pthread_exit() 結束呼叫端這條執行緒。有兩點誠實的提醒值得往後帶著走。第一,API 是關於名稱與行為的約定——它和系統呼叫不是同一回事。pthreads 是一個底下可能使用系統呼叫的函式庫,而那條 API 與系統呼叫的界線是個你會一再遇到的獨立概念。第二,pthreads 把在執行緒之間共享資料的工具交到你手上,卻對你是否正確使用它們概不負責。

具體來說,你可能會寫 pthread_create(&t, NULL, work, arg) 來在一條新執行緒上啟動 work() 函式,同時主執行緒在它旁邊繼續跑,再用 pthread_join(t, NULL) 等那條執行緒結束。同樣這兩行在 Linux 與 macOS 上編譯並表現一致——這種可攜性正是標準的全部意義所在。而那第二點提醒,正是整個階梯正走向的那道懸崖。

那道懸崖,就是共享的可變狀態。一旦你有兩條執行緒共享行程的記憶體,你就能從兩邊都寫入同一個變數,而 pthreads 會讓你照做、毫無怨言。如果兩條執行緒在沒有協調的情況下同時動到同一份資料,你就會得到競爭條件,程式可能算出錯誤或隨機的結果。(一個值得知道的逃生口:絕不該共享的資料可以放進執行緒區域儲存,每條執行緒各拿一份私有副本,於是根本沒有東西要保護。)讓真正的共享變得安全,是本階梯最後一篇導覽那個困難而核心的主題。