本文為原創,轉載請注明:http://www.cnblogs.com/tolimit/
最近在回想一些知識點的時候,覺得對進程這一塊有些模糊,特別寫一篇隨筆對進程信息進行鞏固和復習。
程序和進程
以我個人的理解就是,程序是一段二進制編碼甚至是一個簡單的可執行文件,而當程序這段二進制編碼放入內存運行時,它就會產生一個或多個進程。
CPU時間片
對於CPU來說,它的工作就是不停地執行指令,而由於CPU執行指令的速度非常快,它可以用5ms的時間專門用於執行進程A,5ms的時間專門用於執行進程B,5ms的時間專門用於執行進程C,然后這樣不停交替執行進程A、B、C。在我們看來就像進程A、B、C在同時執行一樣,而實際上同一個時間點只有一個進程正在CPU上運行。
進程描述符
就是用於描述一個進程的結構體,每個進程有且只有一個進程描述符,它里面包含了這個進程相關的所有信息。
struct task_struct { ...... /* 進程狀態 */ volatile long state; /* 指向內核棧 */ void *stack; /* 用於加入進程鏈表 */ struct list_head tasks; ...... /* 指向該進程的內存區描述符 */ struct mm_struct *mm, *active_mm; ........ /* 進程ID,每個進程(線程)的PID都不同 */ pid_t pid; /* 線程組ID,同一個線程組擁有相同的pid,與領頭線程(該組中第一個輕量級進程)pid一致,保存在tgid中,線程組領頭線程的pid和tgid相同 */ pid_t tgid; /* 用於連接到PID、TGID、PGRP、SESSION哈希表 */ struct pid_link pids[PIDTYPE_MAX]; ........ /* 指向創建其的父進程,如果其父進程不存在,則指向init進程 */ struct task_struct __rcu *real_parent; /* 指向當前的父進程,通常與real_parent一致 */ struct task_struct __rcu *parent; /* 子進程鏈表 */ struct list_head children; /* 兄弟進程鏈表 */ struct list_head sibling; /* 線程組領頭線程指針 */ struct task_struct *group_leader; /* 在進程切換時保存硬件上下文(硬件上下文一共保存在2個地方: thread_struct(保存大部分CPU寄存器值,包括內核態堆棧棧頂地址和IO許可權限位),內核棧(保存eax,ebx,ecx,edx等通用寄存器值)) */ struct thread_struct thread; /* 當前目錄 */ struct fs_struct *fs; /* 指向文件描述符,該進程所有打開的文件會在這里面的一個指針數組里 */ struct files_struct *files; ........ /* 信號描述符,用於跟蹤共享掛起信號隊列,被屬於同一線程組的所有進程共享,也就是同一線程組的線程此指針指向同一個信號描述符 */ struct signal_struct *signal; /* 信號處理函數描述符 */ struct sighand_struct *sighand; /* sigset_t是一個位數組,每種信號對應一個位,linux中信號最大數是64 * blocked: 被阻塞信號掩碼 * real_blocked: 被阻塞信號的臨時掩碼 */ sigset_t blocked, real_blocked; sigset_t saved_sigmask; /* restored if set_restore_sigmask() was used */ /* 私有掛起信號隊列 */ struct sigpending pending; ........ }
這里只截取了部分之后需要說明的字段。在內核中,會有一個進程鏈表通過使用進程描述符中的tasks結構把所有進程的進程鏈表鏈接起來。
進程內核棧
我們在編程的時候知道,在進程地址空間中有個棧,用於程序的順利執行,而當程序陷入內核態之后,就不能夠使用應用態的棧了,所以,對於每個進程(准確說是對於每個線程),它在內核中也有一個內核態的棧區,在內核中,把棧和thread_info(線程描述符)結構結合起來放在一起,這塊存儲區域通常為8192字節,也就是兩個頁框。thread_info結構大小為52字節,也就是說,進程的可用的棧大小為8140個字節。因為進程在內核態中所需要執行的代碼量並不算多,所以這個8K的內核棧已經足夠使用。在編譯內核時也可以設置整個內核棧為一個頁框大小(4KB),不過在這種情況下,內核在處理硬中斷和軟中斷時就不使用進程的內核棧棧,而是使用額外的兩個個棧:硬中斷請求棧(每個CPU一個,大小4K),軟中斷請求棧(每個CPU一個,大小4K)。不過值得注意的是,在進行異常處理時還是會使用進程的內核棧。
如上圖可以看到,進程的內核棧是向下增長的,也就是棧底在高位地址,棧頂在低位地址。對於這個內核棧的作用,我們可以總結一下:
- 進程陷入內核后用於代替應用層的棧區進行使用。
- 中斷發生時用於保存進程上下文現場,並且用於中斷嵌套的現場保存和返回。
- 當發生進程切換時,部分寄存器的值會保存在進程的內核棧中。
- thread_info中保存着一些重要的字段用於維持進程的正常運行。
輕量級進程
linux使用輕量級進程對多線程應用提供支持,其實它的創建也是基於fork()系統調用,只是在進程描述符的初始化當中有所區別。首先,輕量級進程也是一個進程,它有它自己的pid,有它自己的內核棧和進程描述符,甚至還有它自己的調度策略,而輕量級進程和普通進程不同的就是它沒有自己的進程地址空間,並且要響應線程組內其他線程接收到的信號(但可以通過修改信號屏蔽字屏蔽某些信號)。輕量級進程使用的是父進程的內存地址空間,也就是在task_struct結構中的mm和active_mm指針都指向父進程的mm指針所指地址。而信號描述符指針signal會指向父進程指向的地址。而在應用層,線程有自己的棧,我想這個應該是由glibc實現的。
輕量級進程和普通進程區別:
- 沒有自己的進程地址空間,使用父進程的進程地址空間
- 與組內所有進程共享信號,但有自己的信號屏蔽字
進程狀態
- TASK_RUNNING:可運行狀態,進程要么在CPU上執行,要么准備執行。
- TASK_INTERRUPTIBLE:可中斷的等待狀態,進程被掛起(睡眠),直到某個條件為真,產生一個硬中斷、釋放進程正等待的系統資源、或傳遞一個信號都是可以喚醒進程的條件。
- TASK_UNINTERRUPTIBLE:不可中斷的等待狀態,與可中斷等待狀態類似,只是不能被信號喚醒。在一些特殊情況下會使用,例如:當進程打開一個設備文件,設備驅動會開始探測相應的硬件時會用到這種狀態。
- TASK_STOPED:暫停狀態,當進程接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU信號后進入。
- TASK_TRACED:跟蹤狀態,進程執行由debugger程序暫停,當一個進程被另一個進程監控時,任何信號都可以把這個進程置於TASK_TRACED狀態。
還有兩個狀態是既可以存放在進程描述符的state字段中,也可以存放在exit_state字段中。從這兩個字段可以看出,只有當進程執行被終止時,進程的狀態才會為這兩種狀態中的一種:
- EXIT_ZOMBIE:僵死狀態,進程將被終止,但父進程還沒有發布wait4()或者waitpid()系統調用來返回關於死亡進程的信息。發布wait()類系統調用之前,內核不能丟棄包含在死進程描述符中的數據,因為父進程可能還需要它。
- EXIT_DEAD:僵死撤銷狀態,進程被終止后的最終狀態,父進程發布wait4()或者waitpid()系統調用后,內核刪除此進程描述符。
對於一個普通進程,它的執行狀態如下圖所示:
我們使用一個簡單地例子說明這種狀態的轉變,我們有個程序A,它的工作就是做一些計算,然后把計算結構寫入磁盤文件中。我們在shell中運行它,起初它就是TASK_RUNNING狀態,也就是運行態,CPU會不停地分配時間片供我們的進程A運行,每次時間片耗盡后,進程A都會轉變到就緒態(實際上還是TASK_RUNNING狀態,只是此時在等待CPU分配時間片,暫時不在CPU上運行)。當進程A使用fwrite或write將數據寫入磁盤文件時,就會進入阻塞態(TASK_INTERRUPTIBLE狀態),而磁盤將數據寫入完畢后,會通過一個中斷告知內核,內核此時會將進程A的狀態由阻塞態(TASK_INTERRUPTIBLE)轉變為就緒態(TASK_RUNNING)等待CPU分配時間片運行。而最后當進程A需要退出時,內核先會將其設置為僵死狀態(EXIT_ZOMBIE),這時候它所使用的內存已經被釋放,只保留了一個進程描述符供父進程使用,最后當父進程(也就是我們起初啟動它的shell)通過wait()類系統調用通知內核后,內后會將進程A設置為僵死撤銷狀態(EXIT_DEAD),並釋放其進程描述符。到這里進程A的整個運行周期完整結束。
PID和tgid字段
PID是一個數字,用於標識一個進程,就像學生的學號一樣,每個進程都有一個唯一的編號,保存在進程描述符的pid字段中。一般的,在系統運行期間,PID都是被順序編號,比如進程A的PID為10,那下個創建的進程的PID則為11。不過PID的值有一個上限,當內核使用的PID達到這個上限后就會循環開始找已閑置的小PID號。在缺省狀態下,最大PID值為32767(PID_MAX_DEFAULT - 1);可以通過修改/proc/sys/kernel/pid_max這個文件來減小PID上限值。而在64位系統中,PID可擴大到4194303。
內核是通過一個叫pidmap的位圖來管理已分配的PID號和閑置的PID號。在32位系統中,pidmap的大小就是一個頁框的大小(4KB),而一個頁框大小為32768位,也就是每一位代表一個PID號,1代表此PID已經被分配,0代表此PID號未被使用;而在64位系統下,pidmap會使用多個頁框。
在POSIX標准中規定了一個多線程應用程序中所有的線程都必須有相同的PID,在linux內核中,是使用輕量級進程實現線程的功能,但是輕量級進程也是一個進程,他們的PID都不相同,為了實現這一點,內核在進程描述符中引入了tgid字段。在linux的線程組概念中,一個線程組中所有線程使用的該線程組領頭線程相同的PID,也就是該組第一個輕量級進程的PID,並保存到進程描述符的tgid字段中,如下圖:
在編程過程中,我們使用的getpid()函數返回的值其實是當前進程的tgid而不是pid的值,而由於線程組中領頭線程和pid和tgid相同,因而getpid()對這類進程所起到的作用和一般進程是一樣的。
接下來說說內核如何將所有的PID和進程描述符組織在一起,方便系統查找和使用。在系統運行過程中,可能會有成百上千的進程在運行,這時候進程的查找效率就至關重要了,比如系統管理員使用kill 1024命令去終止PID=1024的進程,內核會從這個PID導出對應的進程描述符進行處理。內核為了提高查找效率,專門使用了4個哈希表用於索引進程描述符。為什么要4個,因為我們可以用pid、tgid、pgrp、session去找進程,這幾個哈希表說明如下:
在內核中,這四個哈希表一共占16個頁框,也就是每個哈希表占4個頁框,他們每個可以擁有2048個表項,內核會把把這四個哈希表的地址保存到pid_hash數組中。現在問題來了,拿pid的哈希表為例,怎么在2048個表項中保存32767個PID值,其實內核會對每個已經分配的PID值進行一個處理,得到的結果的數值就是對應的表項,處理結果相同的PID被串成一個鏈表,如下:
當我們使用kill 29384命令時,內核會根據29384處理得出199,然后以199為下標,獲取PID哈希表中對應的鏈表頭,並在此鏈表中找出PID=29384的進程。進程描述符中使用struct pid_link pids[PIDTYPE_MAX]鏈入這四個哈希表。對於另外三個哈希表,道理一樣。
進程間關系
在系統中,除了進程0,一個進程是由另一個進程創建,它們都具有父子關系。如果一個進程創建多個子進程,則子進程之間有兄弟關系。在整個系統啟動期間,會初始化系統的第一個進程init_task,這個進程屬於內核中的一個進程,它算是所有進程的祖先,之后它會啟動PID為1的init進程和PID為2的kthreadd,這兩個進程之后啟動的所有進程,而init_task之后會轉變為一個idle進程用於CPU空閑時運行。在進程描述符中,使用real_parent、parent、children、sibling這幾個指針將進程關系組織在一起,我們看看這幾個指針的說明:
而如果一個進程P0創建了進程P1、P2、P3,進程P3又創建了進程P4,它們整個鏈表情況是這樣的
組織進程
所有處於TASK_RUNNING狀態的進程都會被放入CPU的運行隊列,它們有可能在不同CPU的運行隊列中。
系統沒有為TASK_STOPED、EXIT_ZOMBIE和EXIT_DEAD狀態的進程建立專門的鏈表,因為處於這些狀態的進程訪問比較簡單,可通過PID和通過特定父進程的子進程鏈表進行訪問。
所有TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE都會被放入相應的等待隊列,系統中有很多種等待隊列,有些是等待磁盤操作的終止,有些是等待釋放系統資源,有些是等待時間經過固定的間隔,每個等待隊列它的喚醒條件不同,比如等待隊列1是等待系統釋放資源A的,等待隊列2是等待系統釋放資源B的。因此,等待隊列表示一組睡眠進程,當某一條件為真時,由內核喚醒這條等待隊列上的進程。我們看看內核中一個簡單的sleep_on()函數:
/* wq為某個等待隊列的隊列頭 */ void sleep_on (wait_queue_head_t *wq) { /* 聲明一個等待隊列結點 */ wait_queue_t wait; /* 用當前進程初始化這個等待隊列結點 */ init_waitqueue_entry (&wait, current); /* 設置當前進程狀態為TASK_UNINTERRUPTIBLE */ current->state = TASK_UNINTERRUPTIBLE; /* 將這個代表着當前進程的等待隊列結點加入到wq這個等待隊列 */ add_wait_queue (wq, &wait); /* 請求調度器進行調度,執行完schedule后進程會被移除CPU運行隊列,只有等待隊列喚醒后才會重新回到CPU運行隊列 */ schedule (); /* 這里進程已經被等待隊列喚醒,重新移到CPU運行隊列,也就是等待的條件已經為真,喚醒后第一件事就是將自己從等待隊列wq中移除 */ remove_wait_queue (wq, &wait); }
這時候又有一個問題,比如有等待隊列是等待系統釋放資源A,而等待隊列中所有的進程都是希望能夠占有這個資源A的,就像我們編程中用到的信號量,這時候系統的做法不是將這個等待隊列中所有的進程都進行喚醒,而是只喚醒一個。內核區分這種互斥進程的原理就是這個等待隊列中所有的等待隊列結點wait_queue_t中的flags被設置為1(默認是0)。