18張圖揭祕高性能Linux服務器內存池技術是如何實現的

你們生活中確定都有這樣的經驗,那就是大衆化的產品都比較便宜,但便宜的大衆產品就是一個詞,普通;而能夠定製的產品通常都價位不凡,這種定製的產品註定不會在大衆中普及,所以定製產品就是一個詞,獨特。
程序員

有的同窗可能會有疑問,你不是要聊技術嗎?怎麼又提及消費了?編程

原來技術也有大衆貨以及定製品。安全


通用 VS 定製服務器

做爲程序員(C/C++)咱們知道申請內存使用的是malloc,malloc其實就是一個通用的大衆貨,什麼場景下均可以用,可是什麼場景下均可以用就意味着什麼場景下都不會有很高的性能
malloc性能不高的緣由一在於其沒有爲特定場景作優化,除此以外還在於malloc看似簡單,可是其調用過程是很複雜的,一次malloc的調用過程可能須要通過操做系統的配合才能完成。
那麼調用malloc時底層都發生了什麼呢?簡單來講會有這樣典型的幾個步驟:
  1. malloc開始搜索空閒內存塊,若是能找到一塊大小合適的就分配出去微信

  2. 若是malloc找不到一塊合適的空閒內存,那麼調用brk等系統調用擴大堆區從而得到更多的空閒內存數據結構

  3. malloc調用brk後開始轉入內核態,此時操做系統中的虛擬內存系統開始工做,擴大進程的堆區,注意額外擴大的這一部份內存僅僅是虛擬內存,操做系統並無爲此分配真正的物理內存多線程

  4. brk執行結束後返回到malloc,從內核態切換到用戶態,malloc找到一塊合適的空閒內存後返回併發

以上就是一次內存申請的完整過程,咱們能夠看到,一次內存申請過程實際上是很是複雜的,關於這個問題的詳細討論你能夠參考這裏
既然每次分配內存都要通過這麼複雜的過程,那麼若是程序大量使用malloc申請內存那麼該程序註定沒法得到高性能
幸虧,除了大衆貨的malloc,咱們還能夠私人定製,也就是針對特定場景本身來維護內存申請和分配,這就是高性能高併發必備的內存池技術

內存池技術有什麼特殊的嗎?
有的同窗可能會說,等等,那malloc和這裏提到的內存池技術有什麼區別呢?
第一個區別在於咱們所說的malloc實際上是標準庫的一部分,位於標準庫這一層;而內存池是應用程序的一部分。
其次在於定位,咱們本身實現的malloc其實也是定位通用性的,通用性的內存分配器設計實現每每比較複雜,可是內存池技術就不同了,內存池技術專用於某個特定場景,以此優化程序性能,但內存池技術的通用性是不好的,在一種場景下有很高性能的內存池基本上沒有辦法在其它場景也能得到高性能,甚至根本就不能用於其它場景,這就是內存池這種技術的定位。
那麼內存池技術是怎樣優化性能的呢?

內存池技術原理
簡單來講,內存池技術一次性獲取到大塊內存,而後在其之上本身管理內存的申請和釋放,這樣就繞過了標準庫以及操做系統
也就是說,經過內存池,一次內存的申請不再用去繞一大圈了。
除此以外,咱們能夠根據特定的使用模式來進一步優化,好比在服務器端,每次用戶請求須要建立的對象可能就那幾種,那麼這時咱們就能夠在本身的內存池上提早建立出這些對象,當業務邏輯須要時就從內存池中申請已經建立好的對象,使用完畢後還回內存池。
所以咱們能夠看到,這種爲某些應用場景定製的內存池相比通用的好比malloc內存分配器會有大的優點。
接下來咱們就着手實現一個。

實現內存池的考慮
值得注意的是,內存池實際上有不少的實現方法,在這裏咱們仍是以服務器端編程爲例來講明。
假設你的服務器程序很是簡單,處理用戶請求時只使用一種對象(數據結構),那麼最簡單的就是咱們提早申請出一堆來,使用的時候拿出一個,使用完後還回去:
怎麼樣,足夠簡單吧!這樣的內存池只能分配特定對象(數據結構),固然這樣的內存池須要本身維護哪些對象是已經被分配出去的,哪些是尚未被使用的。
可是,在這裏咱們能夠實現一個稍微複雜一些的,那就是能夠申請不一樣大小的內存,並且因爲是服務器端編程,那麼一次用戶請求過程當中咱們只申請內存,只有當用戶請求處理完畢後一次性釋放全部內存,從而將內存申請釋放的開銷下降到最小。
所以,你能夠看到,內存池的設計都是針對特定場景的。
如今,有了初步的設計,接下來就是細節了。

數據結構
爲了可以分配大小可變的對象,顯然咱們須要管理空閒內存塊,咱們能夠用一個鏈表把全部內存塊連接起來,而後使用一個指針來記錄當前空閒內存塊的位置,如圖所示:
從圖中咱們能夠看到,有兩個空閒內存塊,空閒內存之間使用鏈表連接起來,每一個內存塊都是前一個的2倍,也就是說,當內存池中的空閒內存不足以分配時咱們就向malloc申請內存,只不過其大小是前一個的2倍:
其次,咱們有一個指針free_ptr,指向接下來的空閒內存塊起始位置,當向內存池分配內存時找到free_ptr並判斷當前內存池剩餘空閒是否足夠就能夠了,有就分配出去並修改free_ptr,不然向malloc再次成倍申請內存。
從這裏的設計能夠看出,咱們的內存池實際上是不會提供相似free這樣的內存釋放函數的,若是要釋放內存,那麼會一次性將整個內存池釋放掉,這一點和通用的內存分配器是不同。
如今,咱們能夠分配內存了,還有一個問題是全部內存池設計不得不考慮的,那就是線程安全,這個話題你能夠參考這裏

線程安全
顯然,內存池不該該侷限在單線程場景,那咱們的內存池要怎樣實現線程安全呢?
有的同窗可能會說這還不簡單,直接給內存池一把鎖保護就能夠了。
這種方法是否是可行呢?仍是那句話,It depends,要看狀況。
若是你的程序有大量線程申請釋放內存,那麼這種方案下鎖的競爭將會很是激烈,線程這樣的場景下使用該方案不會有很好的性能。
那麼還有沒有一種更好的辦法嗎?答案是確定的。

線程局部存儲
既然多線程使用線程池存在競爭問題,那麼幹脆咱們爲每一個線程維護一個內存池就行了,這樣多線程間就不存在競爭問題了。
那麼咱們該怎樣爲每一個線程維護一個內存池呢?
線程局部存儲,Thread Local Storage正是用於解決這一類問題的,什麼是線程局部存儲呢?
簡單說就是,咱們能夠建立一個全局變量,所以全部線程均可以使用該全局變量,但與此同時,咱們將該全局變量聲明爲線程私有存儲,那麼這時雖然全部線程依然看似使用同一個全局變量,但該全局變量在每一個線程中都有本身的副本,變量指向的值是線程私有的,相互之間不會干擾。
關於線程局部存儲,能夠參考這裏
假設這個全局變量是一個整數,變量名字爲global_value,初始值爲100,那麼當線程A將global_value修改成200時,線程B看到的global_value的值依然爲100,只有線程A看到的global_value爲200,這就是線程局部存儲的做用。

線程局部存儲+內存池
有了線程局部存儲問題就簡單了,咱們能夠將內存池聲明爲線程局部存儲,這樣每一個線程都只會操做屬於本身的內存池,這樣就不再會有鎖競爭問題了。
注意,雖然這裏給出了線程局部存儲的設計,但並非說加鎖的方案就比不上線程局部存儲方案,仍是那句話,一切要看使用場景,若是加鎖的方案夠用,那麼咱們就沒有必要絞盡腦汁的去用其它方案,由於加鎖的方案更簡單,代碼也更容易維護。
還須要提醒的是,這裏只是給出了內存池的一種實現方法,並非說全部內存池都要這麼設計,內存池能夠簡單也可複雜,一切要看實際場景,這一點也須要注意。

其它內存池形式
到目前爲止咱們給出了兩種內存池的設計方法,第一種是提早建立出一堆須要的對象(數據結構),本身維護好哪些對象(數據結構)可用哪些已被分配;第二種能夠申請任意大小的內存空間,使用過程當中只申請不釋放,最後一次性釋放。這兩種內存池自然適用於服務器端編程。
最後咱們再來介紹一種內存池實現技術,這種內存池會提早申請出一大段內存,而後將這一大段內存切分爲大小相同的小內存塊:
而後咱們本身來維護這些被切分出來的小內存塊哪些是空閒的哪些是已經被分配的,好比咱們可使用棧這種數據結構,最初把全部空閒內存塊地址push到棧中,分配內存是就pop出來一個,用戶使用完畢後再push回棧裏。
從這裏的設計咱們能夠看出,這種內存池有一個限制,這個限制就是說程序申請的最大內存不能超過這裏內存塊的大小,不然不足以裝下用戶數據,這須要咱們對程序所涉及的業務很是瞭解才能夠。
用戶申請到內存後根據須要將其塑形成特定對象(數據結構)。
關於線程安全的問題,能夠一樣採用線程局部存儲的方式來實現:

一個有趣的問題
除了線程安全,這裏還有一個很是有趣的問題,那就是若是線程A申請的對象被線程B拿去釋放,咱們的內存池該怎麼處理呢?
這個問題之因此有趣是由於咱們必須知道該內存屬於哪一個線程的局部存儲,但申請的內存自己並不能告訴你這樣的信息
有的同窗可能會說這還不簡單,不就是一個指針到另外一個指針的映射嗎,直接用map之類存起來就行了,但問題並無這麼簡單,緣由就在於若是咱們切分的內存塊很小,那麼會存在大量內存塊,這就須要存儲大量的映射關係,有沒有辦法改進呢?
改進方法是這樣的,通常來講,咱們申請到的大段內存實際上是會按照特定大小進行內存對齊,咱們假設老是按照4K字節對齊,那麼該大段內存的起始地址後12個bit(4K = 2^12)爲老是0,好比地址0x9abcd000,同時咱們也假設申請到的大段內存大小也是4K:
那麼咱們就能知道該大段內存中的各個小內存塊起始地址除了後12個bit位外都是同樣的:
這樣拿到任意一個內存的地址咱們就能知道對應的大段內存的起始地址,只須要簡單的將後12個bit置爲0便可,有了大段內存的起始地址剩下的就簡單了,咱們能夠在大段內存中的最後保存對應的線程局部存儲信息:
這樣咱們對任意一個內存塊地址進行簡單的位運算就能夠獲得對應的線程局部存儲信息,大大減小了維護映射信息對內存的佔用。

總結
內存池是高性能服務器中常見的一種優化技術,在這裏咱們介紹了三種實現方法,值得注意的是,內存池實現沒有統一標準,一切都要根據具體場景定製,所以咱們能夠看到內存池設計是有針對性的,固然其反面就是不具有通用性。
但願本文對你們理解內存池有所幫助。

最後的最後,若是以爲文章對你有幫助的話,請多多分享轉發在看異步

長按關注 碼農的荒島求生

往期精選ide

看完這篇還不懂線程與線程池你來打我 

讀取文件時,程序經歷了什麼?

一文完全理解I/O多路複用 

從小白到高手,你須要理解同步與異步 

程序員應如何完全理解回調函數 

高性能高併發服務器是如何實現的 

函數運行時在內存中是什麼樣子 

程序員應如何理解協程 

線程間到底共享了哪些進程資源?

線程安全代碼究竟是怎麼編寫的?

本身動手實現一個malloc內存分配器 

神祕!申請內存時底層發生了什麼?

碼農的荒島求生

本文分享自微信公衆號 - 碼農的荒島求生(escape-it)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索