進程創建
在上一節講解進程概念時,我們提到fork函數是從已經存在的進程中創建一個新進程。那么,系統是如何創建一個新進程的呢?這就需要我們更深入的剖析fork函數。
1.1 fork函數的返回值
調用fork創建進程時,原進程為父進程,新進程為子進程。運行man fork
后,我們可以看到如下信息:
#include <unistd.h>
pid_t fork(void);
fork函數有兩個返回值,子進程中返回0,父進程返回子進程pid,如果創建失敗則返回-1。
實際上,當我們調用fork后,系統內核將會做:
- 分配新的內存塊和內核數據結構(如task_struct)給子進程
- 將父進程的部分數據結構內容拷貝至子進程
- 添加子進程到系統進程列表中
- fork返回,開始調度
1.2 寫時拷貝
在創建進程的過程中,默認情況下,父子進程共享代碼,但是數據是各自私有一份的。如果父子只需要對數據進行讀取,那么大多數的數據是不需要私有的。這里有三點需要注意:
第一,為什么子進程也會從fork之后開始執行?
因為父子進程是共享代碼的,在給子進程創建PCB時,子進程PCB中的大多數數據是父進程的拷貝,這里面就包括了程序計數器(PC)。由於PC中的數據是即將執行的下一條指令的地址,所以當fork返回之后,子進程會和父進程一樣,都執行fork之后的代碼。
第二,創建進程時,子進程需要拷貝父進程所有的數據嗎?
父進程的數據有很多,但並不是所有的數據都要立馬使用,因此並不是所有的數據都進行拷貝。一般情況下,只有當父進程或者子進程對某些數據進行寫操作時,操作系統才會從內存中申請內存塊,將新的數據拷寫入申請的內存塊中,並且更改頁表對應的頁表項,這就是寫時拷貝。原理如下圖所示:
第三,為什么數據要各自私有?
這是因為進程具有獨立性,每個進程的運行不能干擾彼此。
1.3 fork函數的用法及其調用失敗的原因
fork函數的用法:
- 一個父進程希望復制自己,通過條件判斷,使父子進程分流同時執行不同的代碼段。例如,父進程等待客戶端請求,生成子 進程來處理請求。
- 如子進程從fork返回后,調用進程替換的函數,如exec等(將會在本節4.程序替換中講解)。
fork函數調用失敗的原因:
- 系統中進程太多
- 實際用戶的進程數超過了限制
2.進程終止
2.1 進程終止的原因
進程終止的原因有三種
- 代碼運行完畢,結果正確
- 代碼運行完畢,結果不正確
- 代碼異常終止
2.2 常見的進程退出方法
進程正常終止
1.從main函數return,這是最常見的進程退出方法。在函數設計中,0代表正確,非0代表錯誤。其中不同的非0的退出碼對應了退出原因。
2.調用exit或者_exit
_exit函數是系統調用,執行man _exit
可以看到
#include <unistd.h>
void _exit(int status);
status 定義了進程的終止狀態。父進程可以通過wait來獲得子進程的status(會在3.進程等待中講解)。
需要注意的是,
exit函數是庫函數,雖然status是int,但是僅有低8位可以被父進程所用。所以_exit(-1)時,在終端執行echo $?發現返回值 是255。
#include <stdlib.h>
void exit(int status);
從作用上來看,_exit和exit是相似的,exit是對_exit的封裝,exit的執行實際上是通過調用_exit來實現的。
但是二者也有一些細微的差別,請看如下代碼段:
代碼1
int main()
{
printf("Hello world");
exit(0);
}
代碼2
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("Hello world");
_exit(0); }
相比於_exit函數,exit函數先要執行用戶定義的清理函數,在沖刷緩沖區,關閉所有打開的流,將所有的緩存數據寫入文件后,再調用_exit。因此我們可以看到,執行exit輸出了“hello World",而執行_exit並沒有輸出。
那么,return和exit有什么區別呢?
在普通函數中,return是用來終止函數的,只有在main函數中才是終止進程,而exit無論在哪里,一旦調用,整個進程就會終止。
3.進程等待
3.1 為什么要有進程等待?
在講進程概念時我們提到,當子進程退出,父進程如果不管不顧,子進程殘留資源(PCB)存放於內核中,就可能會造成僵屍進程。如果該資源不能得到釋放,就會導致內存泄漏。僵屍進程是不能使用 kill -9 命令清除掉的。因為 kill 命令只是用來終止進程的, 而僵屍進程已經終止。
同時,父進程派給子進程的任務完成的如何,我們是需要知道的。例如,子進程運行完成,結果對還是不對, 或者是否正常退出。
因此,就需要父進程通過進程等待的方式,回收子進程的資源。
3.2 進程等待的方法
一個進程在終止時會關閉所有文件,釋放在用戶空間分配的內存,但它的 PCB 還保留着,內核在其中保存了一些信息:如果是正常終止則保存着退出狀態,如果是異常終止則保存着導致該進程終止的信號是哪個。當這個進程的父進程調用 wait 或 waitpid 獲取這些信息后,才會將這個進程徹底清除掉。
一個進程的退出狀態可以在 Shell 中通過運行echo $?
查看,因為 Shell 是它的父進程,當它終止時 Shell 調用 wait 或 waitpid 得到它的退出 狀態同時徹底清除掉這個進程。
3.2.1 wait函數
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
- 返回值:成功返回被等待進程pid,失敗返回-1。
- status:是一個輸出型參數,將wait函數內部計算的結果通過status返回給調用者,父進程從而獲取子進程退出狀態,如果不關心子進程的退出狀態則可以將參數設置成為NULL。
這里提一下輸入型參數和輸出型參數的區別,輸入型參數是調用者給函數傳的參數,而輸出型參數是是函數將內部計算結果返回給調用者,因此輸出型參數往往用指針。
父進程調用 wait 函數可以回收子進程終止信息。該函數有三個功能:
- 阻塞等待子進程退出
- 回收子進程殘留資源
- 獲取子進程結束狀態(退出原因)。
當父進程調用wait得到傳出參數status后,可以借助宏函數來進一步判斷進程終止的具體原因:
WIFEXITED(status): 若為正常終止子進程返回的狀態,則為真。(查看進程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,說明子進程正常終止,提取子進程退出碼。(查看進程的退出碼(exit 的參數))
3.2.2 waitpid函數
作用同 wait,但waitpid可指定 pid 進程清理,可以通過非阻塞方式等待子進程退出。
pid_ t waitpid(pid_t pid, int *status, int options);
pid:
- pid = -1,等待任一子進程退出,此時與wait等效
- pid > 0, 回收指定 ID 的子進程,pid為指定進程的進程號。如果不存在該子進程,則立即出錯返回
status:
- 同wait
option:
- 0:阻塞模式,即父進程會阻塞在waitpid處,等到子進程退出后繼續。
- WNOHANG: 非阻塞模式,若pid指定的子進程沒有結束,則waitpid函數返回0,不予以等待。若正常結束,則返回該子進程的ID。一般情況下,非阻塞模式需要搭配循環使用。
注意:一次 wait 或 waitpid 調用只能清理一個子進程,清理多個子進程應使用循環。
返回值:
- 當正常返回的時候waitpid返回收集到的子進程的進程ID;
- 如果設置了選項WNOHANG,而調用中waitpid發現沒有已退出的子進程可收集,則返回0;
- 如果調用中出錯,則返回-1,這時errno會被設置成相應的值以指示錯誤所在
3.3.3 子進程的status
關於status的用法,我已經在wait函數處講解,此處不再贅述。這里將從底層的角度剖析status的含義。
status不能簡單的當作整形來看待,可以當作位圖來看待,具體細節如下圖(只研究status低16比特位)。
我們以下一段代碼為例,來展示一下非阻塞等待方式
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t pid;
pid = fork();
if(pid < 0){
printf("%s fork error\n",__FUNCTION__);
return 1;
}else if( pid == 0 ){ //child
printf("child is run, pid is : %d\n",getpid());
sleep(5);
exit(1);
} else{
int status = 0;
pid_t ret = 0;
do
{
ret = waitpid(-1, &status, WNOHANG);//非阻塞式等待
if( ret == 0 ){
printf("child is running\n");
}
sleep(1);
}while(ret == 0);
if( WIFEXITED(status) && ret == pid ){
printf("wait child 5s success, child return codeis:%d.\n",WEXITSTATUS(status));
}else{
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
這段代碼先創建子進程,讓子進程等待5s再退出,父進程每1s檢查一下,5s后子進程退出,ret將變成子進程的進程號,退出循環等待。最終的運行結果如下:
4.進程替換
4.1進程替換的原理
在講進程替換原理前,我們需要先知道什么是進程替換。在講fork函數時我們提到,fork 創建子進程后執行的是和父進程相同的程序(但有可能執行不同的代碼分支),如果此時我們用一個新的程序替換掉子進程的地址空間、代碼段和數據,子進程將會從新程序的啟動例程開始執行,這就是進程替換。
進程替換並不是創建新的進程,因為替換前后該進程的PID並未改變。
4.2 環境變量
進程替換需要用到一種exec函數,在講exec函數族之前,我們先介紹一下環境變量的概念。
4.2.1常見的環境變量
按照慣例,環境變量字符串都是name=value 這樣的形式,大多數 name 由大寫字母加下划線組成,一般把name 的部分叫做環境變量,value 的部分則是環境變量的值。
環境變量定義了進程的運行環境,具有全局屬性,因此設置環境變量時要加export,一些比較重要的環境變量的含義如下:
PATH
可執行文件的搜索路徑。ls 命令也是一個程序,執行它不需要提供完整的路徑名/bin/ls, 然而通常我們執行當前目錄下的程序 a.out 卻需要提供完整的路徑名./a.out,這是因為 PATH 環境變量的值里面包含了 ls 命令所在的目錄/bin,卻不包含 a.out 所在的目錄。
PATH 環境變量的值可以包含多個目錄,用:號隔開。在 Shell 中用 echo 命令可以查看這個環境變量的值: echo $PATH
SHELL
當前 Shell,它的值通常是/bin/bash。
TERM
當前終端類型
HOME
當前用戶主目錄的路徑,很多程序需要在主目錄下保存配置文件,使得每個用戶在運行該程序時都有自己的一套配置。
4.2.2與環境變量相關的函數
getenv函數
獲取環境變量值: char *getenv(const char *name)
;
成功:返回環境變量的值;失敗:NULL (name 不存在)
setenv 函數
設置環境變量的值 :int setenv(const char *name, const char *value, int overwrite)
;
成功:返回0;失敗: 返回-1
參數 overwrite 取值:
1:覆蓋原環境變量
0:不覆蓋。(該參數常用於設置新環境變量,如:HELLO = “hello”)
unsetenv 函數
刪除環境變量 name 的定義: int unsetenv(const char *name)
;
成功:0;失敗:-1
注意事項:name 不存在仍返回 0(成功)。
4.2.3 環境變量的組織形式
environ 變量是一個char * 類型,存儲着系統的環境變量。*每個程序都會收到一張環境表,環境表是一個字符指針數組,每個指針指向一個以’\0’結尾的環境字符串。
4.3 exec函數族
4.3.1 exec函數族的使用
知道了環境變量的概念后,再簡要介紹一下命令行參數。當我們在某個目錄下輸入ls -a
和ls -l
時,會有如下顯示:
我們發現,同樣的ls命令,由於后面所跟的字符串不同,顯示了不同的結果。這里的“-a”,“-l”被稱為參數。實際上,一個程序內可以通過加入參數,讓相同的程序執行不同的功能。
接下來我們來介紹進程替換必不可少的函數族——exec函數族。
其實有六種以 exec 開頭的函數,統稱 exec 函數:
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
注意:這些函數如果調用成功則加載新的程序從啟動代碼開始執行,不再返回。 如果調用出錯則返回-1 所以exec函數只有出錯的返回值而沒有成功的返回值!
這些函數如何使用,我們來看下面這段代碼:
#include <unistd.h>
int main()
{
char *const argv[] = {"ps", "-ef", NULL};//argv[0]始終是程序名
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
//execl("/bin/ps", "ps", "-ef", NULL);
// 帶p的,可以使用環境變量PATH,無需寫全路徑
//execlp("ps", "ps", "-ef", NULL);
// 帶e的,需要自己組裝環境變量
//execle("ps", "ps", "-ef", NULL, envp);
//execv("/bin/ps", argv);
// 帶p的,可以使用環境變量PATH,無需寫全路徑
//execvp("ps", argv);
// 帶e的,需要自己組裝環境變量
execve("/bin/ps", argv, envp);
exit(0);
}
事實上,只有execve是真正的系統調用,其它五個函數最終都調用 execve。
這些函數原型看起來很容易混,但只要掌握了規律就很好記。
- l(list) : 表示參數采用列表,如果采用列表形式,const char *arg中的第一個參數必須是可執行程序本身,如上例中的 “ps”。
- v(vector) : 參數用數組 ,v和l只能二選一
- e(env) : 表示自己維護環境變量,有e參數中就需要有char *const envp[]
- p(path) : 有p自動搜索環境變量PATH,第一個參數直接輸入程序名即可,且有p一定沒有e,因為有表示已經自動添加了環境變量,如果沒有p則需要輸入對應程序的路徑
4.3.2 進程替換的應用
我們平時使用的shell讀取命令和分析命令就是一個很典型的例子,如下圖所示:
我們平時輸入的如ls -a
等命令實際上是一個個可執行程序。當shell讀取一行命令時,shell會對命令進行解析,並且shell創建一個子進程,再通過調用execve,用可執行程序替換掉子進程,當程序執行完畢並且退出后,shell讀取子進程的退出信息。這樣,即便會出現程序崩潰的情況,也不會影響到shell本身。
以上就是關於進程控制的內容,主要分為四個方面——進程創建,進程終止,進程等待以及進程替換。有了以上的知識,我們已經可以實現一個很簡易的shell,如何實現,請讀者自行思考!