Linux內存管理之進程創建的寫時拷貝技術

Unix的進程創建很特別。許多其他的操作系統都提供了產生進程的機制,首先在新的地址空間創建進程,讀入可執行的文件,最後開始執行。Unix採用了與衆不同的實現方式,它把上述步驟分解到兩個單獨的函數中去執行:fork()和exec()。(這裏的exec是指exec一族的函數,內核實現了execve函數,在此基礎上還實現了execlp、execle、execv和execvp等)。首先fork通過拷貝當前進程創建一個子進程。子進程與父進程的區別僅僅在於PID(每個進程是唯一的)、PPID(父進程的進程號,子進程將其設置爲被拷貝進程的PID)和某些資源和統計量(例如,掛起的信號,它沒有必要被繼承)。exec函數負責讀取可執行文件並將其載入地址空間開始運行。


上面我們先簡單的述說了linux內核產生進程的機制,在述說寫時拷貝技術之前,下面我們先詳細說明下fork和exec都做了哪些事情:

或許許多朋友可能對fork和exec的調用比較模糊的,學過c語言的都知道,Linux下某個進程的地址空間分爲:代碼段、數據段、堆空間和棧空間。代碼段是用來存放運行的代碼的,數據段是用來存放全局變量的和static變量,堆空間是進程調用malloc分配地址空間的,棧是用來存放程序的臨時變量的。


fork()函數:

linux通過clone系統調用實現fork。這個調用通過一系列的參數標誌來指明父、子進程需要共享的資源。fork和vfork和__clone庫函數都是根據各自需要的參數標誌去調用clone,然後clone去調用do_fork:

do_fork完成了創建中的大部分工作,它定義在kernel/fork.c文件中。該函數調用copy_process函數,然後讓進程開始運行。copy_process函數完成的工作如下:

(1)調用dup_task_struct爲新進程創建一個內核棧、thread_info結構和task_struct,這些值與當前進程的值相同。此時,子進程和父進程的描述符完全相同。

(2)檢查並確保新創建這個子進程後,當前用戶所擁有的進程數目沒有超出給它分配的資源的限制

(3)子進程着手使自己與父進程區別開來。進程描述符內的許多成員都要被清0或設爲初始值。那些不是繼承而來的進程描述符成員,主要是統計信息。task_struct中的大多數數據都依然未被修改

(4)子進程的狀態設置爲TASK_UNINTERRUPTIBLE,以保證它不會投入運行

(5)copy_process調用copy_flag以更新task_struct的flags成員。表明進程是否擁有超級用戶權限的PF_SUPERPRIV標誌被清0.表用進程還沒有調用exec函數PF_FORKNOEXEC標誌被設置

(6)調用alloc_pid爲新進程分配一個有效的PID

(7)根據傳遞給clone的參數標誌,copy_process拷貝或共享打開的文件、文件系統信息、信號處理函數、進程地址空間和命名空間等。在一般情況下,這些資源會被給定進程的所有進程共享;否則,這些資源對每個進程是不同的,因此被拷貝到這裏。

(8)最後,copy_process做掃尾工作並返回一個指向子進程的指針。

再回到do_fork函數,如果copy_process函數成功返回,新創建的子進程被喚醒並讓其投入運行。內核有意選擇子進程首先執行。因爲一般子進程都會馬上調用exec函數,這樣可以避免寫時拷貝的額外開銷,如果父進程首先執行的話,有可能會開始向地址空間寫入(這個相關內容後續會進一步說明)

exec( )函數:

exec函數在當前進程的上下文中加載並運行一個新的程序,只有在出錯的情況下exec 函數纔會返回到調用程序中,所以與fork函數調用一次返回兩次不同,exec調用一次並從不返回

exec( )執行過程:

1)刪除當前進程虛擬地址空間的用戶部門已經存在的區域結構。

2)加載可執行文件,用可執行文件中的內容覆蓋當前進程地址空間相應區域

3)設置程序計數器即eip中的值,使它指向新的代碼區的入口點,調用啓動代碼,啓動代碼設置棧,控制傳給新程序的主函數

其中加載可執行文件的執行過程如下:

1)啓動加載器,加載器刪除子進程現有的虛擬地址段

2)加載器根據可執行目標文件中的段頭部表信息,創建一組新的代碼段、數據段、堆段和棧段。新的堆、棧段初始化爲零。代碼段和數據段映射爲可執行文件的代碼段和數據段。

3)根據可執行文件 ELF 中的.interp段查找動態鏈接器ld.so的路徑名,動態鏈接器實際上也是一個共享對象,加載器同樣通過映射的方式將它加載到進程的地址空間。然後把控制權交給動態鏈接器的入口地址(與可執行文件一樣,共享對象也有入口地址),當動態鏈接器得到控制權後,進行一系列初始化操作,然後根據可執行文件ELF中.dynamic段,這個段裏保存了動態鏈接器所需要的相關信息,比如依賴於哪些共享對象(例如libc.so)、動態鏈接符號表位置、動態鏈接重定位表的位置、共享對象初始化代碼的地址等信息,根據它們查找和加載可執行文件所依賴的共享對象,並映射到進程地址空間的共享區域中。

4)當所有動態鏈接工作完成以後,動態連接器會將控制權交給可執行文件的入口地址,即跳轉到可執行文件的_start 啓動代碼並調用新程序中的main函數開始執行。

task_struct進程控制塊,ELF文件格式與進程地址空間的聯繫:

task_struct進程控制塊中的mm字段所指向的mm_struct結構描述了進程地址空間的信息,包括代碼段、數據段、堆段、棧段所在地址空間裏的起始和結束地址等信息。

ELF文件格式中的 ELF頭部、段頭部表、.init、.text、.rodata段對應進程地址空間中的代碼段,在加載可執行文件時,會把它們映射到進程地址空間中的代碼段區域。

ELF文件格式中的 .data、.bss段 對應 進程地址空間中的 數據段,在加載可執行文件時,會把它們映射到進程地址空間的數據段區域。

講完exec的執行過程大家可能就更容易理解了,一個進程一旦調用exec類函數,它本身就“死亡”了,系統把代碼段替換成新的程序的代碼,廢棄原有的數據段和堆棧段,併爲新程序分配新的數據段和堆棧段,唯一留下的,就是進程號,也就是說,對系統而言,還是同一個進程,不過已經是另外一個程序了

COW技術:

在Linux程序中,fork()會產生一個和父進程完全相同的子進程,但子進程在此後多會exec系統調用,出於效率考慮,linux中引入了“寫時複製“技術,也就是隻有進程空間的各段的內容要發生變化時,纔會將父進程的內容複製一份給子進程。

那麼子進程的物理空間沒有代碼,怎麼去取指令執行exec系統調用呢?

在fork之後exec之前兩個進程用的是相同的物理空間(內存區),子進程的代碼段、數據段、堆棧都是指向父進程的物理空間,也就是說,兩者的虛擬空間不同,但其對應的物理空間是同一個。當父子進程中有更改相應段的行爲發生時,再爲子進程相應的段分配物理空間,如果不是因爲exec,內核會給子進程的數據段、堆棧段分配相應的物理空間(至此兩者有各自的進程空間,互不影響),而代碼段繼續共享父進程的物理空間(兩者的代碼完全相同)。而如果是因爲exec,由於兩者執行的代碼不同,子進程的代碼段也會分配單獨的物理空間(exec相關內容請參考上面)。

還有個細節問題就是,fork之後內核會通過將子進程放在隊列的前面,以讓子進程先執行,以免父進程執行導致寫時複製,而後子進程執行exec系統調用,因無意義的複製而造成效率的下降。因爲如果讓父進程先執行的話,那麼會進行寫時拷貝,也就是爲子進程分配了相應的數據段、堆棧段的物理空間,如果再執行exec的話,又會爲新的程序分配新的數據段、堆棧段等,這樣fork函數的執行效率就會降低)    

爲了節約物理內存,在調用fork生成新進程時,新進程與原進程會共享同一內存區。只有當其中一進程進行寫操作時,系統纔會爲其另外分配內存頁面。這就是寫時拷貝(copy on write)的概念的引出。

當進程A使用系統調用fork創建一個子進程B時,由於子進程B實際上是父進程A的一個拷貝,因此會擁有與父進程相同的物理頁面。也即爲了達到節約內存和加快創建速度的目標,fork函數會讓子進程B以只讀的方式共享父進程A的物理頁面。同時將父進程A對這些物理頁面的訪問權限也設置成只讀。這樣一來當父進程A或者子進程B任何一方對這些以共享的物理頁面執行寫操作時,都會產生頁面出錯異常中斷,此時cpu會執行系統提供的異常處理函數do_wp_page來試圖解決這個異常。

do_wp_page會對這塊導致寫入異常中斷的物理頁面進行取消共享操作(使用un_up_page),爲寫進程複製一新的物理頁面,使父進程A和子進程B各自擁有一塊內容相同的物理頁面。這時才真正地執行了複製操作(只複製這一塊物理頁面)。並且將要執行寫入操作的這塊物理頁面標記成可以寫訪問的。最後從異常處理函數中返回,cpu就會重新執行剛纔導致異常的寫入操作指令,使進程能夠繼續執行下去


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章