1. 程式人生 > >寫時復制技術

寫時復制技術

tro 註意 區分 com shell 而後 順序 str2 對象

寫時復制技術最初產生於Unix系統,用於實現一種傻瓜式的進程創建:當發出fork( )系統調用時,內核原樣復制父進程的整個地址空間並把復制的那一份分配給子進程。這種行為是非常耗時的,因為它需要:

· 為子進程的頁表分配頁面

· 為子進程的頁分配頁面

· 初始化子進程的頁表

· 把父進程的頁復制到子進程相應的頁中

創建一個地址空間的這種方法涉及許多內存訪問,消耗許多CPU周期,並且完全破壞了高速緩存中的內容。在大多數情況下,這樣做常常是毫無意義的,因為許多子進程通過裝入一個新的程序開始它們的執行,這樣就完全丟棄了所繼承的地址空間。

現在的Unix內核(包括Linux),采用一種更為有效的方法稱之為寫時復制(或COW)。這種思想相當簡單:父進程和子進程共享頁面而不是復制頁面。然而,只要頁面被共享,它們就不能被修改。無論父進程和子進程何時試圖寫一個共享的頁面,就產生一個錯誤,這時內核就把這個頁復制到一個新的頁面中並標記為可寫。原來的頁面仍然是寫保護的:當其它進程試圖寫入時,內核檢查寫進程是否是這個頁面的唯一屬主;如果是,它把這個頁面標記為對這個進程是可寫的。

1. Linux的fork()使用寫時復制

傳統的fork()系統調用直接把所有的資源復制給新創建的進程。這種實現過於簡單並且效率低下,因為它拷貝的數據或許可以共享(This approach is significantly na?ve and inefficient in that it copies much data that might otherwise be shared.)。更糟糕的是,如果新進程打算立即執行一個新的映像,那麽所有的拷貝都將前功盡棄。Linux的fork()使用寫時拷貝(copy-on-write)頁實現。寫時拷貝是一種可以推遲甚至避免拷貝數據的技術。內核此時並不復制整個進程的地址空間,而是讓父子進程共享同一個地址空間。只用在需要寫入的時候才會復制地址空間,從而使各個進行擁有各自的地址空間。也就是說,資源的復制是在需要寫入的時候才會進行,在此之前,只有以只讀方式共享。這種技術使地址空間上的頁的拷貝被推遲到實際發生寫入的時候。在頁根本不會被寫入的情況下---例如,fork()後立即執行exec(),地址空間就無需被復制了。fork()的實際開銷就是復制父進程的頁表以及給子進程創建一個進程描述符。在一般情況下,進程創建後都為馬上運行一個可執行的文件,這種優化,可以避免拷貝大量根本就不會被使用的數據(地址空間裏常常包含數十兆的數據)。由於Unix強調進程快速執行的能力,所以這個優化是很重要的。

COW技術初窺:

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

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

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

在網上看到還有個細節問題就是,fork之後內核會通過將子進程放在隊列的前面,以讓子進程先執行,以免父進程執行導致寫時復制,而後子進程執行exec系統調用,因無意義的復制而造成效率的下降。

COW詳述:

現在有一個父進程P1,這是一個主體,那麽它是有靈魂也就身體的。現在在其虛擬地址空間(有相應的數據結構表示)上有:正文段,數據段,堆,棧這四個部 分,相應的,內核要為這四個部分分配各自的物理塊。即:正文段塊,數據段塊,堆塊,棧塊。至於如何分配,這是內核去做的事,在此不詳述。

1. 現在P1用fork()函數為進程創建一個子進程P2,

內核:

(1)復制P1的正文段,數據段,堆,棧這四個部分,註意是其內容相同。

(2)為這四個部分分配物理塊,P2的:正文段->PI的正文段的物理塊,其實就是不為P2分配正文段塊,讓P2的正文段指向P1的正文段塊,數據段->P2自己的數據段塊(為其分配對應的塊),堆->P2自己的堆塊,棧->P2自己的棧塊。如下圖所示:同左到右大的方向箭頭表示復制內容。

技術分享圖片

2. 寫時復制技術:內核只為新生成的子進程創建虛擬空間結構,它們來復制於父進程的虛擬究竟結構,但是不為這些段分配物理內存,它們共享父進程的物理空間,當父子進程中有更改相應段的行為發生時,再為子進程相應的段分配物理空間。

技術分享圖片

3. vfork():這個做法更加火爆,內核連子進程的虛擬地址空間結構也不創建了,直接共享了父進程的虛擬空間,當然了,這種做法就順水推舟的共享了父進程的物理空間

技術分享圖片

通過以上的分析,相信大家對進程有個深入的認識,它是怎麽一層層體現出自己來的,進程是一個主體,那麽它就有靈魂與身體,系統必須為實現它創建相應的實體, 靈魂實體與物理實體。這兩者在系統中都有相應的數據結構表示,物理實體更是體現了它的物理意義。

補充一點:Linux COW與exec沒有必然聯系

PS:實際上COW技術不僅僅在Linux進程上有應用,其他例如C++的String在有的IDE環境下也支持COW技術,即例如:

string str1 = "hello world";
string str2 = str1;

之後執行代碼:

str1[1]=‘q‘;
str2[1]=‘w‘;

在開始的兩個語句後,str1和str2存放數據的地址是一樣的,而在修改內容後,str1的地址發生了變化,而str2的地址還是原來的,這就是C++中的COW技術的應用,不過VS2005似乎已經不支持COW。

2. fork()函數

頭文件

[objc] view plaincopy技術分享圖片技術分享圖片
  1. #include<unistd.h>
  2. #include<sys/types.h>

函數原型

[objc] view plaincopy技術分享圖片技術分享圖片
  1. pid_t fork( void);

(pid_t 是一個宏定義,其實質是int 被定義在#include<sys/types.h>中)
返回值: 若成功調用一次則返回兩個值,子進程返回0,父進程返回子進程ID;否則,出錯返回-1

口訣: 父返子,子返0,fork出錯返-1
示例代碼

[objc] view plaincopy技術分享圖片技術分享圖片
  1. #include<sys/types.h> //對於此程序而言此頭文件用不到
  2. #include<unistd.h>
  3. #include<stdio.h>
  4. #include<stdlib.h>
  5. int main(int argc, charchar ** argv ){
  6. //由於會返回兩次,下面的代碼會被執行兩遍
  7. //如果成功創建子進程:
  8. //1. 父進程返回子進程ID,因此(父進程)會走一遍“分支3”
  9. //2. 子進程返回0,因此(子進程)會走一遍“分支2”
  10. pid_t pid = fork();
  11. if (pid < 0){ //分支1
  12. fprintf(stderr, "error!");
  13. }else if( 0 == pid ){//分支2
  14. printf("This is the child process!");
  15. _exit(0);
  16. }else{//分支3
  17. printf("This is the parent process! child process id = %d", pid);
  18. }
  19. //可能需要時候wait或waitpid函數等待子進程的結束並獲取結束狀態
  20. exit(0);
  21. }

註意!樣例代碼僅供參考,樣例代碼存在著父進程在子進程結束前結束的可能性。必要的時候可以使用wait或 waitpid函數讓父進程等待子進程的結束並獲取子進程的返回狀態。
fork的另一個特性是所有由父進程打開的描述符都被復制到子進程中。父、子進程中相同編號的文件描述符在內核中指向同一個file結構體,也就是說,file結構體的引用計數要增加

3. Linux的fork()使用寫時復制(詳)

fork函數用於創建子進程,典型的調用一次,返回兩次的函數,其中返回子進程的PID和0,其中調用進程返回了子進程的PID,而子進程則返回了0,這是一個比較有意思的函數,但是兩個進程的執行順序是不定的。fork()函數調用完成以後父進程的虛擬存儲空間被拷貝給了子進程的虛擬存儲空間,因此也就實現了共享文件等操作。但是虛擬的存儲空間映射到物理存儲空間的過程中采用了寫時拷貝技術(具體的操作大小是按著頁控制的),該技術主要是將多進程中同樣的對象(數據)在物理存儲其中只有一個物理存儲空間,而當其中的某一個進程試圖對該區域進行寫操作時,內核就會在物理存儲器中開辟一個新的物理頁面,將需要寫的區域內容復制到新的物理頁面中,然後對新的物理頁面進行寫操作。這時就是實現了對不同進程的操作而不會產生影響其他的進程,同時也節省了很多的物理存儲器。

C代碼 [objc] view plaincopy技術分享圖片技術分享圖片
  1. #include<stdio.h>
  2. #include<stdlib.h>
  3. #include<unistd.h>
  4. #include<fcntl.h>
  5. #include<sys/types.h>
  6. #include<sys/stat.h>
  7. int main(){
  8. char p = ‘p‘;
  9. int number = 11;
  10. if(fork()==0) /*子進程*/
  11. {
  12. p = ‘c‘; /*子進程對數據的修改*/
  13. printf("p = %c , number = %d \n ",p,number);
  14. exit(0);
  15. }
  16. /*父進程*/
  17. number = 14; /*父進程對數據修改*/
  18. printf("p = %c , number = %d \n ",p,number);
  19. exit(0);
  20. }

[objc] view plaincopy技術分享圖片技術分享圖片
  1. $ gcc -g TestWriteCopyTech.c -o TestWriteCopyTech
  2. $ ./TestWriteCopyTech
  3. p = p , number = 14 -----父進程打印內容
  4. $ p = c , number = 11 -----子進程打印內容


原因分析:
由於存在企圖進行寫操作的部分,因此會發生寫時拷貝過程,子進程中對數據的修改,內核就會創建一個新的物理內存空間。然後再次將數據寫入到新的物理內存空間中。可知,對新的區域的修改不會改變原有的區域,這樣不同的空間就區分開來。但是沒有修改的區域仍然是多個進程之間共享。
fork()函數的代碼段基本是只讀類型的,而且在運行階段也只是復制,並不會對內容進行修改,因此父子進程是共享代碼段,而數據段、Bss段、堆棧段等會在運行的過程中發生寫過程,這樣就導致了不同的段發生相應的寫時拷貝過程,實現了不同進程的獨立空間。
但是需要註意的是文件操作,由於文件的操作是通過文件描述符表、文件表、v-node表三個聯系起來控制的,其中文件表、v-node表是所有的進程共享,而每個進程都存在一個獨立的文件描述符表。父子進程虛擬存儲空間的內容是大致相同的,父子進程是通過同一個物理區域存儲文件描述符表,但如果修改文件描述符表,也會發生寫時拷貝操作,只有這樣才能保證子進程中對文件描述符的修改,不會影響到父進程的文件描述符表。例如close操作,因為close會導致文件的描述符的值發生變化,相當於發生了寫操作,這是產生了寫時拷貝過程,實現新的物理空間,然後再次發生close操作,這樣就不會產生子進程中文件描述符的關閉而導致父進程不能訪問文件。

測試函數:

[objc] view plaincopy技術分享圖片技術分享圖片
  1. #include<stdio.h>
  2. #include<stdlib.h>
  3. #include<unistd.h>
  4. #include<sys/types.h>
  5. #include<sys/stat.h>
  6. #include<fcntl.h>
  7. #include<sys/wait.h>
  8. int main(){
  9. int fd;
  10. char c[3];
  11. charchar *s = "TestFs";
  12. fd = open("foobar.txt",O_RDWR,0);
  13. if(fork()==0) //子進程
  14. {
  15. fd = 1;//stdout
  16. write(fd,s,7);
  17. exit(0);
  18. }
  19. //父進程
  20. read(fd,c,2);
  21. c[2]=‘\0‘;
  22. printf("c = %s\n",c);
  23. exit(0);
  24. }

編譯運行:

Shell代碼 [objc] view plaincopy技術分享圖片技術分享圖片
  1. $ gcc -g fileshare2.c -o fileshare2
  2. $ ./fileshare2
  3. c = fo ----foobar.txt中的內容
  4. $ TestFs ---標準輸出


原因分析:由於父子進程的文件描述符表是相同的,但是在子進程中對fd(文件描述符表中的項)進行了修改,這時會發生寫時拷貝過程,內核在物理內存中分配一個新的頁面存儲子進程原文件描述符fd存在頁面的內容,然後再進修寫操作,實現將fd修改為1,也就是標準輸出。但是父進程的fd並沒有發生改變,還是與其他的子進程共享文件描述符表,因此仍然是對文件foobar.txt進行操作。
因此需要註意fork()函數實質上是按著寫時拷貝的方式實現文件的映射,並不是共享,寫時拷貝操作使得內存的需求量大大的減少了,具體的寫時拷貝實現,請參看非常經典的“深入理解計算機系統”的第622頁。

fork()會產生一個和父進程完全相同的子進程,但子進程在此後多會exec系統調用,出於效率考慮,linux中引入了“寫時復制“技術,也就是只有進程空間的各段的內容要發生變化時,才會將父進程的內容復制一份給子進程。在fork之後exec之前兩個進程用的是相同的物理空間(內存區),子進程的代碼段、數據段、堆棧都是指向父進程的物理空間,也就是說,兩者的虛擬空間不同,但其對應的物理空間是同一個。當父子進程中有更改相應段的行為發生時,再為子進程相應的段分配物理空間,如果不是因為exec,內核會給子進程的數據段、堆棧段分配相應的物理空間(至此兩者有各自的進程空間,互不影響),而代碼段繼續共享父進程的物理空間(兩者的代碼完全相同)。而如果是因為exec,由於兩者執行的代碼不同,子進程的代碼段也會分配單獨的物理空間。
fork之後內核會通過將子進程放在隊列的前面,以讓子進程先執行,以免父進程執行導致寫時復制,而後子進程執行exec系統調用,因無意義的復制而造成效率的下降。

fork時子進程獲得父進程數據空間、堆和棧的復制,所以變量的地址(當然是虛擬地址)也是一樣的。

每個進程都有自己的虛擬地址空間,不同進程的相同的虛擬地址顯然可以對應不同的物理地址。因此地址相同(虛擬地址)而值不同沒什麽奇怪。
具體過程是這樣的:
fork子進程完全復制父進程的棧空間,也復制了頁表,但沒有復制物理頁面,所以這時虛擬地址相同,物理地址也相同,但是會把父子共享的頁面標記為“只讀”(類似mmap的private的方式),如果父子進程一直對這個頁面是同一個頁面,知道其中任何一個進程要對共享的頁面“寫操作”,這時內核會復制一個物理頁面給這個進程使用,同時修改頁表。而把原來的只讀頁面標記為“可寫”,留給另外一個進程使用。

這就是所謂的“寫時復制”。正因為fork采用了這種寫時復制的機制,所以fork出來子進程之後,父子進程哪個先調度呢?內核一般會先調度子進程,因為很多情況下子進程是要馬上執行exec,會清空棧、堆。。這些和父進程共享的空間,加載新的代碼段。。。,這就避免了“寫時復制”拷貝共享頁面的機會。如果父進程先調度很可能寫共享頁面,會產生“寫時復制”的無用功。所以,一般是子進程先調度滴。


寫時復制技術