進程的概述
進程的概念
直觀點說,保存在硬盤上的程序運行以後,會在內存空間裏形成一個獨立的內存體,這個內存體有自己的地址空間,有自己的堆,上級掛靠單位是操作系統。操作系統會以進程爲單位,分配系統資源,所以我們也說,進程是資源分配的最小單位。
進程調度中的三種狀態
- 運行:當一個進程在處理機上運行時,則稱該進程處於運行狀態。處於此狀態的進程的數目小於等於處理器的數目,對於單處理機系統,處於運行狀態的進程只有一個。在沒有其他進程可以執行時(如所有進程都在阻塞狀態),通常會自動執行系統的空閒進程。
- 就緒:當一個進程獲得了除處理機以外的一切所需資源,一旦得到處理機即可運行,則稱此進程處於就緒狀態。就緒進程可以按多個優先級來劃分隊列。例如,當一個進程由於時間片用完而進入就緒狀態時,排入低優先級隊列;當進程由I/O操作完成而進入就緒狀態時,排入高優先級隊列。
- 阻塞:也稱爲等待或睡眠狀態,一個進程正在等待某一事件發生(例如請求I/O而等待I/O完成等)而暫時停止運行,這時即使把處理機分配給進程也無法運行,故稱該進程處於阻塞狀態。
UNIX中的進程控制
創建新進程
1.進程標識符
每個進程都有一個非負整形表示唯一的ID,因此用來作爲進程的標識符。unix系統的ID爲0的進程通常是調度進程,常常被稱爲交換進程。ID爲1的進程爲init進程,在自舉結束的時候由內核調用。init進程絕不會終止,雖然它是一個用戶進程,但是它以超級用戶的特權運行,後面會介紹init對於孤兒進程的領養。
以下方法來獲得標識符
pid_t getpid(); //返回調用進程的pid
pid_t getppid(); //返回調用進程的父進程pid
2.創建新進程
創建進程可以通過下面兩個函數進行創建
pid_t fork(); //子進程中返回0,父進程中返回子進程pid
pid_t vfork(); //子進程中返回0,父進程中返回子進程pid
兩者是有區別的,這就要從創建一個進程的過程中對於父進程相關環境的處理說起了。
兩個函數都是調用一次之後會返回兩次,對於子進程而言返回值是0,在父進程中又返回的是子進程的pid。這樣做的原因也比較好理解:父進程擁有很多子進程,但是沒有函數可以獲得子進程的pid,所以在分配的時候就必須將子進程的ID進行記錄。
兩者的不同在於:
- fork之後,子進程是父進程的副本,子進程獲得父進程數據空間、堆和棧的拷貝。父子進程之間並不共享這些存儲空間部分,但是父子進程共享正文段。但是現在很多采用了寫時複製技術,也就是說fork之後父子進程還是先共享這些存儲空間部分,只有當子進程試圖去修改的時候纔會拷貝一份。
- vfork的設計本身就是爲了創建進程之後讓進程立馬調用exec去執行一個新程序,因此vfork之後子進程與父進程還是共享着存儲空間,在調用exec之前,子進程還是在父進程的空間中運行。除此之外,vfork和fork的另一個不同之處在於vfork之後,保證子進程先執行
下面的程序揭示了兩者之間的區別
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int glob = 6;
char buf[] = "a write to stdout\n";
int main()
{
int var;
pid_t pid;
var = 88;
if(write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf)-1);
exit(-1);
printf("before fork\n");
if((pid = fork()) < 0)
{
printf("fork error\n");
exit(-1);
}
else if(pid == 0)
{
//child
glob++;
var++;
}
else
{
//parent
//父進程休眠2s,讓子進程先執行
sleep(2);
}
printf("pid = %d, glob = %d, bar = %d\n", getpid(), glob, var);
exit(0);
}
執行程序的結果如下:
./a.out
a write to stdout
before fork
pid = 430, glob = 7, var = 89
pid = 429, glob = 6, var = 88./a.out > tmp.txt //將標準輸出定位到tmp.txt中,這時爲全緩衝
cat tmp.txt
a write to stdout
before fork
pid = 432, glob = 7, var = 89
before fork
pid = 431, glob = 6, var = 88
這裏之所以會出現兩次before fork是因爲標準I/O
當輸出定位到終端設備的時候是行緩衝,因此在fork之前,該緩衝碰到了換行符,已經進行了沖洗,所以不會再被子進程所拷貝,但是當輸出定位到文件的時候,會變爲全緩衝而被子進程拷貝。
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int glob = 6;
int main()
{
int var;
pid_t pid;
var = 88;
printf("before fork\n");
if((pid = vfork()) < 0)
{
printf("vfork error\n");
exit(-1);
}
else if(pid == 0)
{
//child
glob++;
var++;
_exit(0);
}
/*
* 父進程可以執行到這裏
* /
printf("pid = %d, glob = %d, bar = %d\n", getpid(), glob, var);
exit(0);
}
運行結果如下:
before fork
pid = 29039, glob = 7, var = 89
從上面可以看到,子進程中對於變量的修改在父進程中是可見的。
3.父子進程的文件共享
每一個進程都有一個文件表項,表示打開的文件及狀態
創建子進程之後,子進程會複製父進程的所有的打開文件描述符,這樣的話就會出現父子進程使用了同一個文件偏移量,
因此父子進程在處理文件的時候就要注意順序的保持。
進程結束
exit函數
進程有以下5中正常種植方式
main
函數中調用執行return
語句,等效於調用exit- 調用
exit
,其操作包括調用終止處理程序,關閉所有標準I/O流 - 調用
_exit()
或_Exit()
,並不清洗I/O流 - 進程中的最後一個線程在其啓動例程中執行返回語句
進程中最後一個線程執行
pthread_exit
函數除此之外,進程還有異常退出的情況。
對於任何一種退出情況,我們都希望父進程能知道子進程是如何結束的,對於三個終止函數(
exit、 _exit和_Exit
),它們將退出狀態 作爲參數返回給父進程,而異常退出的情況,內核產生終止狀態
父進程獲得子進程的終止狀態
父進程利用wait或者waitpid
來獲得子進程的終止狀態或者退出狀態
父進程調用這兩個函數可能會出現下面的情況:
- 如果其所有子進程都還在運行,則父進程阻塞,等待一個子進程終止
- 如果一個子進程已經終止,正等待父進程獲取其終止狀態,則取得子進程的終止狀態並立即返回
- 如果沒有任何子進程,則返回出錯。
pid_t wait(int *status)
pid_t waitpid(pid_t pid, int *status, int options)
//成功則返回進程ID,失敗返回-1
這兩個函數有所區別:
- 在一個子進程終止之前,wait將其調用者阻塞,而waitpid有一個選項可使調用者不阻塞
waitpid並不等待在其調用之後的第一個終止進程,它有pid參數,用來指定等待某個進程結束。
下面的程序用來演示不同的exit值
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid;
int status;
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0) /* child */
exit(7);
if(wait(&status) != pid) /* wait for child */
{
printf("wait error");
exit(-1);
}
printf("%d", status);
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0) /* child */
abort(); /* generate SIGABRT */
if(wait(&status) != pid) /* wait for child */
{
printf("wait error");
exit(-1);
}
printf("%d", status);
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0) /* child */
status /= 0; /* divide by 0 generate SIGFPE */
if(wait(&status) != pid) /* wait for child */
{
printf("wait error");
exit(-1);
}
printf("%d", status);
exit(0);
}
殭屍進程和孤兒進程
殭屍進程和孤兒進程的概念
我們知道在unix/linux中,正常情況下,子進程是通過父進程創建的,子進程在創建新的進程。子進程的結束和父進程的運行是一個異步過程,即父進程永遠無法預測子進程 到底什麼時候結束。 當一個 進程完成它的工作終止之後,它的父進程需要調用wait()或者waitpid()系統調用取得子進程的終止狀態。
孤兒進程:一個父進程退出,而它的一個或多個子進程還在運行,那麼那些子進程將成爲孤兒進程。孤兒進程將被init進程(進程號爲1)所收養,並由init進程對它們完成狀態收集工作。
殭屍進程:一個進程使用fork創建子進程,如果子進程退出,而父進程並沒有調用wait或waitpid獲取子進程的狀態信息,那麼子進程的進程描述符仍然保存在系統中。這種進程稱之爲僵死進程。
殭屍進程和孤兒進程的危害
unix提供了一種機制可以保證只要父進程想知道子進程結束時的狀態信息, 就可以得到。這種機制就是: 在每個進程退出的時候,內核釋放該進程所有的資源,包括打開的文件,佔用的內存等。 但是仍然爲其保留一定的信息(包括進程號the process ID,退出狀態the termination status of the process,運行時間the amount of CPU time taken by the process等)。直到父進程通過wait / waitpid
來取時才釋放。 但這樣就導致了問題,如果進程不調用wait / waitpid
的話, 那麼保留的那段信息就不會釋放,其進程號就會一直被佔用,但是系統所能使用的進程號是有限的,如果大量的產生僵死進程,將因爲沒有可用的進程號而導致系統不能產生新的進程. 此即爲殭屍進程的危害,應當避免。
孤兒進程是沒有父進程的進程,孤兒進程這個重任就落到了init進程身上,init進程就好像是一個民政局,專門負責處理孤兒進程的善後工作。每當出現一個孤兒進程的時候,內核就把孤 兒進程的父進程設置爲init,而init進程會循環地wait()
它的已經退出的子進程。這樣,當一個孤兒進程淒涼地結束了其生命週期的時候,init進程就會代表黨和政府出面處理它的一切善後工作。因此孤兒進程並不會有什麼危害。
任何一個子進程(init除外)在exit()
之後,並非馬上就消失掉,而是留下一個稱爲殭屍進程(Zombie)的數據結構,等待父進程處理。這是每個子進程在結束時都要經過的階段。如果子進程在exit()
之後,父進程沒有來得及處理,這時用ps命令就能看到子進程的狀態是“Z”。如果父進程能及時 處理,可能用ps命令就來不及看到子進程的殭屍狀態,但這並不等於子進程不經過殭屍狀態。 如果父進程在子進程結束之前退出,則子進程將由init接管。init將會以父進程的身份對殭屍狀態的子進程進行處理。
殭屍進程危害場景:
例如有個進程,它定期的產 生一個子進程,這個子進程需要做的事情很少,做完它該做的事情之後就退出了,因此這個子進程的生命週期很短,但是,父進程只管生成新的子進程,至於子進程 退出之後的事情,則一概不聞不問,這樣,系統運行上一段時間之後,系統中就會存在很多的僵死進程,倘若用ps命令查看的話,就會看到很多狀態爲Z的進程。 嚴格地來說,僵死進程並不是問題的根源,罪魁禍首是產生出大量僵死進程的那個父進程。因此,當我們尋求如何消滅系統中大量的僵死進程時,答案就是把產生大 量僵死進程的那個元兇槍斃掉(也就是通過kill發送SIGTERM或者SIGKILL信號啦)。槍斃了元兇進程之後,它產生的僵死進程就變成了孤兒進 程,這些孤兒進程會被init進程接管,init進程會wait()
這些孤兒進程,釋放它們佔用的系統進程表中的資源,這樣,這些已經僵死的孤兒進程 就能瞑目而去了。
殭屍進程的避免
通過fork兩次來避免殭屍進程的產生
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
int main()
{
pid_t pid;
//創建第一個子進程
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
//第一個子進程
else if (pid == 0)
{
//子進程再創建子進程
printf("I am the first child process.pid:%d\tppid:%d\n",getpid(),getppid());
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
//第一個子進程退出
else if (pid >0)
{
printf("first procee is exited.\n");
exit(0);
}
//第二個子進程
//睡眠3s保證第一個子進程退出,這樣第二個子進程的父親就是init進程裏
sleep(3);
printf("I am the second child process.pid: %d\tppid:%d\n",getpid(),getppid());
exit(0);
}
//父進程處理第一個子進程退出
if (waitpid(pid, NULL, 0) != pid)
{
perror("waitepid error:");
exit(1);
}
exit(0);
return 0;
}
子進程執行程序
fork函數執行之後,子進程往往需要調用一種exec函數執行另外的程序。
其中execve()
是內核實現的函數,其他函數都是通過調用這個函數實現的。
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */); (char *)0 爲可選參數
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, ... /* (char *)0 , char *const envp[] */); (char *)0和char *const envp[]爲可選參數
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */);
int execvp(const char *filename, char *const argv[]);
filename中包含 / 的話就是路徑名,不然在就PATH環境變量中查找函數中所指定的文件。
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
char *env_init[] = {"USER=unknown", "PATH=/tmp", NULL};
int main()
{
pid_t pid;
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0)
{
if(execle("/home/sar/bin/echoall", "echoall", "myarg1", "myarg2", (char *)0, env_init) < 0)
{
printf("execle error");
exit(-1);
}
}
if(waitpid(pid, NULL, 0) < 0)
{
printf("waitpid error");
exit(-1);
}
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0)
{
if(execlp("echoall", "echoall", "only 1 arg", (char *)0) < 0)
{
printf("execlp error");
exit(-1);
}
}
exit(0);
}
Linux內核中的進程管理
進程描述符——task_struct
內核將進程的列表放在任務隊列(雙向循環列表)中,列表中元素類型爲task_struct
(進程描述符),下面是其中的片段
struct task_struct {
volatile long state;
void *stack;
unsigned int flags;
int prio, static_prio;
struct list_head tasks;
struct mm_struct *mm, *active_mm;
pid_t pid;
pid_t tgid;
struct task_struct *real_parent;
char comm[TASK_COMM_LEN];
struct files_struct *files;
...
};
在結構體中可以看到幾個預料之中的項,比如執行的狀態、堆棧、一組標誌、父進程、執行的線程(可以有很多)以及開放文件。
state
變量是一些表明任務狀態的比特位。最常見的狀態有:TASK_RUNNING
表示進程正在運行,或是排在運行隊列中正要運行;TASK_INTERRUPTIBLE
表示進程正在休眠、TASK_UNINTERRUPTIBLE
表示進程正在休眠但不能叫醒;TASK_STOPPED
表示進程停止等等。
flags
定義了很多指示符,表明進程是否正在被創建(PF_STARTING
)或退出(PF_EXITING
),或是進程當前是否在分配內存(PF_MEMALLOC
)。
每個進程都會被賦予優先級(static_prio
),但進程的實際優先級是基於加載以及其他幾個因素動態決定的。優先級值越低,實際的優先級越高。
tasks
字段提供了鏈接列表的能力。它包含一個 prev
指針(指向前一個任務)和一個 next
指針(指向下一個任務)。
進程的地址空間由 mm
和 active_mm
字段表示。mm
代表的是進程的內存描述符,而 active_mm
則是前一個進程的內存描述符(爲改進上下文切換時間的一種優化)。
在Linux2.6版本之前,各個進程的task_struct
放在它們內核棧的尾端,這樣的好處是可以直接利用棧指針就可以訪問task_struct
而避免了利用寄存器存儲task_struct
地址。2.6版本以後,在棧底(向下增長的棧)或棧頂(向上增長的棧)(不論怎麼樣,還是在內核棧的尾端)創建新的結構體thread_info
。而通過棧指針訪問thread_info
,再通過task_struct
在其中的偏移找到task_struct
。
Linux進程創建
Linux以及其他Unix系統的進程創建很特別。其他很多操作系統都提供了產生進程的機制,首先在新的地址空間創建進程,讀入可執行文件,最後開始執行。但是Unix系統將這些步驟分開爲fork()
和exec()
兩部分。
寫時拷貝(copy-on-write)
正如前面所說,現在內核在fork之後不會完全將父進程的資源賦值給子進程,而是採用寫時拷貝的技術,也就是說fork之後父子進程還是先是以只讀方式共享父進程的地址空間,只有當子進程試圖去修改的時候纔會拷貝相應的資源,這樣fork的實際開銷就是複製父進程的頁表以及給子進程創建唯一的進程描述符。
fork的過程
Linux系統通過clone()
系統調用實現fork()
。這個調用可以通過參數來指明父子進程需要共享的資源,這個特徵也是後面Linux實現線程的基礎。fork()
、vfork()
和__clone()
都是通過各自需要共享所代表的參數調用clone()
,然後由clone()
去調用do_fork()
。
do_fork()
完成創建的大部分工作,該函數又調用copy_process()
,然後讓程序開始執行。調用的層次結構如下。
copy_process()
函數主要按下面的步驟進行:
1)調用dup_task_struct()
爲新進程分配內核棧、thread_info
結構以及task_struct
,這些值與當前進程的值相同。此時子進程與父進程的描述符是完全相同的。
2)檢查並確保新進程創建這個子進程之後,當前用戶所用的進程數目沒有超出它所分配的資源的限制。
3)子進程開始着手將自己和父進程區分開來,進程描述符內的很多成員都要被清0,或設爲初始值,但是還是有很多成員沒有修改。
4)子進程的狀態state
被修改爲TASK_UNINTERRUPTIBLE
(即使有信息喚醒該進程,也不會響應),以保證它不會投入運行。
5)copy_process()
調用copy_flags()
以更新task_struct
的flags
成員。表明是否有超級用戶權限的PF_SUPERPRIV
被清0,表明進程還沒有被exec()
函數調用的PF_FORKNOEXEC
標誌位被設置。
6)調用alloc_pid()
爲新進程分配一個有效地pid
。
7)根據clone()
傳進來的參數,copy_process()
拷貝或共享打開的文件、文件系統信息、信號處理函數、進程地址空間等。
8)最後copy_process()
收尾並返貨一個指向子進程的指針。
Linux進程結束
進程退出
進程結束的時候,內核必須釋放它所佔用的資源,並將這個消息告知父進程。一般進程的析構是自身引起的。調用exit()
後,大部分的工作由do_exit()
處理。
1)將task_struct
中的flags
設爲PF_EXITING
2)調用del_timer_sync()
刪除任一內核計數器。根據返回的結果,確保沒有定時器在排隊,也沒有定時器處理程序在運行。
3)如果BSD的記賬功能開啓,調用acct_update_intrgrals()
來輸出記賬信息。
4)調用exit_mm()
函數釋放進程佔用的mm_struct
(進程用戶空間結構),如果沒有別的進程共享,則徹底釋放。
5)調用sem_exit()
函數。如果進程排隊等候IPC信號,則裏考隊列。
6)調用exit_files()
和exit_fs()
以分別遞減文件描述符和文件系統數據的引用計數。如果引用計數爲0,證明沒有進程使用,則徹底釋放。
7)將存放在task_struct
的exit_code
成員中的任務退出碼設置爲由exit()
提供的退出代碼,或者去完成任何其他由內核提供的退出動作。退出代碼放在這裏由父進程進行檢索
8)調用exit_notify()
向父進程發送信號,給子進程重新找養父,養父爲線程組中的其他線程或者爲init進程,並將exit_state
設置爲EXIT_ZOMBIE
。
9)do_exit()
調用schedule()
切換到新進程,因爲處於EXIT_ZOMBIE
的進程不會被調度,因此do_exit()
不返回。
至此,進程的資源都被釋放了,剩下的就剩內核棧,thread_info
和tast_struct
結構,要告知父進程來處理這些結構,不然就成殭屍進程了。
父進程回收退出進程的剩餘結構
上面說了,調用了do_exit()
之後,儘管進程已經僵死不能再運行了,但是系統還保留了它的進程描述符。需要報告父進程讓父進程來回收。wait()
一族的函數都使調用wait4()
系統調用來實現的。它的準動作是掛起調用它的進程,知道其中一個子進程退出,此時函數會返回該子進程的PID。此外,調用該函數時提供的指針會包含子進程退出時的退出碼。
釋放進程描述符的時候,release_task()
被調用:
1)從pidhash
上刪除該進程,同時從任務列表中刪除該進程。
2)釋放目前殭屍進程所使用的所有資源,並進行最後的統計和記錄。
3)如果這個進程是進程組的最後一個進程,並且領頭進程已經四地哦啊,則通知僵死的零頭進程的父進程。
4)釋放進程內核棧和thread_info
結構所佔的頁,並釋放task_struct
所佔的slab高速緩存。
參考
1.《UNIX環境高級編程》
2.《Linux內核設計與實現》