第2講:程序管理

7hu95b發表於2024-06-10

本文的主要內容:一個程序從生到死的過程。

一、任務佇列和 task_struct 任務描述符

Linux的“任務佇列”是一個雙向連結串列,連結串列中每一項為程序描述符task_struct,它包含了一個正在執行的程式的完整資訊:它開啟的檔案、程序的地址空間、掛起的訊號、程序的狀態等等。

  • Linux 透過 slab 分配器分配 task_struct,並實現“物件複用”和“快取著色”(“著色”是為了能重複使用 task_struct,類似於執行緒池中複用執行緒的設計)。
  • “程序核心棧”並不直接管理 task_struct 結構體,而是將其指標放到一個 struct thread_info 這個新結構體中。查詢時使用 current 宏先找到 thread_info,就能找到task_struct 指標從而確定當前正在執行的程序。
  • 每個程序都有自己唯一的PID(Process Identification Value)。它的最大值決定了系統中允許同時存在的最大程序數,一般為short int的最大值 32768(位於<linux/threads.h>,可以透過 /proc/sys/kernel/pid_max 來修改)。
  • 透過 set_task_state(task, state) 來設定程序的狀態。

(一)程序上下文

使用者程序執行系統呼叫(或中斷異常)而“陷入核心空間”,此時核心代表程序執行,也就是處於“程序上下文”中。因此“程序上下文”包括兩部分資源:

  • 使用者空間:虛擬記憶體空間(包括棧、堆、全域性區、程式碼區等資源)。“使用者空間資源的切換”本質上是“頁表的切換”,這也是造成執行緒和程序之間效能的差異的關鍵。
  • 核心空間:堆疊、暫存器等資源。“核心空間資源的切換”本質上是維護新的 PCB 控制塊和程式計數器等資源。

“中斷上下文”中,系統不代表程序執行,而是執行一箇中斷處理程式。不會有程序干擾這些中斷處理程式,所以此時不存在程序上下文。

(二)程序家族樹

Linux系統的程序之間存在一個明顯的繼承關係。

  • 0號程序:pid=0的核心排程程序,在系統啟動後,它負責 CPU 排程和其他低階任務。

這個程序通常是核心的一部分,而不是一個常規的使用者空間程序,因此零號程序不能被殺死(kill)。

  • 1號程序:使用者空間的第一個程序,也是initsystemd程序。由核心在系統啟動的最後階段啟動init程序,負責初始化系統的其他部分、啟動所有其他使用者空間的程序。是使用者空間所有孤立程序的父程序。

init 程序的描述符是單獨靜態分配的 init_task,可以透過 task ?= &init_task 判斷當前指標是否執行 init 程序。

使用者空間所有程序都是1號程序的後代。每個程序也可以擁有多個子程序。task_stuct 中有struct task_struct *parent 指向父程序;還有一個 children 子程序連結串列。

二、程序建立:fork呼叫

核心是如何fork出一個程序的

從原始碼的角度理解作業系統程序

Linux程序建立分解為 fork 和 exec 兩步。fork 複製當前程序建立一個子程序,exec 讀入可執行檔案並載入地址空間開始執行(實際上還得排程器說了算)。

fork 的工作流程

fork, vfork, __clone 的底層都是 clone 系統呼叫,唯一的區分是傳入的引數不同,從而執行不同的操作。

clone 會呼叫 do_fork,再呼叫 copy_process 完成:

  1. dup_task_struct複製當前程序,包括核心棧、thread_info 和 task_struct,此時父子程序是完全相同的。
  2. 區分父子程序。將子程序 task_struct 內的統計資訊清零(比如掛起的訊號就沒必要繼承)
  3. 子程序的狀態設為 task_uninterruptible
  4. 更新子程序的 flags 標誌。比如超級許可權 PF_SUPERPRIV、是否呼叫了 exec 函式 PF_FORKNOEXEC
  5. 呼叫 alloc_pid 為子程序分配新的 PID,PPID 為父程序。
  6. 資源處理。根據傳入的引數不同,複製或共享開啟的檔案、檔案系統資訊、訊號處理函式、程序地址空間等。
  7. 完成掃尾工作,返回一個指向子程序的指標。

do_fork收到copy_process完成後,會喚醒子程序並優先讓其執行。這是出於“寫時複製策略”的考慮,如果父程序先執行可能會向地址空間寫入資料,導致還需要另外的複製。

COW:只有在需要寫入(修改資料)時,才會複製,使各自程序擁有自己的複製,否則就以只讀方式共享。最大的優勢就是 fork 的實際開銷就是複製父程序的頁表、給子程序建立唯一的程序描述符,確保程序能快速建立、執行。

vfork 最大的不同是:父程序會被阻塞,直到收到 vfork_done 訊號(子程序退出或執行 exec)。這是個很老的設計了,因為原來沒有COW策略,透過這種設計可以讓子程序優先執行。並且 vfork 需要等待訊號,如果等不到(exec 呼叫失敗)那麼父程序會被一直阻塞。

三、執行緒

執行緒在Linux中就是個共享某些資源的程序。因此執行緒在建立時,傳入 clone 函式的引數指定了共享資源,比如clone_vm,clone_fs,clone_files,clone_sighand等。

執行緒也是有數量限制的:因為每個執行緒都要佔據堆疊空間,而實體記憶體不是無限的。可以使用ulimit -n來檢視。

核心執行緒為什麼沒有獨立的地址空間?

核心執行緒是獨立執行在核心空間的標準程序,並且它沒有獨立的地址空間(指向地址空間的 mm 指標被設定為 NULL)。

  • 效能考慮:擁有獨立地址空間意味著上下文切換(context switch)時需要更改頁表和重新整理 TLB(Translation Lookaside Buffer),這是一個相對耗時的操作。
  • 簡化設計:核心執行緒主要用於核心級任務,這些任務通常不需要訪問使用者空間資料,也不需要保護以防止其他程序或執行緒的干擾。因此,沒有必要給它們分配獨立的地址空間。
  • 資源共享:核心執行緒需要訪問全域性核心資料結構,這些結構存在於核心地址空間中。如果核心執行緒有自己獨立的地址空間,那麼訪問這些全域性資料結構將變得更加複雜和耗時。
  • 核心簡潔和一致性:不使用獨立的地址空間可以減少核心的複雜性,同時也更容易保證程式碼的一致性和可維護性。
  • 目的和作用不同:使用者空間程序和核心執行緒的目的和作用不同。使用者空間程序用於執行使用者級程式碼,而核心執行緒用於執行核心級任務。後者通常不需要像前者那樣的隔離和保護。
  • 核心狀態共享:核心執行緒通常需要共享某種狀態或資源,如檔案描述符、核心引數等。如果每個核心執行緒都有自己的地址空間,這種共享將變得更加複雜。

它們只在核心空間執行,從不切換到使用者空間中去。它們的父程序是 kthread 核心執行緒,也是透過 clone 系統呼叫完成建立。

四、程序終結

呼叫 exit() 結束程序,它的底層是 do_exit() 呼叫。過程如下:

  • 設定 PF_EXITING 標誌、刪除核心定時器。
  • 呼叫 exit_mm() 釋放程序佔用的 mm_struct。
  • 釋放資源。exit_files(), exit_fs() 遞減檔案描述符、系統資料的引用計數(為零則可以釋放)。
  • 執行exit_notify通知父程序,尋找養父(執行緒組的其他執行緒或init程序),並設定狀態為EXIT_ZOMBIE,代表其用不被呼叫。此時它也就剩下:核心棧、thread_info 和 task_struct 資訊了,這是給父程序清理用的。
  • 呼叫 schedule 切換到新程序,do_exit 函式永不返回。

最後,父程序呼叫wait()清理殘餘的(處於EXIT_ZOMBIE狀態)程序描述符。

孤兒程序 & 殭屍程序

如果父程序先於子程序退出,那麼這些子程序就變成了“孤兒程序”,如果沒有新的“養父”程序來回收它們,這些資源就會一直佔用,導致資源洩漏。

在子程序退出後,核心仍會保留子程序的一些資訊(如退出狀態)供父程序查詢。如果父程序不呼叫 wait() 來獲取這些資訊,那麼這些程序會變成“殭屍程序”。

統一將孤兒程序的父程序設定為 init 程序的優勢:

  • 可以簡化程序管理的邏輯。init 程序(或者 systemd)被設計為能夠正確處理這些孤兒程序,包括回收它們的資源和處理它們的退出狀態。
  • 確保程序狀態的一致性。即便父程序退出了也不影響子程序的執行狀態。
  • 透過自動處理這些孤兒程序,可以提高系統的健壯性,即使在不太理想的情況下(例如,某些程序意外退出)。

相關文章