📌 個人主頁: 孫同學_
🔧 文章專欄:Liunx
💡 關注我,分享經驗,助你少走彎路!
1. 進程創建
1.1 fork函數
在linux
中fork
函數是非常重要的函數,它從已存在進程中創建一個新進程。新進程為子進程,而原進程為父進程。
#include <unistd.h>
pid_t fork(void);
返回值:?進程中返回0,?進程返回?進程id,出錯返回-1
進程調用fork
,當控制轉移到內核中的fork代碼后,內核做:
- 分配新的內存塊和內核數據結構給子進程
- 將父進程部分數據結構內容拷貝至子進程
- 添加子進程到系統進程列表當中
fork
返回,開始調度器調度
當一個進程調用fork
之后,就有兩個二進制代碼相同的進程。而且它們都運行到相同的地方。但每個進程都將可以開始它們自己的旅程,看如下程序。
int main( void )
{pid_t pid;printf("Before: pid is %d\n", getpid());if ( (pid=fork()) == -1 )perror("fork()"),exit(1);printf("After:pid is %d, fork return %d\n", getpid(), pid);sleep(1);return 0;
}
運?結果:
[root@localhost linux]# ./a.out
Before: pid is 43676
After:pid is 43676, fork return 43677
After:pid is 43677, fork return 0
這里看到了三行輸出,一行before
,兩行after
。進程43676先打印before
消息,然后它有打印after
。另一個afte
r消息有43677打印的。注意到進程43677沒有打印before
,為什么呢?如下圖所示
所以,fork
之前父進程獨立執行,fork
之后,父子兩個執行流分別執行。
注意:fork
之后,誰先執行完全由調度器決定。
1.2 寫時拷貝
通常,父子代碼共享,父子再不寫入時,數據也是共享的,當任意一方試圖寫入,便以寫時拷備的方式各自一份副本。具體見下圖:
上圖顯示父進程代碼段在自己的頁表中是只讀的,包括我們以前定義的字符常量區,代碼是不可寫的,可是數據段為什么也是只讀的?起始在我們的父進程還沒創建子進程前,代碼段是只讀的沒問題,但是數據段對應的映射關系,可能有一百個一千個映射地址,這些映射地址的權限實際上是讀寫的,但一旦創建了子進程,操作系統就會把數據段的權限也改成只讀的。 然后后面的父子進程,比如說子進程嘗試對它的數據進行寫入,當它寫入時,操作系統就會發現你要訪問的數據,第一,數據是合法的,因為虛擬地址物理地址都有,而且它發現訪問的區域是數據段,如果是代碼段肯定在start_code
,end_code
這個區間里面,如果是數據段肯定在start_data
,start_end
這個區間里面,發現你是數據段,而且頁表的映射關系是正確的,但是發現數據段怎么是只讀的,所以這時候操作系統就會出錯,這種出錯不是真的錯了,是操作系統檢測到一個用戶對一個只讀的區域進行寫入,但操作系統經過檢查發現它是數據段,而且是子進程,這時候操作系統就會觸發寫時拷貝。寫時拷貝是通過設置頁表的權限,讓頁表讓操作系統出錯的行為。讓操作系統知道我們正在訪問一個只讀的區域,進而在錯誤的驅使之下讓操作系統完成對應的寫時拷貝這樣的任務。
因為有寫時拷貝技術的存在,所以父子進程得以徹底分離離!完成了進程獨立性的技術保證!
寫時拷貝,是一種延時申請技術,可以提高整機內存的使用率
為什么要寫時拷貝?
- 減少子進程的創建時間
- 減少內存浪費
1.3 fork調用失敗的原因
- 系統中有太多的進程
- 實際用戶的進程數超過了限制
2. 進程終止
進程終止的本質是釋放系統資源,就是釋放進程申請的相關內核數據結構和對應的數據和代碼。
2.1 進程退出場景
- 代碼運行完畢,結果正確
- 代碼運行完畢,結果不正確
- 代碼異常終止(退出碼無意義)
2.2 進程常見退出方式
正常終止(可以通過 echo $?
查看最近進程的退出碼):
- 從
main
返回(main
函數結束表示進程結束,其他函數只表示自己函數調用完成) - 調用
exit(status)
(任何地方調用exit
表示進程結束,并返回給父進程bash
子進程的退出碼) _exit
:終止一個調用進程(相當于誰調用它,它把誰“弄死”)
2.2.1 exit函數
#include <unistd.h>
void exit(int status);
exit
最后也會調用_exit
,但在調用_exit
之前,還做了其他工作:
- 執行用戶通過
atexit
或on_exit
定義的清理函數。 - 關閉所有打開的流,所有的緩存數據均被寫入
- 調用
_exit
2.2.2 _exit函數
#include <unistd.h>
void _exit(int status);
參數:status 定義了進程的終?狀態,?進程通過wait來獲取該值
說明:雖然
status
是int
,但是僅有低8位可以被父進程所用。所以_exit(-1)
時,在終端執行$?
發現返回值是255。
2.2.3 exit和_exit的區別:
exit
是c語言提供的,_exit
是系統提供的
進程如果exit
退出的時候,exit()
會進行緩沖區的刷新
進程如果exit
退出的時候,_exit
不會進行緩沖區的刷新
庫函數和系統調用是上下層的關系,庫函數沒有進程終止能力,只能調用系統調用,操作系統給它提供的進程終止的接口它才能終止進程,所以exit
的底層封裝了_exit
,所以我們之前談論的緩沖區一定不在操作系統的內部,而是庫緩沖區(c語言提供的緩沖區)
異常退出:
ctrl + c
,信號終止
2.2.4 退出碼
退出碼在Linux中通常用來表示命令執行后的結果,0
表示成功,非0
表示不同的錯誤類型
Linux Shell
中的主要退出碼:
退出碼 | 說明 |
---|---|
0 | 成功(命令正常執行) |
1 | 一般性錯誤(如參數錯誤、文件未找到、權限不足等) |
2 | Shell 內置命令誤用(如語法錯誤、未找到命令等) |
126 | 權限問題(命令不可執行,如缺少執行權限) |
127 | 命令未找到(Shell 找不到指定命令) |
130 | 進程被 Ctrl+C 終止(SIGINT 信號) |
141 | 進程被 SIGHUP 信號終止(如終端關閉) |
3. 進程等待
3.1 進程等待必要性
- 之前講過,子進程退出,父進程如果不管不顧,就可能造成僵尸進程的問題,進而造成內存泄漏。
- 進程一旦變成僵尸狀態,那就刀槍不入,“殺人不眨眼”的
kill-9
也無能為力,因為誰也沒有辦法殺死一個已經死去的進程。 - 最后,父進程派給子進程的任務完成的如何,我們需要知道。如,子進程運行完成,結果對還是不對,或者是否正常退出。
- 父進程通過進程等待的方式,回收子進程資源,獲取子進程退出信息
3.2 進程等待的方法
3.2.1 wait方法
如果等待子進程,子進程沒有退出,父進程就會阻塞在wait
調用處(相當于scanf)
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* status);
返回值:成功返回被等待進程pid,失敗返回-1。
參數:輸出型參數,獲取子進程退出狀態,不關?則可以設置成為NULL
3.2.2 waitpid方法
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:當正常返回的時候waitpid返回收集到的子進程的進程ID;如果設置了選項WNOHANG,?調?中waitpid發現沒有已退出的子進程可收集,則返回0;如果調?中出錯,則返回-1,這時errno會被設置成相應的值以指?錯誤所在;
參數:pid:Pid = -1,等待任?個子進程。與wait等效。Pid > 0.等待其進程ID與pid相等的?進程。status: 輸出型參數WIFEXITED(status): 若為正常終?子進程返回的狀態,則為真。(查看進程是否是正常退出)WEXITSTATUS(status)((status>>8)&0xFF): 若WIFEXITED?零,提取子進程退出碼。(查看進程的退出碼)options:默認為0,表?阻塞等待WNOHANG: 若pid指定的子進程沒有結束,則waitpid()函數返回0,不予以等
待。若正常結束,則返回該子進程的ID。
- 如果子進程已經退出,調用
wait/waitpid
時,wait/waitpid
會立即返回,并且釋放資源,獲得子進程退出信息。 - 如果在任意時刻調用
wait/waitpid
,子進程存在且正常運行,則進程可能阻塞。 - 如果不存在該子進程,則立即出錯返回。
3.2.3 獲取子進程status
wait
和waitpid
,都有一個status
參數,該參數是一個輸出型參數,由操作系統填充。- 如果傳遞NULL,表示不關心子進程的退出狀態信息。
- 否則,操作系統會根據該參數,將子進程的退出信息反饋給父進程。
status
不能簡單的當作整形來看待,可以當作位圖來看待,具體細節如下圖(只研究status
低16比特位):
3.2.4 阻塞與非阻塞等待
- 進程的阻塞等待方式:
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int main(void)
{pid_t pid;if ((pid = fork()) == -1)perror("fork"), exit(1);if (pid == 0) {sleep(20);exit(10);}else {int st;int ret = wait(&st);if (ret > 0 && (st & 0X7F) == 0) { // 正常退出 printf("child exit code:%d\n", (st >> 8) & 0XFF);}else if (ret > 0) { // 異常退出 printf("sig code : %d\n", st & 0X7F);}}
}
測試結果:
# ./a.out #等20秒退出
child exit code : 10
# ./a.out #在其他終端kill掉
sig code : 9
- 進程的非阻塞等待方式:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
typedef void (*handler_t)(); // 函數指針類型
std::vector<handler_t> handlers; // 函數指針數組
void fun_one() {printf("這是?個臨時任務1\n");
}
void fun_two() {printf("這是?個臨時任務2\n");
}
void Load() {handlers.push_back(fun_one);handlers.push_back(fun_two);
}
void handler() {if (handlers.empty())Load();for (auto iter : handlers)iter();
}
int main() {pid_t pid;pid = fork();if (pid < 0) {printf("%s fork error\n", __FUNCTION__);return 1;}else if (pid == 0) { // childprintf("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");}handler();} while (ret == 0);if (WIFEXITED(status) && ret == pid) {printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));}else {printf("wait child failed, return.\n");return 1;}}return 0;
}
4. 進程替換
我們先來看一段代碼:
上面這種現象就叫做程序替換,也就是我自己的程序把系統當中的指令跑起來了。在程序替換的時候,并沒有創建新的進程,只是把當前進程的代碼和數據用新的進程的代碼和數據覆蓋式的進行替換。
- 問題一:“為什么我的程序運行結束了”,這段話沒有在顯示器上打印出來?
答案是一旦程序替換成功就去執行新代碼了,原始代碼的后半部分已經不存在了
- 有沒有辦法讓后面的代碼能繼續執行?
答案是有的,創建一個子進程,讓子進程去做替換工作,讓父進程繼續執行后面的代碼。
效果演示:
📌Tips:程序替換也能替換我們自己寫的程序,就相當于一種加載器,可以加載各種程序,包括編譯型的解釋型的,程序替換本質上不會創建新的進程
為什么不會影響父進程呢?
a.進程具有獨立性 b.數據和代碼發生寫時拷貝
execl
的返回值
execl
函數只有失敗返回值,沒有成功返回值
💦結論:exec*
系列的函數,不用做返回值判定,只要返回,就是失敗
4.1 替換原理
4.2 替換函數
-
int execl(const char *path,const char *arg,...)
,第一個參數表示程序+路徑名
,第二個參數有個口訣:命令行怎么寫,我們就怎么傳(當然傳-al
也是可以的),而把參數一個一個的傳進來我們稱之為list
,類似于以鏈表的形式傳給它,所以execl
這個l
就是llist
的意思,execl
函數的最后一個參數必須以NULL
結尾,表明參數傳遞完成 -
int execlp(const char *file,cont char *arg,...)
,execlp
當中的p
表示PATH
,所以第一個參數只需傳要執行的程序名就行了,因為execlp
會自動的在環境變量(PATH)里查找對應的命令,所以execlp
一般執行系統級的命令。后面參數的傳遞和上面的相同
-
int execv(const char *path,char *const argv[])
,首先它沒有帶p
所以它的參數是path
,所以同上上,這里的v
就相當于vector
,所以第二個參數就以數組的形式呈現了,所以就必須提供一個命令行參數表,就是指針數組,就是把ls -a -l
整體放在數組里,一次性傳遞,這個表也必須以NULL
結尾。所以我們以前執行的所有命令行參數都是父進程通過execv傳給子進程的
-
int execvp(const char *path,char *const argv[])
,有p
所以不用帶路徑
-
int execvpe(const *file,char *const argv[],char *const envp[])
這里的v
表示以數組的方式傳進來,p
表示不用帶路徑,e
表示環境變量,如果非要傳遞環境變量列表,要求:被替換的子進程使用全新的Env列表(自己寫的)
若要以新增的方式傳遞環境列表呢?
?putenv
表示哪個進程調用它,就在誰的環境變量表里新增一個環境變量(B是A的子進程,C是B的子進程,如果B在它的環境列表里導入了一個新的環境變量,A的環境列表里看不到,而C的環境列表里能看到)
?如果我們就行用execvpe
的方式呢?environ
表示把新增的環境變量添加到環境變量表里面去,然后把環境變量表的起始地址傳給execvpe
-
int execle(const *path,const *arg,...,char * const envp[])
總結:
這些函數原型看起來很容易混,但只要掌握了規律就很好記。
l(list)
:表示參數采用列表v(vector)
:參數用數組p(path)
:有p自動搜索環境變量PATHe(env)
:表示自己維護環境變量
上面這些函數都是對系統調用進行了語言型的封裝,最后都要調用系統調用execve
,為什么要做語言封裝呢?因為程序替換時要面對各種各樣上層替換的場景。所以execve
在man手冊第2節
下圖exec
函數簇一個完整的例子:
👍 如果對你有幫助,歡迎:
- 點贊 ??
- 收藏 📌
- 關注 🔔