從作業系統的角度來談為什麼需要「虛擬記憶體」

Larry Lu
Starbugs Weekly 星巴哥技術專欄

--

上個月的專欄「你一定用過 htop,但你有看懂每個欄位嗎?」發出去之後有不少人來問我為什麼作業系統需要多弄一層虛擬記憶體,而不直接讓程式存取實體記憶體就好

因為實在太多人問了類似的問題,所以就乾脆寫成一篇文章,當作幫自己複習,哪天有人再問也可以直接丟文章給他XD

Background

我想應該大家都知道,程式在被 CPU 執行之前,必須先把程式的內容載入到到一段 連續的記憶體空間,如此一來 CPU 才能根據記憶體中的的程式一行一行執行下去

因此,當你同時開啟很多程式,他們在記憶體中就會長這樣

之後每當有新啟動的程式,系統就會從剩餘的記憶體中分配一段 連續的空間 給他,而若有程式結束了,那系統也會把他佔用的記憶體清除掉

雖然這樣的做法聽起來很美好,但實務上卻很常遇到 記憶體碎片化 的問題

記憶體碎片化

記憶體碎片化(Memory Fragmentation)簡單來說就是雖然剩餘的空間總合夠大,但因為那些空間被切割成大大小小的區塊,導致沒有一段足夠大的連續空間可以使用

以上圖來說,原本我的記憶體最右側還剩下 3GB 可以用,如果我再把 VSCode 關掉,那就會有 6GB 的 free memory

縱使有 6GB 的 free memory,但如果現在想打開 4GB 的 Minecraft 來玩,系統就會因為找不到連續的 4GB 而無法開啟

而且一般在使用電腦時程式都會開開關關,所以碎片化的問題會越來越嚴重。雖然看似有很多 free memory,但因為這些空間太碎了,所以什麼程式都開不起來

記憶體虛擬化

為了解決碎片化的問題,現在的電腦都會做 記憶體虛擬化(Memory Virtualization),也就是給每個 process 一塊獨立的虛擬記憶體(Virtual Memory),然後把他對應到可用的 實體記憶體(Physical Memory)中

譬如說我現在開了 Firefox 跟 Chrome,那系統就會給他們倆各自一大塊虛擬記憶體,讓他們自由運用裡面的空間。如此一來 Firefox 跟 Chrome 就會覺得自己拿到的記憶體是連續的一大塊,但實際上並沒有

再換句話說,如果你今天寫程式宣告了一個巨大 array,邏輯上你確實拿到一塊很大的連續空間,但實際上那個巨大 array 在實體記憶體中是分散的,只是你的程式感覺不到

Memory Management Unit(MMU)

雖然 記憶體虛擬化 聽起來完美解決了碎片化的問題,但如果每次程式要去存取記憶體時,作業系統都要花時間把虛擬位址(Virtual Address)轉成實體位址(Physical Address),那程式跑起來就會慢很多

為了解決這個問題,從 1980 年代開始的電腦都會加上一塊硬體叫 MMU,根據維基的說法大概是長這樣XD

這個 MMU 內部有一個 page table 記錄了虛擬/實體位址的對應關係,當你的程式叫 CPU 去拿一個變數,CPU 就會馬上叫 MMU 去找對應的實體位址,得到實體位址後再馬上把變數值拿給你

因為是直接用硬體來實作,雖然還是會稍微降低效能,但跟軟體比起來已經少了很多 overhead

一舉多得

前面有提到一開始做 記憶體虛擬化 是為了解決碎片化的問題,但除此之外還帶來不少其他優點

Process 間共享實體記憶體

平常在開發時經常會用不同程式打開同一個檔案,譬如說我一邊在跑 node app.js,同時又在用 VSCode 讀 app.js 的程式碼,這時候 OS 就可以只載入一次 app.js,然後把兩個 process 的虛擬位址對應到同一塊實體記憶體

除了檔案之外,很多程式也會用到一樣的 dynamic library(畢竟常用的就那幾個),像 Mac OS 裡面的 ls、cat、node、go 等等指令在執行時都需要 libSystem.B.dylib

這種類型的 library 因為太常用,所以若記憶體夠用的話,系統就會一直把他放在記憶體裡面,下次又需要時就可以馬上 mapping 給需要的 process 使用

不需要載入整個程式(Demand Paging)

在有虛擬記憶體之前,要執行一個程式往往需要把整個程式載入記憶體

但仔細想一想,每個程式都有很多地方根本不太會被執行到:譬如說有些程式碼的功能是在程式崩潰之時把 stack trace 印出來、有些則是在服務異常時發送 slack 通知給開發人員。如果這些例外狀況極少發生,那把整個程式都載入記憶體內顯然不是個好主意

因此系統在執行程式時雖然會把整個程式都載入虛擬記憶體,讓 process 以為他隨時可以呼叫 error_handler()panic() 等等 function,但實際上只會把「馬上要用到的地方」放進實體記憶體,其他沒用的東西就先不載入,等真的需要再去拿就好(說穿了就是 lazy loading)

有了 demand paging 的機制後,CPU 就不必等整個程式都載入,有多少就先跑多少,所以啟動程式的等待時間會比較短、記憶體也比較不會被少數程式塞滿

把用不到的東西丟出去(Swapping)

剛剛 Demand Paging 講的是只把需要的部分載入記憶體,那萬一那些「需要的部分」很大,大到記憶體不夠用呢?這時系統就會開始做 Swapping,也就是把「曾經用過,但以後用不太到」的東西丟出去

譬如說程式剛啟動時要跑的 init()、偶爾才跑一次的 error_handler()他們都曾經被執行過所以一定有被載入記憶體。但如果可用的記憶體快沒了,系統就會把他們 swap 出去(沒用的東西都給我滾),哪天需要時再從硬碟拿回來就好

有了 swapping 機制後雖然可以增進記憶體的使用效率,而且記憶體絕對不會不夠用(說穿了就是拿硬碟當擴充記憶體),但因為硬碟速度很慢,所以若是系統很頻繁的做 swapping 就會導致效能變差(因為 CPU 一直在等硬碟讀寫)

那怎麼知道系統用了多少 Swap 呢?看 htop 就可以了。我的 htop 打開後會看到 Swp 是 0/1023MB,意思是系統沒有把任何記憶體 swap 到硬碟上(因為我的 Mem 還夠用),但如果需要的話最多可以把 1023MB 的記憶體 swap 出去,等需要時再拿回來就好

如果沒有裝 htop 的話,top 最上面也有 swap in 跟 swap out 可以看目前用了多少 swap 哦~

那知道 Swap 使用量可以做什麼呢?剛剛有提到說頻繁的做 swapping 會導致效能變差,因此如果常常覺得電腦、主機慢到炸裂,開個瀏覽器一分鐘才跳出來,而且剛好 Swap 的使用量又很高,那就很有可能是記憶體不足,快幫你的機器升級吧~

總結

回到這篇的主題,為什麼需要多加一層虛擬記憶體呢?我想現在大家都知道了。總的來說 虛擬記憶體 就是在 實體記憶體應用程式 之間加上一個中間層,有了這個中間層後,系統就可以在應用程式感覺不到的情況下偷偷摸摸做很多事,像是 share memory、lazy loading、swapping 等等

而對應用程式來說,記憶體就是記憶體,系統說我的 function 在那邊就是在那邊,我只需要去呼叫、去使用就好了,不用管 function 什麼時候會被載入進來,也不用擔心我的資料哪時會被 swap 出去,反正系統一定會做最好的安排

延伸閱讀

--

--

Larry Lu
Starbugs Weekly 星巴哥技術專欄

我是 Larry 盧承億,傳說中的 0.1 倍工程師。我熱愛技術、喜歡與人分享,專長是 JS 跟 Go,平常會寫寫技術文章還有參加各種技術活動,歡迎大家來找我聊聊~