Linux系統編程之進程控制(進程創建、終止、等待及替換)


進程創建

在上一節講解進程概念時,我們提到fork函數是從已經存在的進程中創建一個新進程。那么,系統是如何創建一個新進程的呢?這就需要我們更深入的剖析fork函數。

1.1 fork函數的返回值

調用fork創建進程時,原進程為父進程,新進程為子進程。運行man fork后,我們可以看到如下信息:

#include <unistd.h>
pid_t fork(void);

fork函數有兩個返回值,子進程中返回0,父進程返回子進程pid,如果創建失敗則返回-1。

實際上,當我們調用fork后,系統內核將會做:

  • 分配新的內存塊和內核數據結構(如task_struct)給子進程
  • 將父進程的部分數據結構內容拷貝至子進程
  • 添加子進程到系統進程列表中
  • fork返回,開始調度

image-20210815112339172

1.2 寫時拷貝

在創建進程的過程中,默認情況下,父子進程共享代碼,但是數據是各自私有一份的。如果父子只需要對數據進行讀取,那么大多數的數據是不需要私有的。這里有三點需要注意:

第一,為什么子進程也會從fork之后開始執行?

因為父子進程是共享代碼的,在給子進程創建PCB時,子進程PCB中的大多數數據是父進程的拷貝,這里面就包括了程序計數器(PC)。由於PC中的數據是即將執行的下一條指令的地址,所以當fork返回之后,子進程會和父進程一樣,都執行fork之后的代碼。

第二,創建進程時,子進程需要拷貝父進程所有的數據嗎?

父進程的數據有很多,但並不是所有的數據都要立馬使用,因此並不是所有的數據都進行拷貝。一般情況下,只有當父進程或者子進程對某些數據進行寫操作時,操作系統才會從內存中申請內存塊,將新的數據拷寫入申請的內存塊中,並且更改頁表對應的頁表項,這就是寫時拷貝。原理如下圖所示:

image-20210815120742835

第三,為什么數據要各自私有?

這是因為進程具有獨立性,每個進程的運行不能干擾彼此。

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);
}

image-20210815172538765

代碼2

 #include<stdio.h>  
 #include<unistd.h>  
 int main()  
 {  
   printf("Hello world");  
    _exit(0);                                                  }  

image-20210815172816988

相比於_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比特位)。

image-20210815211524186

我們以下一段代碼為例,來展示一下非阻塞等待方式

#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將變成子進程的進程號,退出循環等待。最終的運行結果如下:

image-20210815213529132

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

image-20210815233743515

SHELL

當前 Shell,它的值通常是/bin/bash。

image-20210815233908961

TERM

當前終端類型

image-20210815234151056

HOME

當前用戶主目錄的路徑,很多程序需要在主目錄下保存配置文件,使得每個用戶在運行該程序時都有自己的一套配置。

image-20210815234257823

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 環境變量的組織形式

image-20210815235403900

environ 變量是一個char * 類型,存儲着系統的環境變量。*每個程序都會收到一張環境表,環境表是一個字符指針數組,每個指針指向一個以’\0’結尾的環境字符串。

4.3 exec函數族

4.3.1 exec函數族的使用

知道了環境變量的概念后,再簡要介紹一下命令行參數。當我們在某個目錄下輸入ls -als -l時,會有如下顯示:

image-20210816003940801

我們發現,同樣的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讀取命令和分析命令就是一個很典型的例子,如下圖所示:

image-20210816011210421

我們平時輸入的如ls -a等命令實際上是一個個可執行程序。當shell讀取一行命令時,shell會對命令進行解析,並且shell創建一個子進程,再通過調用execve,用可執行程序替換掉子進程,當程序執行完畢並且退出后,shell讀取子進程的退出信息。這樣,即便會出現程序崩潰的情況,也不會影響到shell本身。

以上就是關於進程控制的內容,主要分為四個方面——進程創建,進程終止,進程等待以及進程替換。有了以上的知識,我們已經可以實現一個很簡易的shell,如何實現,請讀者自行思考!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM