Linux內核深度解析之進程管理丨內含贈書福利(二)
3.喚醒新進程
函數wake_up_new_task負責喚醒剛剛創建的新進程,其代碼如下:
第7行代碼,把新進程的狀態從TASK_NEW切換到TASK_RUNNING。
第9行代碼,在SMP系統上,創建新進程是執行負載均衡的絕佳時機,為新進程選擇一個負載最輕的處理器。
第11行代碼,鎖住運行隊列。
第12行代碼,更新運行隊列的時鐘。
第13行代碼,根據公平運行隊列的平均負載統計值,推算新進程的平均負載統計值。
第15行代碼,把新進程插入運行隊列。
第18行代碼,檢查新進程是否可以搶占當前進程。
第22行代碼,在SMP系統上,調用調度類的task_woken方法。
第26行代碼,釋放運行隊列的鎖。
4.新進程第一次運行
新進程第一次運行,是從函數ret_from_fork開始執行。函數ret_from_fork是由各種處理器架構自定義的函數,ARM64架構定義的ret_from_fork函數如下:
在介紹函數copy_thread時,我們已經說過:如果新進程是內核線程,寄存器x19存放線程函數的地址,寄存器x20存放線程函數的參數;如果新進程是用戶進程,寄存器x19的值是0。
函數ret_from_fork的執行過程如下。
第4行代碼,調用函數schedule_tail,為上一個進程執行清理操作。
第8行和第9行代碼,如果寄存器x19的值是0,說明當前進程是用戶進程,那么使用寄存器x28存放當前進程的thread_info結構體的地址,然后跳轉到標號ret_to_user返回用戶模式。
第6行和第7行代碼,如果寄存器x19的值不是0,說明當前進程是內核線程,那么調用線程函數。
函數schedule_tail負責為上一個進程執行清理操作,是新進程第一次運行時必須最先做的事情,其代碼如下:
函數schedule_tail的執行過程如下。
第6行代碼,調用函數finish_task_switch(),為上一個進程執行清理操作,參考2.8.6節。
第7行代碼,執行運行隊列的所有負載均衡回調函數。
第8行代碼,開啟內核搶占。
第10行和第11行代碼,如果pthread庫在調用clone()創建線程時設置了標志位CLONE_CHILD_SETTID,那么新進程把自己的進程標識符寫到指定位置。
2.5.2 裝載程序
當調度器調度新進程時,新進程從函數ret_from_fork開始執行,然后從系統調用fork返回用戶空間,返回值是0。接著新進程使用系統調用execve裝載程序。
linux內核提供了兩個裝載程序的系統調用:
兩個系統調用的主要區別是:如果路徑名是相對的,那么execve解釋為相對調用進程的當前工作目錄,而execveat解釋為相對文件描述符dirfd指向的目錄。如果路徑名是絕對的,那么execveat忽略參數dirfd。
參數argv是傳給新程序的參數指針數組,數組的每個元素存放一個參數字符串的地址,argv[0]應該指向要裝載的程序的名稱。
參數envp是傳給新程序的環境指針數組,數組的每個元素存放一個環境字符串的地址,環境字符串的形式是“鍵=值”。
argv和envp都必須在數組的末尾包含一個空指針。
如果程序的main函數被定義為下面的形式,參數指針數組和環境指針數組可以被程序的main函數訪問:
可是,POSIX.1標準沒有規定main函數的第3個參數。根據POSIX.1標準,應該借助外部變量environ訪問環境指針數組。
兩個系統調用最終都調用函數do_execveat_common,其執行流程如圖2.11所示。
圖2.11 裝載程序的執行流程
(1)調用函數do_open_execat打開可執行文件。
(2)調用函數sched_exec。裝載程序是一次很好的實現處理器負載均衡的機會,因為此時進程在內存和緩存中的數據是最少的。選擇負載最輕的處理器,然后喚醒當前處理器上的遷移線程,當前進程睡眠等待遷移線程把自己遷移到目標處理器。
(3)調用函數bprm_mm_init創建新的內存描述符,分配臨時的用戶棧。
如圖2.12所示,臨時用戶棧的長度是一頁,虛擬地址范圍是[STACK_TOP_MAX?頁長度,STACK_TOP_MAX),bprm->p指向在棧底保留一個字長(指針長度)后的位置。
(4)調用函數prepare_binprm設置進程證書,然后讀文件的前面128字節到緩沖區。
(5)依次把文件名稱、環境字符串和參數字符串壓到用戶棧,如圖2.13所示。
圖2.12 臨時用戶棧
圖2.13 把文件名稱、環境和參數壓到用戶棧
(6)調用函數exec_binprm。函數exec_binprm調用函數search_binary_handler,嘗試注冊過的每種二進制格式的處理程序,直到某個處理程序識別正在裝載的程序為止。
1.二進制格式
在Linux內核中,每種二進制格式都表示為下面的數據結構的一個實例:
每種二進制格式必須提供下面3個函數。
(1)load_binary用來加載普通程序。
(2)load_shlib用來加載共享庫。
(3)core_dump用來在進程異常退出時生成核心轉儲文件。程序員使用調試器(例如GDB)分析核心轉儲文件以找出原因。min_coredump指定核心轉儲文件的最小長度。
每種二進制格式必須使用函數register_binfmt向內核注冊。
下面介紹常用的二進制格式:ELF格式和腳本格式。
2.裝載ELF程序
(1)ELF文件:ELF(Executable and Linkable Format)是可執行與可鏈接格式,主要有以下4種類型。
目標文件(object file),也稱為可重定位文件(relocatable file),擴展名是“.o”,多個目標文件可以鏈接生成可執行文件或者共享庫。
可執行文件(executable file)。
共享庫(shared object file),擴展名是“.so”。
核心轉儲文件(core dump file)。
如圖2.14所示,ELF文件分成4個部分:ELF首部、程序首部表(program header table)、節(section)和節首部表(section header table)。實際上,一個文件不一定包含全部內容,而且它們的位置也不一定像圖2.14中這樣安排,只有ELF首部的位置是固定的,其余各部分的位置和大小由ELF首部的成員決定。
圖2.14 ELF文件的格式
程序首部表就是我們所說的段表(segment table),段(segment)是從運行的角度描述,節(section)是從鏈接的角度描述,一個段包含一個或多個節。在不會混淆的情況下,我們通常把節稱為段,例如代碼段(text section),不稱為代碼節。
32位ELF文件和64位ELF文件的差別很小,本書只介紹64位ELF文件的格式。
ELF首部的成員及說明如表2.4所示。
表2.4 ELF首部的成員及說明
程序首部表中每條表項的成員及說明如表2.5所示。
表2.5 程序首部表中每條表項的成員及說明
節首部表中每條表項的成員及說明如表2.6所示。
表2.6 節首部表中每條表項的成員及說明
重要的節及說明如表2.7所示。
表2.7 重要的節及說明
可以使用程序“readelf”查看ELF文件的信息。
1)查看ELF首部:readelf -h
2)查看程序首部表:readelf -l
3)查看節首部表:readelf -S
(2)代碼實現:內核中負責解析ELF程序的源文件,如表2.8所示。
表2.8 解析ELF程序的源文件
如圖2.15所示,源文件“fs/binfmt_elf.c”定義的函數load_elf_binary負責裝載ELF程序,主要步驟如下。
圖2.15 裝載ELF程序
1)檢查ELF首部。檢查前4字節是不是ELF魔幻數,檢查是不是可執行文件或者共享庫,檢查處理器架構。
2)讀取程序首部表。
3)在程序首部表中查找解釋器段,如果程序需要鏈接動態庫,那么存在解釋器段,從解釋器段讀取解釋器的文件名稱,打開文件,然后讀取ELF首部。
4)檢查解釋器的ELF首部,讀取解釋器的程序首部表。
5)調用函數flush_old_exec終止線程組中的所有其他線程,釋放舊的用戶虛擬地址空間,關閉那些設置了“執行execve時關閉”標志的文件。
6)調用函數setup_new_exec。函數setup_new_exec調用函數arch_pick_mmap_layout以設置內存映射的布局,在堆和棧之間有一個內存映射區域,傳統方案是內存映射區域向棧的方向擴展,另一種方案是內存映射區域向堆的方向擴展,從兩種方案中選擇一種。然后把進程的名稱設置為目標程序的名稱,設置用戶虛擬地址空間的大小。
7)以前調用函數bprm_mm_init創建了臨時的用戶棧,現在調用函數set_arg_pages把用戶棧定下來,更新用戶棧的標志位和訪問權限,把用戶棧移動到最終的位置,并且擴大用戶棧。
8)把所有可加載段映射到進程的虛擬地址空間。
9)調用函數setbrk把未初始化數據段映射到進程的用戶虛擬地址空間,并且設置堆的起始虛擬地址,然后調用函數padzero用零填充未初始化數據段。
10)得到程序的入口。如果程序有解釋器段,那么把解釋器程序中的所有可加載段映射到進程的用戶虛擬地址空間,程序入口是解釋器程序的入口,否則就是目標程序自身的入口。
11)調用函數create_elf_tables依次把傳遞ELF解釋器信息的輔助向量、環境指針數組envp、參數指針數組argv和參數個數argc壓到進程的用戶棧。
12)調用函數start_thread設置結構體pt_regs中的程序計數器和棧指針寄存器。當進程從用戶模式切換到內核模式時,內核把用戶模式的各種寄存器保存在內核棧底部的結構體pt_regs中。因為不同處理器架構的寄存器不同,所以各種處理器架構必須自定義結構體pt_regs和函數start_thread,ARM64架構定義的函數start_thread如下:
3.裝載腳本程序
腳本程序的主要特征是:前兩字節是“#!”,后面是解釋程序的名稱和參數。解釋程序用來解釋執行腳本程序。
如圖2.16所示,源文件“fs/binfmt_script.c”定義的函數load_script負責裝載腳本程序,主要步驟如下。
圖2.16 裝載腳本程序
(1)檢查前兩字節是不是腳本程序的標識符。
(2)解析出解釋程序的名稱和參數。
(3)從用戶棧刪除第一個參數,然后依次把腳本程序的文件名稱、傳給解釋程序的參數和解釋程序的名稱壓到用戶棧。
(4)調用函數open_exec打開解釋程序文件。
(5)調用函數prepare_binprm設置進程證書,然后讀取解釋程序文件的前128字節到緩沖區。
(6)調用函數search_binary_handler,嘗試注冊過的每種二進制格式的處理程序,直到某個處理程序識別解釋程序為止。
2.6 進程退出
進程退出分兩種情況:進程主動退出和終止進程。
Linux內核提供了以下兩個使進程主動退出的系統調用。
(1)exit用來使一個線程退出。
(2)Linux私有的系統調用exit_group用來使一個線程組的所有線程退出。
glibc庫封裝了庫函數exit、_exit和_Exit用來使一個進程退出,這些庫函數調用系統調用exit_group。庫函數exit和_exit的區別是exit會執行由進程使用atexit和on_exit注冊的函數。
注意:我們編寫用戶程序時調用的函數exit,是glibc庫的函數exit,不是系統調用exit。
終止進程是通過給進程發送信號實現的,Linux內核提供了發送信號的系統調用。
(1)kill用來發送信號給進程或者進程組。
(2)tkill用來發送信號給線程,參數tid是線程標識符。
(3)tgkill用來發送信號給線程,參數tgid是線程組標識符,參數tid是線程標識符。
tkill和tgkill是Linux私有的系統調用,tkill已經廢棄,被tgkill取代。
當進程退出的時候,根據父進程是否關注子進程退出事件,處理存在如下差異。
(1)如果父進程關注子進程退出事件,那么進程退出時釋放各種資源,只留下一個空的進程描述符,變成僵尸進程,發送信號SIGCHLD(CHLD是child的縮寫)通知父進程,父進程在查詢進程終止的原因以后回收子進程的進程描述符。
(2)如果父進程不關注子進程退出事件,那么進程退出時釋放各種資源,釋放進程描述符,自動消失。
進程默認關注子進程退出事件,如果不想關注,可以使用系統調用sigaction針對信號SIGCHLD設置標志SA_NOCLDWAIT(CLD是child的縮寫),以指示子進程退出時不要變成僵尸進程,或者設置忽略信號SIGCHLD。
怎么查詢子進程終止的原因?Linux內核提供了3個系統調用來等待子進程的狀態改變,狀態改變包括:子進程終止,信號SIGSTOP使子進程停止執行,或者信號SIGCONT使子進程繼續執行。這3個系統調用如下。
注意:wait4已經廢棄,新的程序應該使用waitpid和waitid。
子進程退出以后需要父進程回收進程描述符,如果父進程先退出,子進程成為“孤兒”,誰來為子進程回收進程描述符呢?父進程退出時需要給子進程尋找一個“領養者”,按照下面的順序選擇領養“孤兒”的進程。
(1)如果進程屬于一個線程組,且該線程組還有其他線程,那么選擇任意一個線程。
(2)選擇最親近的充當“替補領養者”的祖先進程。進程可以使用系統調用prctl(PR_SET_CHILD_SUBREAPER)把自己設置為“替補領養者”(subreaper)。
(3)選擇進程所屬的進程號命名空間中的1號進程。
2.6.1 線程組退出
系統調用exit_group實現線程組退出,執行流程如圖2.17所示,把主要工作委托給函數do_group_exit,執行流程如下。
圖2.17 線程組退出的執行流程
(1)如果線程組正在退出,那么從信號結構體的成員group_exit_code取出退出碼。
(2)如果線程組未處于正在退出的狀態,并且線程組至少有兩個線程,那么處理如下。
1)關中斷并申請鎖。
2)如果線程組正在退出,那么從信號結構體的成員group_exit_code取出退出碼。
3)如果線程組未處于正在退出的狀態,那么處理如下。
把退出碼保存在信號結構體的成員group_exit_code中,傳遞給其他線程。
給線程組設置正在退出的標志。
向線程組的其他線程發送殺死信號,然后喚醒線程,讓線程處理殺死信號。
4)釋放鎖并開中斷。
(3)當前線程調用函數do_exit以退出。
假設一個線程組有兩個線程,稱為線程1和線程2,線程1調用exit_group使線程組退出,線程1的執行過程如下。
(1)把退出碼保存在信號結構體的成員group_exit_code中,傳遞給線程2。
(2)給線程組設置正在退出的標志。
(3)向線程2發送殺死信號,然后喚醒線程2,讓線程2處理殺死信號。
(4)線程1調用函數do_exit以退出。
線程2退出的執行流程如圖2.18所示,線程2準備返回用戶模式的時候,發現收到了殺死信號,于是處理殺死信號,調用函數do_group_exit,函數do_group_exit的執行過程如下。
圖2.18 線程2退出的執行流程
(1)因為線程組處于正在退出的狀態,所以線程2從信號結構體的成員group_exit_code取出退出碼。
(2)線程2調用函數do_exit以退出。
線程2可能在以下3種情況下準備返回用戶模式。
(1)執行完系統調用。
(2)被中斷搶占,中斷處理程序執行完。
(3)執行指令時生成異常,異常處理程序執行完。
函數do_exit的執行過程如下。
(1)釋放各種資源,把資源對應的數據結構的引用計數減一,如果引用計數變成0,那么釋放數據結構。
(2)調用函數exit_notify,先為成為“孤兒”的子進程選擇“領養者”,然后把自己的死訊通知父進程。
(3)把進程狀態設置為死亡(TASK_DEAD)。
(4)最后一次調用函數__schedule以調度進程。
死亡進程最后一次調用函數__schedule調度進程時,進程調度器做了如下特殊處理。
第8行和第9行代碼,執行調度類的task_dead方法。
第11行代碼,如果結構體thread_info放在進程描述符里面,而不是放在內核棧的頂部,那么釋放進程的內核棧。
第12行代碼,把進程描述符的引用計數減1,如果引用計數變為0,那么釋放進程描述符。
2.6.2 終止進程
系統調用kill(源文件“kernel/signal.c”)負責向線程組或者進程組發送信號,執行流程如圖2.19所示。
(1)如果參數pid大于0,那么調用函數kill_pid_info來向線程pid所屬的線程組發送信號。
(2)如果參數pid等于0,那么向當前進程組發送信號。
(3)如果參數pid小于?1,那么向組長標識符為-pid的進程組發送信號。
(4)如果參數pid等于?1,那么向除了1號進程和當前線程組以外的所有線程組發送信號。
函數kill_pid_info負責向線程組發送信號,執行流程如圖2.20所示,函數check_kill_permission檢查當前進程是否有權限發送信號,函數__send_signal負責發送信號。
圖2.19 系統調用kill的執行流程
圖2.20 向線程組發送信號的執行流程
函數__send_signal的主要代碼如下:
第11~13行代碼,如果目標線程忽略信號,那么沒必要發送信號。
第15行代碼,確定把信號添加到哪個信號隊列和集合。線程組有一個共享的信號隊列和集合,每個線程有一個私有的信號隊列和集合。如果向線程組發送信號,那么應該把信號添加到線程組共享的信號隊列和集合中;如果向線程發送信號,那么應該把信號添加到線程私有的信號隊列和集合中。
第18行代碼,如果是傳統信號,并且信號集合已經包含同一個信號,那么沒必要重復發送信號。
第22~25行代碼,判斷分配信號隊列節點時是否可以忽略信號隊列長度的限制:對于傳統信號,如果是特殊的信號信息,或者信號的編碼大于0,那么允許忽略;如果是實時信號,那么不允許忽略。
第27行和第28行代碼,分配一個信號隊列節點。
第29行和第30行代碼,如果分配信號隊列節點成功,那么把它添加到信號隊列中。
第37行代碼,如果某個進程正在通過信號文件描述符(signalfd)監聽信號,那么通知進程。signalfd是進程創建用來接收信號的文件描述符,進程可以使用select或poll監聽信號文件描述符。
第38行代碼,把信號添加到信號集合中。
第39行代碼,調用函數complete_signal:如果向線程組發送信號,那么需要在線程組中查找一個沒有屏蔽信號的線程,喚醒它,讓它處理信號。
上一節已經介紹過,當線程準備從內核模式返回用戶模式時,檢查是否收到信號,如果收到信號,那么處理信號。
2.6.3 查詢子進程終止原因
系統調用waitid的原型如下:
參數idtype指定標識符類型,支持以下取值。
(1)P_ALL:表示等待任意子進程,忽略參數id。
(2)P_PID:表示等待進程號為id的子進程。
(3)P_PGID:表示等待進程組標識符是id的任意子進程。
參數options是選項,取值是0或者以下標志的組合。
(1)WEXITED:等待退出的子進程。
(2)WSTOPPED:等待收到信號SIGSTOP并停止執行的子進程。
(3)WCONTINUED:等待收到信號SIGCONT并繼續執行的子進程。
(4)WNOHANG:如果沒有子進程退出,立即返回。
(5)WNOWAIT:讓子進程處于僵尸狀態,以后可以再次查詢狀態信息。
系統調用waitpid的原型是:
系統調用wait4的原型是:
參數pid的取值如下。
(1)大于0,表示等待進程號為pid的子進程。
(2)等于0,表示等待和調用進程屬于同一個進程組的任意子進程。
(3)等于-1,表示等待任意子進程。
(4)小于-1,表示等待進程組標識符是pid的絕對值的任意子進程。
參數options是選項,取值是0或者以下標志的組合。
(1)WNOHANG:如果沒有子進程退出,立即返回。
(2)WUNTRACED:如果子進程停止執行,但是不被ptrace跟蹤,那么立即返回。
(3)WCONTINUED:等待收到信號SIGCONT并繼續執行的子進程。
以下選項是Linux私有的,和使用clone創建子進程一起使用。
(1)__WCLONE:只等待克隆的子進程。
(2)__WALL:等待所有子進程。
(3)__WNOTHREAD:不等待相同線程組中其他線程的子進程。
系統調用waitpid、waitid和wait4把主要工作委托給函數do_wait,函數do_wait的執行流程如圖2.21所示,遍歷當前線程組的每個線程,針對每個線程遍歷它的每個子進程,如果是僵尸進程,調用函數eligible_child來判斷是不是符合等待條件的子進程,如果符合等待條件,調用函數wait_task_zombie進行處理。
圖2.21 函數do_wait的執行流程
函數wait_task_zombie的執行流程如下。
(1)如果調用者沒有傳入標志WEXITED,說明調用者不想等待退出的子進程,那么直接返回。
(2)如果調用者傳入標志WNOWAIT,表示調用者想讓子進程處于僵尸狀態,以后可以再次查詢子進程的狀態信息,那么只讀取進程的狀態信息,從線程的成員exit_code讀取退出碼。
(3)如果調用者沒有傳入標志WNOWAIT,處理如下。
1)讀取進程的狀態信息。如果線程組處于正在退出的狀態,從線程組的信號結構體的成員group_exit_code讀取退出碼;如果只是一個線程退出,那么從線程的成員exit_code讀取退出碼。
2)把狀態切換到死亡,釋放進程描述符。
{-:-} 福利
本文轉載自異步社區
Linux 任務調度
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。