掃盲課。對 Linux 系統下,程序和執行緒的基本概念和對比進行闡述。
一、程序
程序是處於執行期的程式及相關資源的總稱。作業系統為程序提供兩種虛擬機器制:虛擬處理器 & 虛擬記憶體,目的是讓程序有一種假象:“獨享處理器和整個記憶體空間”。
關於程序描述符 struct task_struct
放在後續內容中解釋。
(一)程序狀態機
這裡我們介紹兩個版本的“程序狀態機”。
(1)從邏輯上程序可分為五種狀態:建立、執行、就緒、阻塞、結束。
(2)也可根據 task_struct
的五個標誌位進行分類:
task_running
:圖中的“就緒佇列”和“執行佇列”。task_interruptible
:阻塞掛起,收到某些訊號後反應。task_uninterruptible
:忽略訊號,fork(clone呼叫)會用到。__task_traced
:被其他程序跟蹤除錯,比如 ptrace。__task_stopped
:停止。不僅是退出,除錯期間也會進入該狀態。
(二)常見的程序
(1)孤兒程序
- 子程序退出時發現父程序已經退出了,會導致子程序的資源無法正確釋放而洩露。
- 避免孤兒程序:在程序結束時會尋找新的父程序或託管給0號程序,即 init 程序。
(2)殭屍程序
- 子程序在執行結束退出時,核心會釋放所有的資源;但是仍然會保留其程序描述符中的相關資訊,直到父程序透過 wait 或 waitpid 來獲取這些資訊時才釋放。如果不釋放這些資訊就會變成“殭屍程序”。
- 注意:子程序銷燬時會臨時進入“僵死狀態”,而不是“殭屍程序”,注意區分。
- 避免殭屍程序:子程序在退出時向父程序傳送訊號,讓父程序呼叫wait函式釋放。
(3)守護程序
- 執行在後臺、獨立於執行終端、屬於1號程序管理。用於週期性的執行某種任務或等待處理某些發生的事件(比如 ssh 伺服器、ftp 伺服器等)。
- 建立守護程序:fork呼叫後父程序退出,使子程序變成孤兒程序,歸1號程序管理。
二、執行緒
執行緒是程序中的一條執行流。Linux沒有實現專門的執行緒,Windows下專門實現了“輕量級程序”。
同一個程序內多個執行緒之間可以共享:程式碼段、資料段、堆區(開啟的檔案)等資源,但每個執行緒各自都有一套獨立的PID、棧(指標)、PC計數器、程式執行所需的暫存器,從而確保執行緒的控制流是相對獨立的。
主要是私有棧:用於儲存函式呼叫、區域性變數以及其他臨時資料,不能讓其他執行緒訪問。
主執行緒的私有棧是佔用程序虛擬記憶體空間的棧記憶體;其他執行緒的私有棧則是共享記憶體對映區的記憶體。
(一)程序崩潰
C/C++程序中的一個執行緒崩潰時,有可能會導致其所屬程序的所有執行緒崩潰。
- 理論上每個執行緒都是獨立執行的,執行緒的崩潰通常只會影響到它本身,所以不會導致整個程序崩潰。
- 執行緒之間共享地址空間和資料資源,導致這些共享資源處於不一致的狀態,該不確定性會讓OS直接結束當前程序。
因為各個執行緒的地址空間是共享的,所以某個執行緒對地址的非法訪問就會導致記憶體的不確定性,進而可能會影響到其他執行緒。既然這種操作是危險的,OS會認為這很可能導致一系列嚴重的後果,於是乾脆讓整個程序崩潰。
有一些特殊情況下,執行緒的崩潰可能會影響整個程序。
- 主執行緒的崩潰:主執行緒通常負責啟動和管理其他執行緒,所以它的崩潰會導致程序終止,進而導致子執行緒終止。
- 關鍵資源的破壞:關鍵資源(如共享記憶體區域或檔案控制代碼)受損或無法使用,其他執行緒可能會受到影響而導致程序的異常終止。
- 無法處理的異常:行為引發無法恢復的異常,例如訪問非法記憶體地址或遇到不可處理的硬體錯誤。
結束程序的方式:使用 kill
系統呼叫向程序傳送訊號。也支援自定義訊號處理回撥函式 sigHandler,在結束前“垂死掙扎”一番。
可以透過
kill -l
檢視所有訊號。比如常見的kill -9 pid_num
就是一種訊號,-9
代表SIGKILL
訊號,該訊號會忽略任何訊號處理函式,會馬上幹掉該程序。
Java 不會崩潰:JVM 有自定義的訊號處理函式,會執行資源清理後再 exit
退出。
舉例:StackOverflow 報錯,如果發生無限遞迴會導致棧幀(預設為 8M 大小)被佔滿。
當發生崩潰後,JVM 自定義的訊號處理函式會攔截 SIGSEGV 訊號、恢復程序的執行、丟擲 StackoverflowError 和 NPE 兩個錯誤供我們捕獲。
(二)分類:使用者執行緒 & 核心執行緒
執行緒控制塊TCB(Thread Control Block)
(1)使用者執行緒(邏輯執行緒)
- 在使用者態下透過“執行緒管理庫”建立、管理、執行,不受核心空間的排程。Linux下的執行緒管理庫 pthread 用法之後的文章會具體分析。
- 不能參與 CPU 的搶佔,只能共享程序的時間片,實現“執行緒的並行”。
- 作業系統只能看到程序的PCB,看不到執行緒的TCB。因此作業系統不直接參與使用者執行緒的執行緒管理和排程,一切由使用者級執行緒庫函式管理,包括執行緒的建立、終止、同步和排程等。
優點:(1)透過 TCB 可以在不支援執行緒技術的OS中使用執行緒;(2)切換時無需核心態和使用者態的切換,所以切換速度很快;
缺點:同一程序中只能同時有一個執行緒在執行,所以(1)不能充分利用多核CPU;(2)如果一個執行緒發起系統呼叫而阻塞,會導致整個程序中所有執行緒的阻塞。
(2)核心執行緒(物理執行緒)
- 又稱為“守護程序”。執行在“核心態”,只能由核心進行管理排程,也是核心最小的排程單位。
- 參與 CPU 的搶佔,可以實現真正的多核併發。由於它的資料結構和堆疊很小,所以它的建立、排程的開銷比程序小的多。
一個核心執行緒阻塞不影響同一程序下的其他執行緒,可以在多核處理器中執行。
舉例:中斷的下半部分為“軟中斷”,便是透過核心執行緒的方式進行實現的。
(3)對比
- 核心支援:使用者執行緒可在一個不支援執行緒的OS中實現;核心執行緒則需要得到OS核心的支援。因此上一條、兩者建立聯絡的前提是OS必須支援核心執行緒。
- CPU分配:核心只為使用者執行緒所在的程序分配一個處理器,使用者執行緒透過時間片排程的方式競爭該處理器;核心執行緒則可以在多個處理器上並行。
- 排程:只有核心執行緒才是CPU排程的單位,使用者執行緒則透過所在的程序去排程。
根據上述原因,使用者執行緒必須要對映到核心執行緒上,才能讓核心看到 & 排程使用者執行緒執行,從而讓使用者態的多執行緒實現併發執行。
建立對映的方式有:一對一模型、混合執行緒模型(多對一、多對多)。也要考慮到OS對執行緒數量的限制、選擇合適的模型。
建立和管理大量的核心執行緒需要消耗系統資源,包括記憶體和排程開銷;並且執行緒之間的切換也需要時間和開銷,頻繁的上下文切換會導致開銷增加。
(三)多執行緒/執行緒池的簡單入門
根據CPU核數不同,多執行緒實際上分為“並行”和“併發”兩種。我們最感興趣的其實是“執行緒池”(Thread Pool)技術。它專門用於管理和複用多個執行緒,能極大減小執行緒的建立和銷燬的開銷。適用於需要處理大量短時間任務的場景,並能根據系統的承受能力調整可執行執行緒的數量。
一個簡單的執行緒池由三部分組成:
- 執行緒池管理器(Thread Pool Manager):負責執行緒池的建立、銷燬和管理;並維護著執行緒佇列和任務佇列,並負責分配任務給空閒執行緒執行。
- 執行緒池(Thread Pool):由一組執行緒組成,當執行緒池中的執行緒處於可用狀態時,便可以執行提交給執行緒池的任務。
- 任務佇列(Task Queue):用於儲存待執行的任務,當執行緒池中的執行緒空閒時,會從任務佇列中獲取任務進行執行。
執行緒池的工作步驟有以下幾步:
- 初始化執行緒池,建立一定數量的執行緒並將其置於可用狀態(阻塞狀態)。
- 當有任務提交給執行緒池時,執行緒池從任務佇列中獲取任務。
- 執行緒池分配空閒執行緒來執行任務。
- 執行完任務後,執行緒繼續保持可用狀態,返回到執行緒池中,等待下一個任務。
- 如果任務佇列為空且沒有待處理的任務,空閒執行緒等待新任務的到來。
三、程序和執行緒的區別
- “程序”是資源分配的最小單位,“執行緒”是程式執行(CPU 排程)的最小單位。
- 地址空間是否獨立。每一個程序都有它自己的獨立地址空間,一般不會發生讀寫同一塊記憶體(共享記憶體除外);執行緒共享所在程序的地址空間,對於臨界區資料需要進行同步或互斥處理。
- 上下文切換的開銷不同。(1)程序的上下文切換開銷比較大:需要切換像虛擬記憶體等頁表,對 Cache 快取機制影響較大;(2)同一個程序的執行緒切換很快:只需要切換像“暫存器”和“棧”等私有資料即可;但如果是不同程序的執行緒進行切換,那麼和程序切換一樣
上下文切換會導致 CPU Cache 快取全部作廢,虛擬記憶體的切換會導致 Page Cache(TLB)全部失效,導致在一定時間範圍內、記憶體訪問的低效。
- 建立方式不同。兩者都執行 fork 系統呼叫(底層為 clone 系統呼叫),執行緒會傳入代表共享資源的引數標誌,建立速度更快(不涉及像記憶體、檔案等資源管理資訊);此外程序還有 COW 策略,以及建立子程序後執行 exec() 優先執行子程序。
- 通訊方式。程序之間通訊記為 IPC(比如共享記憶體);執行緒因為共享記憶體和檔案資源,不需要經過核心,所以資料交換效率很高。