掌握不深,能力有限,僅作學習探討,需在以後更深層次的去理解。
本篇主要是對於進程知識的一個梳理,順便簡介了一下線程:
進程是Unix操作系統最基本的抽象之一,一個進程就是處於執行期的程序(目標碼存放在某種存儲介質上)。
執行線程,簡稱線程,實在進程中活動的對象。每個線程都擁有一個獨立的程序計數器、進程棧和一組進程寄存器。
內核調度的對象是線程,而不是進程。Linux系統的線程實現非常特別——它對線程和進程並不特別區分。對Linux而言,線程只不過是一種特殊的進程罷了。
進程的另一個名字是任務(task),Linux內核通常把進程也叫做任務。
1 進程描述符及任務結構
內核把進程存放叫做任務隊列(task list)的雙向循環鏈表中。鏈表中的每一項都是類型爲task_struct、稱爲進程描述符(process descriptor)的結構,該結構定義在<linux.sched.h>中。進程描述符中包含一個具體進程的所有信息。
進程描述符中包含的數據能完整的描述一個正在執行的程序:它打開的文件、進程的地址空間、掛起的信號、進程的狀態,還有其它更多的信息(如下圖)。
1.1 分配進程描述符
在2.6以前的內核中,各個進程的task_struct存放在它們內核棧的尾端。這樣做是爲了讓那些想x86這樣寄存器較少的硬件體系結構只要通過棧指針就能計算出它的位置,從而避免使用額外的寄存器專門記錄。由於現在用slab分配器動態生成task_struct,所以只需在棧底(對於向下增長的棧來說)或棧頂(對於向上增長的棧來說)創建一個新的結構struct thread_info(如下圖)。這個新的結構能使在彙編代碼中計算其偏移變得相當容易。
struct thread_info {
struct task_struct *任務;
struct exec_domain *exec_domain;
unsigned long flags;
unsigned long status;
__u32 cpu;
__s32 preempt_count;
mm_segment_t addr_limit;
struct restart_block restart_block;
unsigned long previous_esp;
__u8 supervisor_stack[0];
};
每個任務的thread_info結構在它的內核棧的尾端分配。結構中task域中存放的是指向該任務實際task_struct的指針。1.2 進程描述符的存放
movl $-8192, %eax
andl %esp, %eax
這裏假定棧的大小爲8KN。當4KB的棧啓用時,就要用4096,而不是8192。1.3 進程狀態
- TASK_RUNNING(運行)——進程是可執行的;它或者正在執行,或者在運行隊列中等待執行。這是進程在用戶空間中執行惟一可能的狀態;也可以應用到內核空間中正在執行的進程。
- TASK_INTERRUPTIBLE(可中斷)——進程正在睡眠(也就是被阻塞),等待某些條件的達成。一旦這些條件達成,內核就會把進程狀態設置爲運行。處於此狀態的進程也會因爲接收到信號而提前被喚醒並投入運行。
- TASK_UNINTERRUPTIBLE(不可中斷)——除了不會因爲接收到信號而被喚醒從而投入運行外,這個狀態與可打斷狀態相同。這個狀態與可打斷狀態相同。這個狀態通常在進程必須在等待時不受干擾或等待事件很快就會發生時出現。由於處於此狀態的任務對信號不作響應,所以較之可中斷狀態,使用的較少。
- TASK_ZOMBIE(僵死)——該進程已經結束了,但是其父進程還沒有調用wait4()系統調用。爲了父進程能夠獲知它的消息,子進程的描述符仍然被保留着。一旦父進程調用了wait4(),進程描述符就會被釋放。
- TASK_STOPPED(停止)——進程停止執行;進程沒有投入運行也不能投入運行。通常這種狀態發生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信號的時候。此外,在調試期間接收到任何信號,都會使進程進入這種狀態。
1.4 設置當前進程狀態
1.5 進程上下文
1.6 進程家族樹
所有進程都是PID爲1的init進程的後代。內核在系統啓動的最後階段啓動init進程。該進程讀取系統的初始化腳本並執行其他的相關程序,最終完成系統啓動的整個過程。
系統中的每個進程必有一個父進程。相應地,每個進程也可以擁有零個或多個子進程。擁有同一個指向其父進程tast_struct、叫做parent的指針,還包含一個成爲children的子進程鏈表。所以對於當前進程,可以通過下面的代碼獲得其父進程的進程描述符:
struct task_struct *my_parent = current->parent;
同樣,也可以按以下方式依次訪問子進程:
struct task_struct *task;
struct list_head *list;
list_for_each(list, ¤t->children) {
task = list_entry(list, struct task_struct, sibling);
/*task 現在指向當前的某個子進程*/
}
init進程的進程描述符是作爲init_task靜態分配的。下面的代碼可以很好地演示所有進程之間的關係:
struct task_struct *task;
for (task = current; task != &init_task; task = task->parent)
;
/*task 現在指向init*/
任務隊列是一個雙向循環鏈表。對於給定的進程,獲取鏈表的下一個進程:
list_entry(task->tasks.next, struct task_struct, tasks)
獲取前一個進程的方法相同:
list_entry(task->tasks.prev, struct task_struct, tasks)
這兩個例程分別通過next_task(task)宏和prev_task(task)宏實現。而實際上,for_each_process(task)宏提供了依次訪問整個任務隊列的能力。每次訪問,任務指針都指向鏈表中的下一個元素:
struct task_struct *task;
for_each_process(task) {
/*它打印出每一個任務的名稱和PID*/
printk("%s[%d]\n", task->comm, task->pid);
}
需要注意的是,在一個擁有大量進程的系統中通過重複來遍歷所有的進程是非常耗時的。因此,如果沒有充足的理由的話別這樣做。
2 進程創建
Unix的進程創建很特別。許多其他的操作系統都提供了產生(spawn)的機制,首先在新的地址空間裏創建進程,讀入可執行文件,最後開始執行。在Unix中,將上述步驟分解到兩個單獨的函數中去執行:fork()和exec()。fork()通過拷貝當前進程創建一個子進程。子進程與父進程的區別僅僅在於PID(每個進程惟一)、PPID(父進程的進程號,子進程將其設置爲被拷貝進程的PID)和某些資源和統計量(例如掛起信號,它沒有必要被繼承)。exec()函數負責讀取可執行文件並將其載入地址空間開始運行。把這兩個函數組合起來使用的效果跟其他系統使用的單一函數的效果相似。2.1 寫時拷貝
傳統的fork()系統調用直接把所有的資源複製給新創建的進程。這種實現過於簡單並且效率低下,因爲它拷貝的數據也許並不共享,更糟的情況是,如果新進程打算立即執行一個新的映像,那麼所有的拷貝都將前功盡棄。Linux的fork()使用寫時拷貝(copy-on-write)頁實現。寫時拷貝是一種可以推遲甚至免除拷貝數據的技術。內核此時並不複製整個進程地址空間,而是讓父進程和子進程共享同一個拷貝。只有在需要寫入的時候,數據纔會被複制,從而使各個進程擁有各自的拷貝。也就是說,資源的複製只有在需要寫入的時候,數據纔會被複制,從而使各個進程擁有各自的拷貝。也就是說,資源的複製只有在需要寫入的時候才進行,在此之前,只是以只讀方式共享。這種技術使地址空間上的頁的拷貝被推遲到實際發生寫入的時候。在頁根本不會被寫入的情況下——舉例來說,fork()後立即調用exec()——它們就無需複製了。fork()的實際開銷就是複製父進程的頁表以及給子進程創建惟一的進程描述符。在一般情況下,進程創建後都會馬上運行一個可執行的文件,這種優化可以避免拷貝大量根本就不會被使用的數據(地址空間裏常常包含數十兆的數據)。由於Unix強調進程快速執行的能力,所以這個優化是很重要的。
2.2 fork()
Linux通過clone()系統調用實現fork()。這個調用通過一系列的參數標誌來指明父、子進程需要共享的資源。fork()、vfork()和__clone()庫函數都根據各自需要的參數標誌區調用clone()。然後由clone()去調用do_fork()。
do_fork完成了創建中的大部分工作,它的定義在kernel/fork.c文件中。該函數調用copy_process()函數,然後讓進程開始運行。copy_process()函數的工作過程:
調用dup_task_struct()爲新進程創建一個內核棧、thread_info結構和task_struct,這些值與當前進程的值相同。此時,子進程和父進程的描述是完全相同。
- 檢查新創建的這個子進程後,當前用戶所擁有的進程數目沒有超出給他分配的資源的限制。
- 現在,子進程着手使自己與父進程區別開來。進程描述符內的許多成員都要被清0或設爲初始值。進程描述符的成員值並不是繼承而來的,而主要是統計信息。進程描述符中的大多數數據都是共享的。
- 接下來,子進程的狀態被設置爲TASK_UNINTERRUPTIBLE以保證它不會投入運行。
- copy_process()調用copy_flags以更新task_struct的flags成員。表明進程是否擁有超級用戶權限的PF_SUPERPRIV標誌被清0。表明進程還有沒有調用exec()函數的PF_FORKNOEXEC標誌被設置。
- 調用get_pid()爲新進程過去一個有效的PID。
- 根據傳遞給clone的參數標誌,copy_process()拷貝或共享打開的文件、文件系統信息、信號處理函數、進程地址空間和命名空間等。在一般情況下,這些資源會被給定進程的所有線程共享;否則,這些資源對每個進程是不同的,因此被拷貝到這裏。
- 讓父進程和子進程平分剩餘的時間片。
- 最後,copy_process()做掃尾工作並返回一個指向子進程的指針。
2.3 vfork()
- 在調用copy_process()時,task_struct的vfork_done成員被設置爲NULL。
- 在執行do_fork()時,如果給定特別標誌,則vfork_done會指向一個特殊地址。
- 子進程開始執行後,父進程不是馬上恢復執行,而是一直等待,直到子進程通過vfork_done指針向它發送信號。
- 在調用mm_release()時,該函數用於進程退出內存地址空間,並且檢查vfork_done是否爲空,如果不爲空,則會向父進程發送信號。
- 回到do_fork(),父進程醒來並返回。
3 線程在Linux中的實現
線程機制是現代編程技術中常用的一種抽象。該機制提供了在同一程序內共享內存地址空間運行的一組線程。這些線程還可以共享打開的文件和其他資源。線程機制支持併發程序設計技術(concurrent programming),在多處理器系統上,它能保證真正的並行處理(parallelism)。
Linux實現線程的機制非常獨特。從內核的角度來說,他並沒有線程這個概念。Linux把所有線程都當作進程來實現。內核並沒有準備特別的調度算法或是定義特別的數據結構來表徵線程。相反,線程僅僅被視爲一個與其他進程共享某些資源的進程。每個線程都擁有惟一隸屬於自己的task_struct,所以在內核中,他看起來就像是一個普通的進程(只是該進程和其它一些進程共享某些資源,如地址空間)。
上述線程機制的實現與Microsoft Windows或是Sun Solaris等操作系統的實現差異非常大。這些系統都在內核中提供了專門支持線程的機制(這些系統嚐嚐把線程稱作輕量級進程,lightweight process)。“輕量級進程”這種叫法本身就概括了Linux在此處與其它系統的差異。在其它的系統中,相較於重量級的進程,線程被抽象成一種耗費較少資源,運行迅速的執行單元。而對於Linux來說,它只是一種進程間共享資源的手段(Linux的進程本身就夠輕了)。舉個例子來說,假如我們有一個包含4個線程的進程,在提供專門線程支持的系統中,通常會有一個包含指向四個不同線程指針的進程描述符。該描述符負責描述像地址空間、打開的文件這樣的共享資源。線程本身再去描述它獨佔的資源。相反,Linux僅僅創建4個進程並分配4個普通的task_struct結構。建立這4個進程時指定它們共享某些資源就行了。
線程的創建和普通進程的創建類似,只不過在調用clone()的時候需要傳遞一些參數標誌來指明需要共享的資源:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
上面的代碼產生的結果和調用fork()差不多,只是父子倆共享地址空間、文件系統資源、文件描述符合信號處理程序。換個說話就是,新建的進程和它的父進程就是流行的所謂線程。
對比一下,一個普通的fork()的實現就是:
clone(SIGCHLD, 0);
而vfork()的實現是:
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
傳遞給clone()的參數標誌決定了新創建進程的行爲方式和父子進程之間共享的資源種類。下表列舉了這些clone用到的的參數標誌以及它們的作用,這些是在<linux/sched.h>中定義的。
參數標誌 | 含義 |
CLONE_FILES | 父子進程共享打開文件 |
CLONE_FS | 父子進程共享文件系統信息 |
CLONE_IDLETASK | 將PID設置爲0(只供idle進程使用) |
CLONE_NEWNS | 爲子進程創建新的命名空間 |
CLONE_PARENT | 指定子進程與父進程擁有同一個父進程 |
CLONE_SETTID | 將TID回寫至用戶空間 |
CLONE_SETTLS | 爲子進程差創建新的TLS |
CLONE_SIGHAND | 父子進程共享信號處理函數 |
CLONE_SYSVSEM | 父子進程共享Sytem V SEM_UNDO語義 |
CLONE_THREAD | 父子進程放入相同的線程組 |
CLONE_VFORK | 調用vfork(),所以父進程準備睡眠等待子進程將其喚醒 |
CLONE_UNTRACED | 防止跟蹤進程在子進程上強制執行CLONE_PTRACE |
CLONE_STOP | 以TASK_STOPPED狀態開始進程 |
CLONE_SETTLS | 爲子進程創建新的TLS(thread-local storage) |
CLONE_CHILD_CLEARTID | 清楚子進程的TID |
CLONE_CHILD_SEETID | 設置子進程的TID |
CLONE_PARENT_SETTID | 設置父進程的TID |
CLONE_VM | 父子進程共享地址空間 |
內核線程
內核經常需要在後臺一些操作。這種任務可以通過內核線程(kernel thread)完成——獨立運行在內核空間的標準進程。內核線程和普通的進程間的區別在於內核線程沒有獨立的地址空間(實際上它的mm指針被設置爲NULL)。它們只在內核空間運行,從來不切換到用戶空間去。內核進程和普通進程一樣,可以被調度,也可以被搶佔。
Linux確實會把一些任務交給內核線程去做,想pdflush和ksoftirqd這些任務就是明顯的例子。這些線程在系統啓動時由另外一些內核線程啓動。實際上,內核線程也只能由其它內核線程創建。在現有內核線程中創建一個新的內核線程的方法如下:
int kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
新的任務也是通過向普通的cline()系統調用傳遞特定的flags參數而創建的。在上面的函數返回時,父線程退出,並返回一個指向子線程task_struct的指針。子線程開始運行fn指向的函數,arg是運行時需要用到的參數。一個特殊的clone標誌CLONE_KERNEL定義了內核線程常用到的參數標誌:CLONE_FS、CLONE_FILES、CLONE_SIGHAND。大部分的內核線程把這個標誌傳遞給它們的flags參數。
4 進程的終結
- 首先,將task_struct中的標誌成員設置爲PF_EXITING
- 其次,調用del_timer_sync()刪除任一內核定時器。根據返回的結果,它確保沒有定時器在排隊,也沒有定時器處理程序在運行。
- 如果BSD的進程計賬功是開啓的,do_exit()調用acct_process()來輸出計帳信息。
- 然後調用_exit_mm()函數放棄進程佔用的mm_struct,如果沒有別的進程使用它們(也就是說,它們沒有被共享),就徹底釋放它們。
- 接下來調用exit_sem()函數。如果進程排隊等候IPC信號,它則離開隊列。
- 調用_exit_files()、_exit_fs()、exit_namespace()和exit_sighand(),已分別遞減文件描述符、文件系統數據、進程名字空間和信號處理函數的引用計數。如果其中某些引用計數的數值降爲零,那麼就代表沒有進程在使用相應的資源,此時可以釋放。
- 接着把存放在task_struct的exit_code成員中的任務退出代碼置位exit()提供的代碼中去,或者去完成任何其它由內核機制規定的退出動作。退出代碼存放在這裏供父進程隨時檢索。
- 調用exit_notify()向父進程發送信號,將子進程的父進程重新設置爲線程組中的其它線程或init進程,並把進程狀態設成TASK_ZOMBIE。
- 最後,do_exit()調用schedule()切換到其它進程。因爲處於TASK_ZOMBIE狀態的進程不會再被調度,所以這是進程所執行的最後一段代碼。
4.1 刪除進程描述符
在調用了do_exit()之後,儘管線程已經僵死不能再運行了,但是系統還保留了它的進程描述符。前面說過,這樣做可以讓系統有辦法在子進程終結後仍能獲得它的信息。因此,進程終結時所需的清理工作和進程描述符的刪除被分開執行。在父進程獲得已終結的子進程信息後,或者通知內核它並不關注那些信息後,子進程的task_struct結構才被釋放。
wait()這一族函數都是通過惟一(但是很複雜)的一個系統調用wait4()實現的。它的標準動作是掛起調用它的進程,直到其中一個子進程退出,此時函數會返回該子進程的PID。此外,調用該函數時提供的指針會包含子函數退出時的退出代碼。
當最終需要釋放進程描述符時,release_task()會被調用,用以完成以下工作:
- 首先,它調用free_uid()來減少該進程擁有者的進程使用計數。Linux用一個單用戶高速緩存統計和記錄每個用戶佔用的進程數目、文件數目。如果這些數目都將爲0,表明這個用戶沒有任何進程和文件,那麼這塊緩存就可以銷燬了。
- 然後,release_task()調用unhash_process()從pidhash上刪除該進程,同時也要從task_list中刪除該進程。
- 接下來,如果這個進程正在被ptrace跟蹤、release_task()將跟蹤的父進程重設爲其最初的父進程並將它從ptrace list上刪除。
- 最後,release_task()調用put_task_struct()釋放進程內核棧和thread_info結構所佔的頁,並釋放task_struct所佔用的slab高速緩存。
4.2 孤兒進程長成的進退維谷
struct task_struct *p, *reaper = father;
struct list_head *list;
if (father->exit_signal != -1)
reaper = prev_thread(reaper);
else
reaper = child_reaper;
if (reaper == father)
reaper = child_reaper;
這段代碼將reaper設置爲該進程所在的線程組內的其他進程。如果線程組內沒有其他進程,它就將reaper設置爲child_reaper,也就是init進程。現在,合適的父進程也已經找到了,只需要遍歷所有子進程併爲他們設置新的父進程:list_for_each(list, &father->children) {
p = list_entry(list, struct task_struct, sibling);
reparent_thread(p, reaper, child_reaper);
}
list_for_each(listm &father->ptrace_children) {
p = list_entry(list, struct task_struct, ptrace_list);
reparent_thread(p, reaper, child_reaper);
}
這段代碼遍歷了兩個鏈表:子進程鏈表和ptrace子進程鏈表,給每個子進程設置新的父進程。這兩個鏈表同時存在的原因很有意思,它也是2.6內核的一個新特性。當一個進程被跟蹤是,它被暫時設定爲調試進程的子進程。此時如果它的父進程退出了,系統會爲它和它的所有兄弟重新找一個父進程。在以前的內核中,這就需要遍歷系統所有的進程來找這些子進程。現在的解決辦法是在一個單獨的被ptrace跟蹤的子進程鏈表中搜索相關的兄弟進程——用兩個相關鏈表減輕了遍歷帶來的消耗。