之所以將Linux底層的寫時復制技術放在Redis篇幅下,是因為Redis進行RDB持久化時,BGSAVE
(后面稱之為"后台保存")會開辟一個子進程,將數據從內存寫進磁盤,這兒我產生了一個疑惑,就當這篇文章的引入場景:
如果我們內存中有4G數據,現在8:00執行后台保存,由於數據寫會磁盤需要時間,假設8:05數據才寫完畢,但是這中間的5分鍾,服務器一直對外提供服務,如果很多數據在這期間遭受到了更改,那么寫回磁盤的數據是8:00之前的數據還是保存了8:00~8:05這段時間變化的數據呢?
如果保存的是變化后的數據,那么有一些問題需要繼續思考,數據寫回磁盤,勢必要經過buffer,那么對於內存來說,完全寫完的時間是不太確定的,因為這中間數據一直在變化,沒法確定數據的邊界。
如果是保存8:00那個時間片的數據快照,那也就是要將數據復制一份,避免服務器提供服務時干擾到需要保存的數據,這兒又有一個新問題,這么整的話會不會內存溢出?畢竟內存是有限的,這樣簡單的復制就是double一下懷着這種疑問發現了Redis的bgsave命令底層其實是Linux的寫時復制技術
進程復制
在Linux程序中,fork()
會產生一個和父進程完全相同的子進程,但子進程在此后多會exec
系統調用,出於效率考慮,linux中引入了“寫時復制(Copy-On-Write)“技術,也就是只有進程空間的各段的內容要發生變化時,才會將父進程的內容復制一份給子進程。關於進程空間淺析Linux進程空間布局
那么子進程的物理空間沒有代碼,怎么去取指令執行exec系統調用呢?
在fork
之后exec
之前兩個進程用的是相同的物理空間(內存區),子進程的代碼段、數據段、堆棧都是指向父進程的物理空間,也就是說,兩者的虛擬空間不同,但其對應的物理空間是同一個。
當父子進程中有更改相應段的行為發生時,再為子進程相應的段分配物理空間,如果不是因為exec
,內核會給子進程的數據段、堆棧段分配相應的物理空間(至此兩者有各自的進程空間,互不影響),而代碼段繼續共享父進程的物理空間(兩者的代碼完全相同)。而如果是因為exec
,由於兩者執行的代碼不同,子進程的代碼段也會分配單獨的物理空間。
還有個細節問題就是,fork
之后內核會通過將子進程放在隊列的前面,以讓子進程先執行,以免父進程執行導致寫時復制,而后子進程執行exec
系統調用,因無意義的復制而造成效率的下降。
進程空間結構
現在有一個父進程P1,這是一個主體,現在在其虛擬地址空間(有相應的數據結構表示)上有:正文段,數據段,堆,棧這四個部分(還有BSS、MMap等),相應的,內核要為這四個部分分配各自的物理塊。即:正文段塊,數據段塊,堆塊,棧塊。至於如何分配,這是內核去做的事,在此不詳述。
現在對比三種創建子進程的區別:
fork()
現在P1用fork()函數為進程創建一個子進程P2,內核操作:
復制P1的正文段,數據段,堆,棧這四個部分,注意是其內容相同。
為這四個部分分配物理塊,P2的:正文段->P1的正文段的物理塊,其實區別就是不為P2分配正文段塊,讓P2的正文段指向P1的正文段塊,數據段->P2自己的數據段塊(為其分配對應的塊),堆->P2自己的堆塊,棧->P2自己的棧塊。如下圖所示:同左到右大的方向箭頭表示復制內容。
P2:正文段===>PI的正文段的物理塊,其實就是不為P2分配正文段塊
P2的正文段===>P1的正文段塊
數據段===>P2自己的數據段塊(為其分配對應的塊)
堆===>P2自己的堆塊
棧===>P2自己的棧塊
如下圖所示:上面為父進程,下面為fork出來的子進程,可以看出只有子進程的正文段(Text Segment)是在物理內存重新分配的。
可以看見只有正文段物理內存會被重新分配。
寫時復制
寫時復制(Copy-On-Write),由前文可知,Linux復制子進程時,並不會為所有程序空間的塊都分配物理塊,寫時復制技術在Fork技術上有了進一步的優化,Text段也不重新分配物理內存,也就是剛分配時是下面這種形式:
寫時復制:內核只為新生成的子進程創建虛擬空間結構,它們來復制於父進程的虛擬究竟結構,但是不為這些段分配物理內存,任何段都不分配,它們共享父進程的物理空間,當父子進程中有更改相應段的行為發生時,再為子進程相應的段分配物理空間,例如途中的Stack塊,注意重新分配是以內存頁,也就是pagecache(4k)為基本單位的。
vfork()
這個做法更加火爆,內核連子進程的虛擬地址空間結構也不創建了,直接共享了父進程的虛擬空間,當然了,這種做法就順水推舟的共享了父進程的物理空間。
通過以上的分析,相信大家對進程有個深入的認識,它是怎么一層層體現出自己來的,進程是一個主體,那么它就有靈魂與身體,系統必須為實現它創建相應的實體, 靈魂實體與物理實體。這兩者在系統中都有相應的數據結構表示,物理實體更是體現了它的物理意義。
傳統的fork()系統調用直接把所有的資源復制給新創建的進程。這種實現過於簡單並且效率低下,因為它拷貝的數據也許並不共享,更糟的情況是,如果新進程打算立即執行一個新的映像,那么所有的拷貝都將前功盡棄。Linux的fork()使用寫時拷貝(copy-on-write)頁實現。寫時拷貝是一種可以推遲甚至免除拷貝數據的技術。內核此時並不復制整個進程地址空間,而是讓父進程和子進程共享同一個拷貝。只有在需要寫入的時候,數據才會被復制,從而使各個進程擁有各自的拷貝。也就是說,資源的復制只有在需要寫入的時候才進行,在此之前,只是以只讀方式共享。這種技術使地址空間上的頁的拷貝被推遲到實際發生寫入的時候。在頁根本不會被寫入的情況下—舉例來說,fork()后立即調用exec()—它們就無需復制了。
fork()的實際開銷就是復制父進程的頁表以及給子進程創建惟一的進程描述符。在一般情況下,進程創建后都會馬上運行一個可執行的文件,這種優化可以避免拷貝大量根本就不會被使用的數據(地址空間里常常包含數十兆的數據)。由於Unix強調進程快速執行的能力,所以這個優化是很重要的。這里補充一點:Linux COW與exec沒有必然聯系