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

什麼是執行緒,又為什麼要有它?

一個行程,是一份正被一雙手烹煮的食譜。一條執行緒,則是同一間廚房裡、照著同一份食譜動工的另一雙手。一起認識行程內部的「控制流」,弄清楚這些手究竟共享什麼、又各自私藏什麼,並看看為什麼開一條執行緒,會比另起一整間廚房便宜這麼多。

從行程到執行緒:第二雙手

在你身後的幾個階梯裡,你已認識了行程(process)——一支正在動作中的程式,擁有自己的位址空間、自己的開啟檔案清單,以及一條沿著程式碼一條指令接一條指令往下走的控制流。我們現在要放大來看的,正是這條「行進中的控制流」。所謂執行緒(thread),就是行程內部的一條控制流:它知道自己現在執行到程式碼的哪個位置(我此刻跑到哪了?)、握有一組裝著工作中數值的 CPU 暫存器、還有一個記錄它呼叫了哪一連串函式的堆疊。直到現在,你都悄悄假設每個行程剛好只有一條這樣的控制流。當一個行程擁有不只一條時,多出來的那條,就是執行緒。

請繼續握住前幾篇導覽裡的廚房畫面:一個行程是一份正被烹煮的食譜。單執行緒的行程,是一位廚師從頭到尾照著食譜做。多執行緒的行程,則是同一間廚房裡的好幾位廚師,全都照著同一份食譜、共用同一個冰箱與同一張料理檯——但每位廚師都握著自己的刀、站在自己的位置上。那座共用的廚房,使他們成為一個團隊而非陌生人;那些各自的位置,則讓他們不必每一秒都互相撞來撞去地工作。「共用廚房、各有座位」這唯一一幅圖,就是執行緒的全部精神,而本篇接下來只是把它講精確而已。

哪些共享,哪些私有

關於執行緒,最具決定性的事實,就是兄弟執行緒之間「共享什麼、各留什麼」的那道精確界線。一個行程裡的所有執行緒,共享那唯一的位址空間:程式的程式碼(text 區段)、它的全域與靜態資料、以及動態配置物件所住的堆積(heap)。它們也共享這個行程的開啟檔案,與其他作業系統資源。所以只要一條執行緒在堆積上配置了一個物件,每一條兄弟執行緒都看得到、碰得到——這正是為什麼在執行緒之間傳遞資料幾乎不花成本,也同時是它們強大與危險的共同源頭。

每條執行緒「各自私藏」的東西同樣重要。每條執行緒都有自己的堆疊——記錄它呼叫了哪些函式、以及住在這些函式裡的區域變數——因為每位廚師都正進行到食譜的不同步驟,絕不能在別位廚師的便條紙上亂塗。每條執行緒也有自己的 CPU 暫存器,其中包括那個指出「下一條要跑哪條指令」的程式計數器(program counter)。這一小包私有狀態(堆疊、暫存器、程式計數器),恰恰就是核心要把核心從一條執行緒切換到另一條時,必須存下與還原的那份狀態。除此之外的一切,執行緒都共同持有。

ONE PROCESS, TWO THREADS

   +-------------------- shared by all threads --------------------+
   |  code (text)   |  globals / static data   |   heap           |
   |                |                          |   open files     |
   +---------------------------------------------------------------+
          ^                                          ^
          |                                          |
   +-------------+                            +-------------+
   | THREAD 1    |  private                   | THREAD 2    |  private
   |  stack      |                            |  stack      |
   |  registers  |                            |  registers  |
   |  PC         |                            |  PC         |
   +-------------+                            +-------------+
一個位址空間,由兩條執行緒共享(程式碼、資料、堆積、檔案);每條執行緒各自保有自己的堆疊、暫存器與程式計數器(PC)。

幹嘛這麼麻煩?四個誠實的理由

既然執行緒會帶來危險,那到底為什麼要用?這裡有四個經典理由,把它們分清楚會很有幫助。第一,回應性(responsiveness):當一條執行緒卡在某件緩慢的事上——磁碟讀取、網路回覆——另一條執行緒仍能讓程式持續回應使用者,於是視窗不會在下載到一半時凍住。第二,資源共享(resource sharing):因為執行緒早已共享位址空間,它們之間互相交付工作完全不必前置設定,這不像各自獨立的行程那樣得開明確的管道。第三,經濟(economy):建立一條執行緒,在時間與記憶體上都遠比建立一整個新行程便宜,因為沒有新的位址空間要蓋。第四,可擴展性(scalability):在一台有好幾顆核心的機器上,不同的執行緒可以跑在不同的核心上,真正地讓一個龐大運算加速。

請注意,這些理由乾淨俐落地分成兩個家族,而這道劃分重要到下一篇會專門講它。前三個——回應性、共享、經濟——關乎並行(concurrency):把程式組織成好幾條交錯穿插的控制流,好讓沒有人會卡在等待上。即使在單核心機器上,這些也照樣有回報。第四個——可擴展性——則關乎平行(parallelism):真的在同一瞬間執行多條執行緒,而這需要真正多出來的核心才辦得到。執行緒是同時服務這兩個家族的唯一機制,這正是為什麼它無所不在。下一篇導覽會把這兩個概念狠狠地拆開,因為混淆它們所造成的執行緒思緒混亂,比任何其他事情都多。

執行緒 vs. 全新的行程

你也許會反駁:先前的階梯不是已經教過怎麼弄出更多控制流了嗎——呼叫 fork 生出第二個行程就好啦,何不就這麼做?因為一個新行程是一整間新廚房。當核心執行 fork 時,它必須張羅一個全新的位址空間(實務上會用你先前認識的寫入時複製(copy-on-write)這招來省事,但那仍然是實實在在的帳務工作)、一張全新的分頁表、一整組全新的檔案描述符。而且一旦 fork 出去,兩個行程在設計上就是彼此隔離的:要共享資料,它們得透過刻意安排的行程間通訊(IPC)——管線、共享記憶體區段、訊息——才行。那份隔離為了安全是優點,但當你真正想要的其實是「合作」時,它就變成了額外開銷。

執行緒則坐在這道權衡的對角。生出一條執行緒,只是在一個「早已存在」的位址空間上多加一個堆疊和一組暫存器——又便宜又快。而且兄弟執行緒之間,靠著直接碰觸同一個堆積就能合作,不必鋪任何管路。這個對比正是執行緒與行程之比較的全部重點:行程以較高的代價給你隔離,執行緒以較低的代價給你共享。當然,這份便宜共享的代價是:一條執行緒裡的臭蟲,可能汙染整個行程都倚賴的資料;而一條執行緒崩潰,也可能把它的兄弟們一起拖下水。天下沒有白吃的午餐——只有不同的菜單。

你實際上怎麼生出一條

在真實的程式裡,你很少直接戳核心;你會呼叫一個執行緒程式庫。在 Linux 與其他類 Unix 系統上,標準的那一套是 POSIX 執行緒,也就是 pthreads。它的形狀很簡單:你交給它一個函式和一個參數,它便啟動一條新的控制流去跑那個函式,而這條控制流會與「生出它的那一條」並行地執行。稍後你可以等它跑完、並收回它的結果。被建立的執行緒共享行程裡的一切,唯獨不共享它自己的堆疊與暫存器——這正是「共用廚房、各有座位」那幅圖,只不過現在用你寫得出來的程式碼表達出來罷了。

  1. 把工作寫成一個函式,例如一個接收陣列某一段、並把它加總起來的函式。
  2. 呼叫程式庫的建立常式,把那個函式連同它該處理的參數一起傳進去;這會回傳一個控制代碼,並立刻啟動一條新的控制流。
  3. 新執行緒並行地跑它的函式,同時建立它的那條執行緒也繼續往前走——兩者共享同一個堆積,所以新執行緒能直接讀那個陣列、完全不必複製。
  4. 呼叫程式庫的 join 常式,阻塞等到新執行緒結束,並收回它所產出的任何東西。

不斷地建立與摧毀執行緒終究還是有成本,所以一個常見的模式是改成「重複使用一組固定的班底」。執行緒池(thread pool)會在一開始就先起好幾條執行緒,再餵給它們一個任務佇列;當一條執行緒做完一件任務,就抓下一件,於是這組班底持續忙碌,不必承受不停聘僱與解僱的折騰。也有更輕量的選項——由執行環境(而非核心)排程的綠色執行緒,以及彼此合作、把控制權來回交接的協程(coroutine)——但這些、加上執行緒池、以及「使用者執行緒該怎麼對應到核心執行緒」這個更深的選擇,都是第四篇導覽的主題。眼下,重點這句話依然成立:一條執行緒,就是穿過一個共享位址空間的另一條控制流,而你用一次程式庫呼叫就能生出它。

牆上的那道陰影

執行緒一切的好,都源自共享——一切的危險,也同樣源自共享。想像兩位廚師同時伸手去拿同一碗麵糊,各要加一顆蛋;如果兩人都先確認「這裡面已經有蛋了嗎?」,兩人都看到「沒有」,於是兩人都打進一顆蛋,這份食譜就悄悄壞掉了。在程式碼裡,這看起來像是兩條執行緒各自在一個共享變數上跑「count = count + 1」。這一行其實是三個機器步驟——讀出值、加一、寫回去——而如果兩條執行緒恰好在最糟糕的時機交錯,兩者都讀到同一個舊數字、都加一、都寫回去,於是其中一次加一就憑空消失了。結果取決於時機,所以它可能通過每一項測試,卻只在真實現場壞掉。

這是後面一切的常駐反派,我們稍後會好好替它命名、加以馴服——眼下,只要敬它三分就好。當兩條執行緒在不協調的情況下共用可變狀態的那一刻起,正確性就不再有保障了。後面那一整疊工具——鎖、號誌、條件變數、監視器——存在的目的恰恰只有一個:讓彼此合作的執行緒共用一間廚房,卻不會毀了對方的菜。你現在已經有了基礎:一條執行緒,是行程內部的一條控制流,因為共享而便宜,也因為同一個理由而危險。接下來,我們要磨利那道「其他一切都倚靠著它」的區分:並行不是平行。