說一說 Linux 進程控制
引言
在上一則發(fā)表的關于 Linux 的文章中,敘述了 Linux 的相關概念,其中就包括進程的資源,進程的狀態(tài),以及進程的屬性等相關內容,在本則教程中,將著重敘述 Linux 進程管理的內容,其中就包括 Linux 進程的創(chuàng)建,進程的終止,進程的等待相關內容。
Linux 進程的創(chuàng)建
函數(shù) fork
現(xiàn)有的一個進程可以調用 fork 函數(shù)創(chuàng)建一個新進程:
- #include <unistd.h>
- pid_t fork(void);
- /* 返回值:子進程返回 0,父進程返回子進程 ID;若出錯,返回 -1 */
由 fork 創(chuàng)建的新進程被稱為子進程。fork 函數(shù)被調用一次,但返回兩次。兩次返回的區(qū)別是子進程返回值是0,而父進程的返回值是新建子進程的進程 ID,子進程創(chuàng)建的過程大概是這樣的:從調用系統(tǒng)調用 fork 后就有了子進程,fork 創(chuàng)建子進程是以父進程為模板的、
下面是一個 fork 函數(shù)創(chuàng)建一個進程的例子:
- int main(int argc, char **argv)
- {
- printf("I am process!\r\n");
- pid_t id = fork();
- if (id < 0)
- {
- printf("fork error\r\n");
- }
- else if (id == 0)
- {
- printf("I am child process and myid is :%d, my parent id is :%d\r\n",getpid(),getppid());
- sleep(3);
- }
- else
- {
- printf("I am parent process and myid is:%d\r\n",getpid());
- sleep(3);
- }
- printf("Now you can see me!\r\n");
- sleep(3);
- return 0;
- }
下面是代碼的運行結果:
在使用 fork 創(chuàng)建子進程的時候,內核所做的工作是:
- 分配新的內存塊和描述進程的數(shù)據結構給子進程
- 將父進程部分數(shù)據結構內容拷貝到子進程
- 添加子子進程到系統(tǒng)進程列表中
- fork 返回,開始調度器調度
需要注意的是:fork 之前父進程獨立運行,fork 之后,父子兩個執(zhí)行流分別運行。且 fork 之后,由調度器決定運行順序
子進程獲得父進程數(shù)據空間、堆和棧的副本。需要注意的是,這是子進程所擁有的副本。父進程和子進程并不共享這些存儲空間部分,但是由于在 fork 之后經常跟隨著 exec,所以現(xiàn)在很多實現(xiàn)并不執(zhí)行一個父進程數(shù)據段、堆和棧的完全副本,作為替代,使用了寫時復制技術,這些區(qū)域由父進程和子進程共享,而且內核將他們的訪問權限改變?yōu)橹蛔x。
寫時復制原理
在講述寫時復制的原理之前,首先得弄明白虛擬內存和物理內存兩個概念:
- 物理內存:也就是相電腦的內存條,如果電腦安裝了 2GB 的內存條,那么系統(tǒng)就擁有 0~2GB 的物理內存空間。
- 虛擬內存:虛擬內存是使用軟件模擬的,例如在 32 位的操作系統(tǒng)下,那么每個進程都獨占 4GB 的虛擬內存空間
應用程序使用的是虛擬內存,而虛擬內存必須要映射到物理內存中才可以使用,如果沒有映射到虛擬內存地址,那么就會導致缺頁異常。下面是虛擬內存和物理內存映射時的一個示意圖:
通過上述的示意圖可以看出來,引入了虛擬內存的概念之后,兩個進程相同的虛擬內存地址能夠映射到不同的物理地址中。
在介紹了虛擬內存和物理內存之后,緊接著來介紹寫時復制的基本原理,在前面的介紹中,我們知道虛擬內存要能夠進行使用,必須映射到物理內存,如果不同進程的虛擬內存地址映射到相同的物理內存地址,那么就實現(xiàn)了共享內存機制。也就是如下圖所示:
通過上述的示意圖可以看出來,進程 A 的虛擬內存空間和進程 B 的虛擬內存空間映射到了一塊相同的物理內存地址中,所以呢,當修改進程 A 的虛擬內存空間的數(shù)據時,那么進程 B 虛擬內存的數(shù)據也會跟著改變。
依據這樣一個原理,實現(xiàn)了寫時復制的機制:
寫時復制的一個過程大致如下所示:
- 創(chuàng)建子進程時,將父進程的虛擬內存與物理內存映射關系復制到子進程,并將內存設置為只讀
- 當子進程或者父進程對內存數(shù)據進行修改的時候,便會觸發(fā)寫時復制機制,將原來的內存頁復制一份新的,并重新設置其內存映射關系,將父子進程的內存讀寫權限設置為可讀寫。
image-20210627103516488
但這個時候只能對內存進行讀操作,如果父進程或子進程對內存進行寫操作,那么將會觸發(fā) 缺頁異常,而在 缺頁異常 處理中會對物理內存進行復制,并且重新映射其內存映射關系,這也就是寫時復制的機制。
回過頭來,對于 fork 來講,有以下兩種用法:
- 一個父進程希望復制自己,使得父進程和子進程同時執(zhí)行不同的代碼段,這在網絡服務進程中是常見的,父進程等待客戶端的服務請求。當這種請求到達的時候,父進程調用 fork ,使子進程處理此請求。父進程則繼續(xù)等待下一服務請求。
- 一個進程要執(zhí)行一個不同的程序,在這種情況下,子進程調用 fork 返回后立即調用 exec 。
而調用 fork 失敗的原因主要是:
- 系統(tǒng)中已經有太多的進程了
- 該實際用戶 ID 的進程總數(shù)超過了系統(tǒng)限制
進程中止
進程有五種正常終止以及3種異常終止方式。首先敘述下5種正常的終止方式:
- 在 main 函數(shù)中執(zhí)行 return 語句,這等效于調用 exit。
- 調用 exit 函數(shù)
- 調用 _exit或 _Exit,對于 _Exit 來說,其目的是為進程提供一種無需運行終止處理程序或者信號處理程序而終止的方法。
- 進程的最后一個線程在啟動例程中執(zhí)行 return 語句。但是,該線程的返回值不用作進程的返回值。當最后一個線程從其啟動例程返回時,該進程以終止狀態(tài) 0 返回。
- 進程的最后一個線程調用 pthread_exit函數(shù),與前面一樣,進程的終止狀態(tài)總是 0。
三種異常終止具體如下:
- 調用 abort,產生 SIGABRT 信號,這是下一種異常終止的特例。
- 當進程收到某些信號時
- 最后一個進程對“取消”請求做出響應
不管進程如何終止,最后都會執(zhí)行內核中的同一段代碼。這段代碼為相應進程關閉所有打開描述符,釋放它所使用的存儲器。
函數(shù) wait 和 waitpid
調用 wait 和 waitpid 會發(fā)生如下幾件事:
- 如果所有子進程都還在運行,那么就阻塞
- 如果一個子進程已經中止,正等待父進程獲取其終止狀態(tài),則取得該子進程的終止狀態(tài)并返回
- 如果它沒有任何子進程,則立即出錯返回。
如果進程是在接受到 SIGABRT 信號而調用 wait ,我們期望 wait 會立即返回,但是如果是在隨機時間點調用 wait ,那么進程可能會阻塞。
下面是這兩個函數(shù)的原型:
- #include <sys/wait.h>
- pid_t wait(int *statloc);
- pid_t waitpid(pid_t pid,int *statloc,int options);
- /* 兩個函數(shù)返回值:若成功,則返回進程 ID;若失敗,則返回 0 或者 -1 */
除了這兩個函數(shù)之外,類似的調用還有其他的函數(shù),這里就不進行贅述了。
競爭條件
當多個進程都企圖對共享數(shù)據進行某種處理,而最后的結果又取決于進程運行的順序時,我們認為發(fā)生了競爭條件。如果在 fork 之后的某種邏輯顯示或隱式地依賴于在 fork 之后是父進程先運行還是子進程先運行,那么 fork 函數(shù)就會是競爭條件活躍的滋生地。
如果一個進程希望等待一個子進程終止,則它必須調用 wait 函數(shù)中的一個,如果一個進程要等待其父進程終止,則可以使用下列形式的循環(huán):
- while (getppid() != 1)
- sleep(1);
這種形式的循環(huán)稱為輪詢,它的問題是浪費了 CPU 時間,因為調用者每隔 1s 都被喚醒,然后進行條件測試,為了避免競爭條件和輪詢,在多個進程之間需要有某種形式的信號發(fā)送和接收的方法。詳細地在下次進行敘述。
函數(shù) exec
在使用了 fork 函數(shù)創(chuàng)建新的子進程后,子進程往往要調用一種 exec 函數(shù)以執(zhí)行另一個程序。當進程調用一種 exec 函數(shù)時,該進程執(zhí)行的程序完全替換為新程序。通俗地理解這句話,也就是說,在 Window 平臺下,我們可以通過雙擊運行可執(zhí)行程序,讓這個可執(zhí)行程序成為一個進程;然而在 Linux 平臺下,我們可以通過運行 ./,讓一個可執(zhí)行程序成為一個進程。
如果我們本來就運行著一個程序(進程),如何在這個進程內部啟動一個外部程序,由內核將這個外部程序讀入內存,使其執(zhí)行起來成為一個進程呢?這里通過 exec函數(shù)族來實現(xiàn)。
exec函數(shù)族,顧名思義,也就是一族函數(shù),在 Linux 中,也不存在著exec()函數(shù),exec指的是一組函數(shù) :
- #include <unistd.h>
- 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[]);
其中只有execve()是真正意義上的系統(tǒng)調用,其它都是在此基礎上經過包裝的庫函數(shù)。
進程調用一種 exec 函數(shù)時,該進程完全由新程序替換,而新程序則從其 main 函數(shù)開始執(zhí)行。因為調用 exec 并不創(chuàng)建新進程,所以前后的進程 ID (當然還有父進程號、進程組號、當前工作目錄……)并未改變。exec 只是用另一個新程序替換了當前進程的正文、數(shù)據、堆和棧段(進程替換)。
接下來舉一個例子,關于execl() 示例代碼:
- #include <stdio.h>
- #include <unistd.h>
- int main(int argc, char *argv[])
- {
- printf("before exec\n\n");
- /* /bin/ls:外部程序,這里是/bin目錄的 ls 可執(zhí)行程序,必須帶上路徑(相對或絕對)
- ls:沒有意義,如果需要給這個外部程序傳參,這里必須要寫上字符串,至于字符串內容任意
- -a,-l,-h:給外部程序 ls 傳的參數(shù)
- NULL:這個必須寫上,代表給外部程序 ls 傳參結束
- */
- execl("/bin/ls", "ls", "-a", "-l", "-h", NULL);
- // 如果 execl() 執(zhí)行成功,下面執(zhí)行不到,因為當前進程已經被執(zhí)行的 ls 替換了
- perror("execl");
- printf("after exec\n\n");
- return 0;
- }
下面是代碼執(zhí)行的結果:
小結
本次內容的分享就到這里了,主要是敘述了Linux進程管理的相關內容,其中就包括Linux進程創(chuàng)建,進程中止,進程等待等內容,在下一則內容中將著重分享進程間通信的相關內容,每周一篇,堅持呀~