1. 程式人生 > >Linux記憶體管理之程序建立的寫時拷貝技術

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就會重新執行剛才導致異常的寫入操作指令,使程序能夠繼續執行下去