Linux 進程概念
- 馮·諾依曼體系結構
- 軟件運行與存儲分級
- 數據流動的理論過程
- 操作系統
- 操作系統(Operator System) 概念
- 操作系統的功能與作用
- 系統調用和庫函數概念
- 進程概念
- 描述進程 - PCB
- task_struct
- 查看進程
- 通過系統調用獲取進程標示符 PID
- 通過系統調用 fork 函數創建進程
- 簡單使用
- 區分父子進程操作
- 父子進程的寫時拷貝
- 進程狀態
- 具體的 Linux 內核解釋
- 運行、阻塞 和 掛起狀態
- 進程如何被轉移
- Linux 進程狀態
- 僵尸進程與其危害
- 僵尸進程危害
- 孤兒進程
以下代碼環境為 Linux Ubuntu 22.04.5 gcc C語言。
馮·諾依曼體系結構
我們生活中的計算機大部分都遵守馮·諾依曼體系,如筆記本、服務器等等。
而計算機都是由一個個的硬件組件組成的:
- 輸入設備:包括鍵盤、鼠標、掃描儀、寫板等。
- 存儲器:內存。
- 中央處理器(CPU):含有運算器和控制器等。
- 輸出設備:顯示器、打印機等。
如果再精確一點說明,則有:
- 這里的存儲器確切是指內存。
- 不考慮緩存情況,這里的 CPU 只能對內存進行讀寫,不能訪問外設(輸入或輸出設備)。
- 外部設備(輸入或輸出設備) 要輸入或者輸出數據,也只能寫入內存或者從內存中讀取。
- 可以肯定的是,所有設備都只能直接和內存打交道。
軟件運行與存儲分級
軟件在運行之前,會先存儲在磁盤或其它外部存儲設備中。當需要運行軟件時,會將軟件的程序加載到內存當中,然后由 CPU 獲取來執行程序,處理程序邏輯,最后由顯示器等輸出設備顯示結果。
考慮到 CPU 的處理速度非常快,這個體系之下還會細分很多的存儲設備,目的就是為了盡可能不拖慢 CPU 的速度。
數據流動的理論過程
當我們使用這個體系結構的計算機進行信息交流,就一定會通過 輸入設備 -> 載入內存 + CPU運算 -> 輸出設備 這個步驟:
操作系統
操作系統(Operator System) 概念
任何計算機系統都包含一個基本的程序集合,稱為操作系統(OS)。
更廣泛上的操作系統包括:
- 內核(包括:進程管理、內存管理、文件管理、驅動管理等)
- 其他程序(例如函數庫,shell程序 等等)
操作系統的功能與作用
在整個計算機軟硬件架構中,操作系統的功能定位是一款搞 " 管理 " 的軟件,它的作用是讓應用程序正常執行,具體為:
- 對下,與硬件交互和管理所有的軟硬件資源。
- 對上,為用戶程序(應用程序)提供一個良好的執行環境。
在上圖中可以看到:
-
軟硬件體系結構為層狀結構,各層也設計成 高內聚低耦合,方便各個部分自己更新迭代。
-
訪問操作系統就必須使用系統提供的系統調用接口。
-
若用戶程序訪問硬件,則一定會貫穿整個軟硬件體系結構!
那操作系統如何 " 管理 " 呢?
-
先描述被管理對象:使用 struct 結構體(使用的是 C語言)構建被管理對象的數據。
-
再組織被管理對象:使用高效的數據結構組織被管理對象。
總結起來就是一句話:先描述,再組織。
系統調用和庫函數概念
在開發角度,操作系統對外會表現為一個整體,但是會暴露自己的部分接口,供上層開發使用,這部分由操作系統提供的接口,叫做系統調用。
系統調用在使用上,功能比較基礎,對用戶的要求相對也比較高,所以有的開發者對部分系統調用進行適度封裝,形成庫。有了庫,就利于更上層用戶或者開發者進行二次開發。
進程概念
從課本概念出發:程序的一個執行實例,正在執行的程序等。
從內核觀點出發:擔當分配系統資源(CPU時間,內存)的實體。
更具體的說進程:進程 = 內核數據結構元素 + 進程的代碼和數據。
描述進程 - PCB
基本概念
-
進程信息被放在一個叫做進程控制塊的數據結構中,可以理解為進程屬性的集合。
-
概念上稱之為 PCB(process control block),在 Linux 操作系統下的 PCB 是:
task_struct
。
task_struct 為 PCB 的一種
-
在 Linux 中描述進程的結構體叫做 task_struct 。
-
task_struct 是 Linux 內核的一種數據結構,它會被裝載到 RAM(內存) 里并且包含著進程的屬性信息。
task_struct
內容分類
- 標示符:描述本進程的唯一標示符,用來區別其它進程。
- 狀態:任務狀態,退出代碼,退出信號等。
- 優先級:相對于其它進程的優先級。
- 程序計數器:程序中即將被執行的下一條指令的地址。
- 內存指針:包括程序代碼和進程相關數據的指針,還有和其它進程共享的內存塊的指針。
- 上下文數據:進程執行時處理器的寄存器中的數據。
- I/O 狀態信息:包括顯示的 I/O 請求,分配給進程的 I/O 設備和被進程使用的文件列表。
- 記賬信息:可能包括處理器時間總和,使用的時鐘數總和,時間限制,記賬號等。
- 其它信息…
組織進程
可以在 Linux 的內核源代碼里找到它,所有運行在系統里的進程都以 task_struct 鏈表的形式存在內核里。
查看進程
-
進程的信息可以通過 /proc 系統文件夾查看。如:要獲取 PID 為 1 的進程信息,則需要查看 /proc/1 這個文件夾。
-
大多數進程信息同樣可以使用
top
和ps
這些用戶級工具命令來獲取。
另外可以注意到 OS 會給每個登錄用戶分配一個 bash 進程。
通過系統調用獲取進程標示符 PID
通過查看 man 手冊可以知道在代碼層面進程的 PID 如何獲取:
我們可以用代碼測試看看:
#include <stdio.h>
#include <unistd.h>int main()
{printf("當前進程的 PID 為 %d\n", getpid());printf("當前進程父進程的 PID 為 %d\n", getppid());return 0;
}
通過系統調用 fork 函數創建進程
簡單使用
通過查看 man 手冊可以知道在代碼層面創建進程的 fork 函數信息:
我們可以測試下面的代碼,父進程進入 fork 函數時,會創建子進程,最后兩者一起從 fork 函數出來執行 PID 的打印:
#include <stdio.h>
#include <unistd.h>int main()
{printf("父進程 PID 為 %d\n", getpid());fork(); // 父進程進入創建子進程,fork() 調用完后兩者同時出來 printf("進程 PID 為 %d\n", getpid()); // 父子都獨自打印自己的 PID return 0;
}
區分父子進程操作
這也就意味著,當父進程進入 fork 函數創建子進程時,兩者代碼一樣,執行代碼命令一樣,在上面的代碼中 fork 函數后的執行操作就是一樣的。
為了區分父子進程,如果是子進程 fork 的返回值規定為 0,如果是父進程則返回大于 0 的數,小于 0 說明創建子進程失敗。
接下來測試返回值是否是這樣規定的:
#include <stdio.h>
#include <unistd.h>int main()
{int ret = fork(); // ret 同時接收 fork 返回的兩個值if (ret < 0) { perror("fork"); // 小于 0 表示調用失敗return 0;} else if (ret == 0) // 0 為子進程{ int child = 2;while (child--){ printf("我是子進程,我的 PID 為 %d\n", getpid());printf("我的父進程 PID 為 %d\n", getppid());}; } else // 大于 0 為父進程{ sleep(3);int parent = 3;while (parent--){ printf("我是父進程,我的 PID 為 %d\n", getpid());} } return 0;
}
可以看到,通過分支語句可以讓兩個進程共享代碼的情況下去執行不同的代碼。
這意味著:
-
fork 函數有兩個返回值,父進程有一個,子進程有一個。
-
父子進程代碼共享,數據各自開辟(寫時拷貝節省空間)。
-
fork 調用之后通常需要使用 if 分支語句進行分流。
父子進程的寫時拷貝
另外我們注意到 ret 變量接收 fork 函數返回值,竟然出現不一樣的 if 語句跳轉。事實上,如果修改父子任何一方的數據,OS 會將被修改數據在底層拷貝一份,讓目標進程修改這個拷貝,這種做法叫寫時拷貝:
#include <stdio.h>
#include <unistd.h>int main()
{int a = 10; int b = 20; int ret = fork();if (ret < 0){ perror("fork");return 0;} else if (ret == 0){ b = 10; printf("我是子進程,我的 PID 為 %d\n", getpid());printf("我的 ret 變量的值為 %d\n", ret); printf("子進程的 a == %d, &a == %p\n", a, &a);printf("子進程的 b == %d, &b == %p\n", b, &b);} else{ sleep(2); // 父進程等待 2 秒,讓子進程先修改。 printf("我是父進程,我的 PID 為 %d\n", getpid());printf("我的 ret 變量的值為 %d\n", ret);printf("父進程的 a == %d, &a == %p\n", a, &a);printf("父進程的 b == %d, &b == %p\n", b, &b);} return 0;
}
可以看到,兩個進程的 b 地址一樣,子進程修改后,父進程的 b 變量竟然沒有變!
這說明進程變量的地址不是物理地址,而是虛擬的地址!雖然兩者地址一樣,但是底層的物理地址一定不一樣。
這可以說明接收 fork 函數的 ret 變量在被修改時,兩個進程的 ret 已經不一樣了(寫時拷貝執行),if 判斷的值自然不一樣。
也就是說,進程具有獨立性,在大部分運行情況不受其它進程影響。
但為什么 fork 函數返回的值大于 0 為父進程,等于 0 為子進程呢?
因為大于 0 的值實際是子進程的 PID,一個父進程可以有多個子進程,其 PID 拿給父進程用于管理(上述子進程的 PID 由父進程的 ret 保管)。而子進程可以使用 getpid() 函數拿到自己的 PID,使用 getppid() 函數拿到父進程的 PID。其 ret 拿取沒有必要,則規定 0 為子進程。
進程狀態
在操作系統的概念上說,進程狀態有:創建、就緒、運行、阻塞、掛起、結束等狀態。
具體的 Linux 內核解釋
運行、阻塞 和 掛起狀態
在具體的操作系統也就是 Linux 中,每個 CPU 有一個調度隊列 runqueue,只要在 runqueue 調度隊列中的進程就算在運行中(也就是包含就緒和運行狀態)。
當進程進入阻塞狀態,通常是在等待某種設備或資源就緒,如:C語言的 scanf 函數會等待用戶在鍵盤輸入內容加回車后再繼續向下執行,若用戶不輸入,則一直處于阻塞狀態。
在阻塞狀態時,進程會脫離調度隊列被分配到其它等待隊列:
如果鍵盤輸入數據后,OS 會第一時間知道并將獲取數據的進程加入調度隊列,讓該進程進入運行狀態。
那什么是掛起狀態呢?當內存空間不夠時,為保證 OS 本身正常運行,OS 不得不將部分沒有使用的進程的代碼和數據部分,臨時的放入磁盤交換分區。此時在內存中的進程只有 task_struct,代碼和數據卻放在了磁盤,這就叫做掛起狀態。
如果輪到當前的進程運行、獲取數據,OS 又會將對應進程的代碼和數據歸還給進程。
進程如何被轉移
上述中我們注意到一個問題,當進程從 runqueue 分配到 wait queue 時,進程是如何被轉移的?
事實上,Linux 進程的 PCB(tast_struct) 使用的是特殊的雙鏈表結構 struct list_task
,這個結構只包含 next 和 prev,用于指向后一個節點(task_struct)和前一個節點。在一個 task_struct 中包含多個 struct list_task
就可以在不同隊列轉移。
當然,這個結構本身不能獲取整個 task_struct 結構的信息,但可以使用 C語言的 offsetof 找到其相對于 task_struct 的偏移值,通過 地址移動 + 強制類型轉換,即可在不同的隊列中獲取 task_struct 完整的結構。
這意味著進程被操作時,刪除與插入到其它隊列的時間復雜度都是 O(1) 級別,極大的提高了效率。
Linux 進程狀態
Linux 對進程的狀態具體規定了以下幾種(在 Linux 內核里,進程也叫做任務 task)。以下的狀態是在 Linux 內核源代碼里定義的,可能不適用于其它 OS:
-
R(running)運行狀態:并不意味著進程一定在運行中,只表明進程要么是在運行中要么在運行隊列(調度隊列)里。
-
S(sleeping)睡眠狀態:意味著進程在等待事件完成(這里的睡眠有時候也叫做 可中斷睡眠(interruptible sleep) )。
-
D(Disk sleep)磁盤休眠狀態:有時候也叫不可中斷睡眠狀態(uninterruptible sleep) ,在這個狀態的進程通常會等待 I/O 的結束。
-
T(stopped)停止狀態:可以通過發送 SIGSTOP 信號讓進程停止下來,再發送 SIGCONT 信號讓進程繼續運行。
-
t(tracing stop)跟蹤停止狀態:當進程被調試如 gdb 調試代碼程序,在斷點處停止時的狀態。
-
X(dead)死亡狀態:這個狀態只是一個返回狀態,不能在任務列表里看到這個狀態。
-
Z(zombie)僵尸狀態:為了獲取進程退出信息的臨時狀態。
我們著重查看僵尸狀態,讓子進程只打印自己的 PID 就退出,而父進程則執行死循環:
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>int main()
{int ret = fork();if (ret < 0){ perror("fork");return 0;} else if (ret == 0){ printf("我是子進程,我的 PID 為: %d\n", getpid()); // 子進程執行后退出,但父進程沒有回收,會一直保持僵尸狀態} else { printf("我是父進程,我的 PID 為: %d\n", getpid()); // 父進程執行死循環while (true){ ; } } return 0;
}
可以看到,子進程狀態處于 Z
也就是僵尸狀態,而父進程使用 Ctrl + Z
快捷鍵正處于暫停狀態,grep 命令也是一個進程,ps 命令查看正好將其打印出來,它此時處于睡眠狀態,S+
的 +
號表示是前臺進程,沒有則表示后臺進程。
另外,D 狀態下的進程是無法被 OS 殺掉的,這是為保證重要的數據正常處理,不受 OS 在內存不足時的干擾。但這意味著除非它自己的任務完成,然后自己結束,想要手動結束就只能讓電腦關機或斷電了。
僵尸進程與其危害
上述的僵尸狀態(zombie)是一個比較特殊的狀態。當進程退出并且父進程沒有讀取到子進程退出的返回代碼時就會產生僵尸進程。
僵尸進程會以終止狀態保持在進程表中,并且會一直在等待父進程讀取退出狀態代碼。
僵尸進程危害
進程的退出狀態必須被維持下去,因為它要告訴與它聯系的進程(父進程)自己任務處理的情況。可父進程一直不讀取,那子進程就會一直處于 Z 狀態。
維護退出狀態本身就是要用數據維護(此時進程的代碼和數據已經沒有了),也屬于進程基本信息,所以保存在 task_struct 中。換句話說,Z 狀態一直不退出,task_struct 就一直要維護,這就導致內存被浪費,也就是內存泄漏。
如果一個父進程創建很多子進程,但不讀取,自己也不退出,就會造成大量的內存資源浪費。
如何讓父進程讀取子進程狀態?使用 wait() 系統調用(有機會再剖析解釋)。
孤兒進程
父進程如果提前退出,而子進程后退出,進入 Z 之后,子進程就稱之為 " 孤兒進程 "。
孤兒進程會被 1 號 init 或 systemd 進程領養,此時由 init / systemd 進程處理子進程,防止內存泄漏:
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>int main()
{int ret = fork();if (ret < 0){ perror("fork");return 0;} else if (ret == 0){ printf("我是子進程,我的 PID 為: %d\n", getpid()); // 子進程死循環 while (true){ ; } } else { printf("我是父進程,我的 PID 為: %d\n", getpid()); // 父進程打印退出} return 0;
}
可以看到子進程被 1 號進程 " 領養 ",子進程退出變為僵尸狀態,而 1 號進程會定期調用 wait() 回收所有孤兒進程的狀態信息,確保其不會長期滯留為僵尸進程。
當然,這種方法只是兜底策略,如果在父進程長期運行的環境下(如服務器),不注意回收子進程的內存泄漏風險依然存在。