進程調度之3:系統調用fork、vfork與clone

date: 2014-10-22 17:40linux

1 原型

前文已大體講過fork、vfork與clone的區別,先來看看它們的原型:數組

pid_t  fork(void);
    pid_t  vfork(void);
    int clone(int (*fn)(void *), void *child_stack, int flags, void*  arg, ...);

fork是全面的複製。併發

vfork建立一個線程,但主要用做建立進程的中間步驟。既然新的進程最終要調用execve執行新的目標程序,並與父進程分道揚鑣,那麼複製父進程的資源徹底是多餘的,因而vfork便應運而生,其建立一個線程並經過指針拷貝共享父進程的資源(這些資源會在execve中被替換)。 vfork與fork的另外一個不一樣是,vfork會保證子進程先運行,在子進程執行execve或者exit以後,父進程纔可能被調度運行。函數

clone能夠用來建立一個線程,並能夠指定子線程的用戶空間堆棧的起始位置(child_stack參數)以及自線程的入口(參數fn)。同時clone也能夠用來建立一個進程,有選擇性的複製父進程的資源(由參數flag來指定)。atom

這幾個系統調用在內核中的實現以下(2.4.0內核)線程

<arch/kernel/process.c>
    asmlinkage int sys_fork(struct pt_regs regs)
    {
	    return do_fork(SIGCHLD, regs.esp, &regs, 0);
    }

    asmlinkage int sys_clone(struct pt_regs regs)
    {
	    unsigned long clone_flags;
	    unsigned long newsp;

	    clone_flags = regs.ebx;
	    newsp = regs.ecx;
	    if (!newsp)
		    newsp = regs.esp;
	    return do_fork(clone_flags, newsp, &regs, 0);
    }

    asmlinkage int sys_vfork(struct pt_regs regs)
    {
	    return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, &regs, 0);
    }

可見這幾個系統調用最終都是以不一樣的參數調用do_fork。這三個函數參數中的pt_regs結構是系統調用陷入內核空間後,對用戶空間中CPU各通用寄存器的備份。對sys_clone來講,regs.ebx保存的是clone的參數flag(flag是第三個參數,不是應該保存在edx中嗎?),regs.ecx保存的就是clone的參數child_task。指針

do_fork函數的原型爲:code

int do_fork(unsigned long clone_flags, unsigned long stack_start,
	            struct pt_regs *regs, unsigned long stack_size);

第一個參數clone_flags包含兩部分,第一部分爲其最低的字節(8bit)表示信號類型,表示子進程去世時內核應該給其父進程發什麼信號。fork和vfork設置的信號爲SIGCHILD,而clone則由調用者決定發什麼信號。blog

第二部分是一些表示資源或者特性的標誌位,這些標誌位定義在<include/linux/sched.h>文件中:進程

/*
     * cloning flags:
     */
    #define CSIGNAL		0x000000ff	/* signal mask to be sent at exit */
    #define CLONE_VM	0x00000100	/* set if VM shared between processes */
    #define CLONE_FS	0x00000200	/* set if fs info shared between processes */
    #define CLONE_FILES	 0x00000400	/* set if open files shared between processes */
    #define CLONE_SIGHAND	0x00000800	/* set if signal handlers and blocked signals shared */
    #define CLONE_PID	0x00001000	/* set if pid shared */
    #define CLONE_PTRACE	0x00002000	/* set if we want to let tracing continue on 
                                      the child too */
    #define CLONE_VFORK	 0x00004000	/* set if the parent wants the child to wake it 
                                      up on mm_release */
    #define CLONE_PARENT	0x00008000	/* set if we want to have the same parent as
                                      the cloner */
    #define CLONE_THREAD	0x00010000	/* Same thread group? */

    #define CLONE_SIGNAL	(CLONE_SIGHAND | CLONE_THREAD)

這裏的標誌位若是爲1則表示對應的資源經過指針拷貝(「淺拷貝」)的方式與父進程共享,反之若是爲0則要進行深度拷貝了。其中CLONE_PID有特殊的用途,它表示父子進程共用同一進程號PID,也就是說子進程雖然有其本身的task_struct結構,卻使用父進程的PID,可是隻有0號進程(0號進程不是init進程,init進程的進程號爲1,0號進程空轉進程即idle進程)被容許如此調用clone。

對於fork,這部分全爲0,表示全部的資源都要複製(「深拷貝」);而對vfork,其設置了CLONE_VFORK和CLONE_VM標誌,表示子進程(線程)共用父進程(用戶空間的)虛存空間,而且當子進程釋放其虛存區間時,要通知父進程;至於clone,這一部分也由調用者設定。

下面分析下do_fork的流程,若是讀者理解淺拷貝和深拷貝,那麼複製的過程很好理解。

2 do_fork流程

2.1 整體流程圖

do_fork流程

下面對流程中的重點函數進行詳細介紹。

2.2 get_pid

通常來說,將上一個進程的PID加1便可獲得新進程的PID,但系統中規定PID不能≥PID_MAX(PID_MAX 定義爲0x8000,可見最大的PID號爲0x7fff即32767)。若是PID≥PID_MAX,則要回過頭去「撿漏」:某些進程已經去世了,以前分配給他們的PID能夠從新利用了。注意撿漏要從PID爲300開始掃描,由於300之內(0~299)的PID保留給系統進程使用(包括內核線程和各「守護神」進程)。若是掃描只爲找到一個可用的PID,顯然不划算,最好是能找到一個可用PID的區間(而不只僅是找到第一個可用的PID)。這樣下一次再建立進程時,直接從可用PID區間中取下一個PID便可。

2.3 copy_files

task_struct結構有一個files成員,其爲struct files_struct類型的指針,指向進程打開的文件的信息,copy_files函數完成該指針的深度拷貝。files_struct結構體的定義以下:

/*
     * Open file table structure
     */
    struct files_struct {
    	atomic_t count;
    	rwlock_t file_lock;
    	int max_fds;
    	int max_fdset;
    	int next_fd;
    	struct file ** fd;	/* current fd array */
    	fd_set *close_on_exec;
    	fd_set *open_fds;
    	fd_set close_on_exec_init;
    	fd_set open_fds_init;
    	struct file * fd_array[NR_OPEN_DEFAULT];
    };

files_struct結構主要有三個部件:其一是個位圖(指針)close_on_exec,表示在進程執行exce時須要close的文件描述符,初始指向結構體中另外一個成員close_on_exec_init的地址;其二也是一個位圖(指針)open_fds,初始指向結構體中另外一成員open_fds_init的地址;其三爲指向文件表項(struct file)指針數組的指針fd,指向進程打開的文件表項數組,初始指向結構體中另外一成員fd_array的地址。因爲位圖(fd_set)可以表示最大文件描述符爲1024(fd_set共有1024個bit),而fd_array數組的大小固定爲NR_OPEN_DEFAULT,隨着程序的運行,進程打開的文件數可能超過NR_OPEN_DEFAULT,或者fd_set(1024個bit)已經沒法容納最大的文件描述符,這個時候就須要對這個三個部件進行擴展,而這三個指針則指向結構體外的某個地址。

若有須要(clone_flag中對應的標誌位爲0,下同),copy_files將執行「深度拷貝」,這裏「深度拷貝」主要是這三個部件的拷貝,在拷貝時要判斷父進程的三個部件是否已進行過擴展,若是是則子進程的三個部件也要進行擴展。

注意,這裏所謂的「深度拷貝」只是中間層次的「深度拷貝」。以fd爲例,fd爲指向指針數組的指針,拷貝以後子進程有本身的一塊存儲空間,用來存放全部的文件表項(struct file)的指針,可是並未執行更深層次的拷貝,並無對每一個文件表項指針所指向的文件表項(struct file)進行拷貝。父子進程每一個相同的文件描述符共享同一個文件表項 ,以下圖所示:

父子進程共享文件表項

可見,fork以後,當子進程經過lseek移動某個文件的「當前文件偏移量」時,因爲這個偏移量記錄在文件表項file中,父進程中對應文件的「當前文件偏移量」也會被更新,這就是在父子進程中同時往標準輸出中打印信息時不會重疊的緣由。罕見的,《情景分析》關於這一點的描述有誤。

2.4 copy_sighand

task_struct結構中有一sig成員,其爲struct signal_struct類型,後者的定義以下:

struct signal_struct {
	    atomic_t		count;
	    struct k_sigaction	action[_NSIG];
	    spinlock_t		siglock;
    };

其核心成員action爲一數組,指定了各個信號的處理方式。_NSIG即爲信號的最大值64。copy_sighand所執行的深度拷貝就是將action數組拷貝一份到子進程。

2.5 copy_mm

task_struct結構中有一個指針mm,相信你們已經很熟悉了,其爲mm_struct結構類型,表明了進程的用戶空間。mm_struct中用兩個重要的成員,其一爲vm_area_struct類型的指針mmap,表明着用戶空間的全部「虛存空間」;其二爲pgd,領銜着「虛存區間」的頁面映射。相應的,copy_mm所執行的深度拷貝也包括兩部分:「虛存區間」的拷貝和頁面表的拷貝。

注意,在拷貝頁表時,只是爲子進程的頁表分配了存儲空間,頁面表項(pte_t)的內容與父進程一致。這意味着針對每個頁面表項,子進程並無像咱們想象的那樣,從新申請一個page,從父進程那裏拷貝一個page的內容,而後將新page的地址和保護屬性寫入頁面表項。取而代之的作法是:子進程的頁面表項仍然指向父進程中的page,只是將這個page的保護屬性設置成「只讀」,並將保護屬性同時設置進父、子進程的頁面表項中。這樣的話,當無論父進程仍是子進程企圖寫入這個頁面時,都會觸發異常頁面異常,這種狀況下,頁面異常處理程序會從新分配一個page,並把內容複製過來,並更新父子進程的頁面表項(將保護屬性設置成可寫),讓父子進程擁有各自的物理page。這就是copy_on_write技術。linux之因此能快速的複製出一個進程,徹底歸功於copy on write技術(不然的話,在fork時,就得老老實實的分配一個一個的物理頁面,並將頁面的內容拷貝過來)。

2.6 copy_thread

咱們以前講過進程的系統空間爲兩個頁面即8K的空間,底端爲task_struct結構,task_struct結構之上爲系統空間堆棧。其實,在系統空間堆棧之上即8K空間的頂端,存放的是pt_regs結構,以下圖所示:

系統空間堆棧

pt_regs結構保存着進入內核前夕CPU各個寄存器的內容,這但是系統調用返回到用戶空間的重要「現場」,對於剛剛出生的子進程,這些信息只能從父進程拷貝而來,也正因如此,父子進程才能夠返回到用戶空間的同一個地方。

copy_thread的代碼比較有趣,咱們來看看:

<arch/kernel/process.c>
    
    529 int copy_thread(int nr, unsigned long clone_flags, unsigned long esp,
    530         unsigned long unused,
    531         struct task_struct * p, struct pt_regs * regs)
    532 {
    533         struct pt_regs * childregs;
    534
    535         childregs = ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p)) - 1;
    536         struct_cpy(childregs, regs);
    537         childregs->eax = 0;
    538         childregs->esp = esp;
    539
    540         p->thread.esp = (unsigned long) childregs;
    541         p->thread.esp0 = (unsigned long) (childregs+1);
    542
    543         p->thread.eip = (unsigned long) ret_from_fork;
    544
    545         savesegment(fs,p->thread.fs);
    546         savesegment(gs,p->thread.gs);
    547
    548         unlazy_fpu(current);
    549         struct_cpy(&p->thread.i387, &current->thread.i387);
    550
    551         return 0;
    552 }

參數中的p指向新建立的子進程;而regs則來自父進程;esp指向進程用戶空間的堆棧的棧頂,對fork與vfork而言,esp來自regs.esp即父進程的用戶空間地址的棧頂,對clone而言,棧頂esp可有調用者指定。

首先,要肯定在進程的系統空間中pt_regs結構的起始地址,這是535行的做用。逐步拆解以下:

  • (THREAD_SIZE + (unsigned long) p) 定位到子進程系統空間(8K)的上邊界;

  • ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p)) 將系統空間的上邊界強轉成struct pt_regs類型的指針;

  • ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p)) – 1,在struct pt_regs類型的指針類型的指針上執行減1操做,指針後移sizeof(struct pt_regs)個存儲單元,因而定位到pt_regs結構的起始地址。

其次,子進程的pt_regs拷貝自父進程(第536行),但也要進行些「修補」。第537行修改子進程在用戶空間的返回值爲0;第538行修改進程用戶空間的棧頂esp。

另外,task_struct結構中有一個thread成員,其爲struct thread_struct類型,裏面存放着進程在切換時系統空間堆棧的棧頂esp、下一條指令eip(進程再次被切換運行時,將從這裏開始運行)等關鍵信息。在複製task_struct結構時,這些內容原封不動從父進程拷貝過來,如今子進程有本身的系統空間堆棧了,因此要適當的加以調整。第540行將p->thread.esp設置成pt_regs結構的起始地址(注意堆棧是向下擴展的),從調度器的角度來看,就好像這個子進程之前曾經進入內核運行過,而在內核中的任務處理完畢(所以進程系統空間堆棧恢復平衡,變成「空」堆棧)準備返回用戶空間時被切換了;而p->thread.esp0則應指向系統空間堆棧的頂端,表示這個進程進入0級(內核空間)運行時,其堆棧的位置。第543行,p->thread.eip被賦值爲ret_from_fork,當子進程調度運行時(確定先從系統空間運行),將從ret_from_fork處開始運行。

2.7 vfork是如何保證子進程先運行的

前文提到,vfork會保證子進程先運行,在子進程執行execve或者exit以後,父進程纔可能被調度運行。爲了實現這個需求,task_struct結構中有一個信號量(struct semaphore)類型的指針vfork_sem。在do_fork函數開始位置,初始化了一個局部信號量sem,並將sem的地址賦給父進程的task_struct.vfork_sem(流程圖中黃色標註的部分)。信號量的初始計數爲0,意味着此時對信號量執行down操做,進程將被阻塞。do_fork在完成子進程的「複製」之後,調用wake_up_process喚醒子進程,而本身則調用down(&sem)進入睡眠。

在子進程調用exec或者exit時,都會調用mm_release,在該函數中,up(tsk->p_opptr->vfork_sem)增長了父進程vfork_sem的計數,因而父進程被喚醒。

void mm_release(void)
	{
		struct task_struct *tsk = current;
	
		/* notify parent sleeping on vfork() */
		if (tsk->flags & PF_VFORK) {
			tsk->flags &= ~PF_VFORK;
			up(tsk->p_opptr->vfork_sem);
		}
	}

仔細想來,vfork保證子進程先運行,不見得是內核「精心」爲linux用戶實現的特性,倒像是內核的無奈之舉。咱們知道vfork系統調用,父子進程共享進程用戶空間,子進程對用戶空間的修改會反過來影響父進程,反之亦然。若是說父子進程各自修改數據區的數據是危險的話,那麼修改堆棧那就是致命的了。而恰巧子進程緊接着就會調用exec(或者exit),而函數調用涉及到傳參,涉及到設置返回地址這些都會修改堆棧。因此決不能讓兩個進程都回到用戶空間併發的運行,必須扣留其中的一個,而只讓一個返回到用戶空間去運行,直到兩個進程再也不共享用戶空間(子進程執行exec)或者其中之一消亡(必然是先返回用戶空間的子進程)爲止。

即便如此,也仍是有危險,子進程絕對不能從調用vfork的那個函數(caller)中返回(好比vfork以後子進程調用return),由於函數返回時,棧會被恢復平衡,以前用來存放caller的函數參數和函數返回地址的內存單元被回收,以後,這些內存單元可能被用來存放其餘的內容。等到子進程去世父進程開始運行時,雖然父進程雖然有本身的esp(保存在pt_regs中),但棧中保存的caller的返回地址被破壞了(esp之上的內容都被破壞),這是很危險的。因此vfork其實是創建在子進程在建立後當即就會調用execve的前提之上的。

相關文章
相關標籤/搜索