1. 程式人生 > 其它 >copy-on-write(寫時拷貝)

copy-on-write(寫時拷貝)

寫時拷貝(copy-on-write, COW)

  就是等到修改資料時才真正分配記憶體空間,這是對程式效能的優化,可以延遲甚至是避免記憶體拷貝,當然目的就是避免不必要的記憶體拷貝。其實我們對寫時拷貝並不陌生,Linux fork和STL string是比較典型的寫時拷貝應用

Linux fork

  傳統的fork()系統呼叫直接把所有的資源複製給新建立的程序。這種實現過於簡單並且效率低下,因為它拷貝的資料或許可以共享,Linux的fork()使用寫時拷貝(copy-on-write)頁實現。寫時拷貝是一種可以推遲甚至避免拷貝資料的技術。核心此時並不複製整個程序的地址空間,而是讓父子程序共享同一個地址空間。只用在需要寫入的時候才會複製地址空間,從而使各個進行擁有各自的地址空間。也就是說,資源的複製是在需要寫入的時候才會進行,在此之前,只有以只讀方式共享。這種技術使地址空間上的頁的拷貝被推遲到實際發生寫入的時候。
  在Linux程式中,fork()會產生一個和父程序完全相同的子程序,但子程序在此後多會exec系統呼叫,出於效率考慮,linux中引入了“寫時複製“技術,也就是隻有程序空間的各段的內容要發生變化時,才會將父程序的內容複製一份給子程序。那麼子程序的物理空間沒有程式碼,怎麼去取指令執行exec系統呼叫呢?
      在fork之後exec之前兩個程序用的是相同的物理空間(記憶體區),子程序的程式碼段、資料段、堆疊都是指向父程序的物理空間,也就是說,兩者的虛擬空間不同,但其對應的物理空間是同一個。當父子程序中有更改相應段的行為發生時,再為子程序相應的段分配物理空間,如果不是因為exec,核心會給子程序的資料段、堆疊段分配相應的物理空間(至此兩者有各自的程序空間,互不影響),而程式碼段繼續共享父程序的物理空間(兩者的程式碼完全相同)。而如果是因為exec,由於兩者執行的程式碼不同,子程序的程式碼段也會分配單獨的物理空間。fork之後核心會通過將子程序放在佇列的前面,以讓子程序先執行,以免父程序執行導致寫時複製,而後子程序執行exec系統呼叫,因無意義的複製而造成效率的下降。
  fork的另一個特性是所有由父程序開啟的描述符都被複制到子程序中。父、子程序中相同編號的檔案描述符在核心中指向同一個file結構體,也就是說,file結構體的引用計數要增加。
  fork函式用於建立子程序,典型的呼叫一次,返回兩次的函式,其中返回子程序的PID和0,其中呼叫程序返回了子程序的PID,而子程序則返回了0,這是一個比較有意思的函式,但是兩個程序的執行順序是不定的。fork()函式呼叫完成以後父程序的虛擬儲存空間被拷貝給了子程序的虛擬儲存空間,因此也就實現了共享檔案等操作。但是虛擬的儲存空間對映到物理儲存空間的過程中採用了寫時拷貝技術(具體的操作大小是按著頁控制的),該技術主要是將多程序中同樣的物件(資料)在物理儲存其中只有一個物理儲存空間,而當其中的某一個程序試圖對該區域進行寫操作時,核心就會在物理儲存器中開闢一個新的物理頁面,將需要寫的區域內容複製到新的物理頁面中,然後對新的物理頁面進行寫操作。這時就是實現了對不同程序的操作而不會產生影響其他的程序,同時也節省了很多的物理儲存器

STL String

  string類的實現必然有個char*成員變數,用以存放string的內容,寫時拷貝針對的物件就是這個char*成員變數。通過賦值或拷貝構造類操作,不管派生多少份string“副本”,每個“副本”的char*成員都是指向相同的地址,也就是共享同一塊記憶體,直到某個“副本”執行string寫操作時,才會觸發寫時拷貝,拷貝一份新的記憶體空間出來,然後在新空間上執行寫操作。顯然,那些只讀的“副本”節省了記憶體分配的時間和空間。

  聽起來有點懵,對於沒了解過寫時拷貝的同學,會感覺完全顛覆平常對string的認知,下面我們來看一下實際例子。

寫時拷貝例子

如上程式碼所示,呼叫拷貝建構函式生成str2,呼叫賦值操作符生成str3,那麼str2與str3是否有分配記憶體空間來儲存內容“abc”呢?

  執行結果告訴我們,str1、str2與str3是共享記憶體空間的(char*成員指向相同的地址)。那麼問題來了,對str1、str2或str3內容的修改是否會互相影響呢?答案是,只要遵守STL的約定來修改,是會觸發寫時拷貝的,不會互相影響(畢竟平時一直這樣用也沒有問題)。

 

可以看到,對str1重新複製,修改str3的值,都會觸發寫時拷貝,分配了新的空間。由於str1、str3都分配了新的空間,str2就可以繼續使用原來的空間了。

寫時拷貝原理

  看了上面的例子,相信大家都已明白寫時拷貝的表象了。但我們不能滿足於現象,還要知道實現原理。應該很多同學都能猜到,string肯定是使用計數器來記錄引用數,當有新的string物件共享記憶體塊時,計數器+1,當有物件觸發寫時拷貝或析構時,計數器-1。

  那麼計數器存放在哪裡呢?這是物件級別的計數器,由若干個物件共享,string類成員變數、靜態變數或全域性變數都不能滿足要求。最合適的就是在堆裡分配空間專門儲存這個計數器,由第一個建立的物件分配並初始化計數器,其他物件按照約定引用計數器。我們知道string的記憶體空間就在堆上,那麼直接在這塊區上多分配一個空間來儲存計數器是最方便的,所有共享這塊記憶體的string物件都能訪問計數器。事實上STL就是這麼實現的,在string記憶體空間的最前面分配了空間儲存計數器,如下圖所示(圖片摘自引文):

  string的所有賦值、拷貝構造操作,計數器都會+1;修改string資料時,先判斷計數器是否為0(0代表沒有其他物件共享記憶體空間),為0則可以直接使用記憶體空間(如例子中的str2),否則觸發寫時拷貝,計數器-1,拷貝一份資料出來修改,並且新的記憶體計數器置0;string物件析構時,如果計數器為0則釋放記憶體空間,否則計數器也要-1。