Go 語言的手工記憶體管理

oschina發表於2015-05-27

介紹

注:如果您對這篇文章有不同觀點,歡迎指正 – 我並不是這方面的專家。

我們從大量的 go 使用者中收集了有關於使用 defer 和 panic 的效能統計。不像其它的 APM/error 記錄公司,我們的重點不是告訴你有一個問題,而是實際解決問題的方法。這就是為什麼我們要使用 go。

這使我們看出對大家來說什麼是好的,壞的和醜的。

當你的專案中隱藏了一個大問題時,如果你只是來回檢視通常是不能發現它的,直到你對它進行測試。

許多效能問題可能已經存在了數週。

通常這些問題大部分可以簡單的通過 pprof 等方法分析目標程式來解決掉。

一旦你懷疑一個地方是錯誤的,你就應該先看看這個(pprof)。

然而,有時候,在經過幾天都不能找到問題源頭的時候,程式猿就會開始抱怨一切。

是資料庫的問題!

框架的問題!

是 cat(計算機輔助程式)的問題!(貓?。。。)

是垃圾回收器的問題!

每當這個點上,團隊裡就會有個傢伙跳出來說,他使用的某某語言更好(-_-!,那樣如樣你不是個軟體工程師,你將被他搞得暈頭轉向。

我這裡不討論關於語言、cats(又是貓?。。)或者框架。

我只是想引導大家討論更有效率的方案。

我確實想說說垃圾回收器。它是在大多數語言中常常被提及的效能問題,但是,如果問題隨手就可以解決掉,那它也就不會引起人們的關注了。

反對垃圾回收器的爭論,在相當長的一段時間內,有快速趕超硬編碼彙編的爭論。當然,如果你瞭解需要知道的一切,關於目標編譯器,目標架構,目標作業系統等,那麼你選擇一兩項內容做優化是說得通的,但是,這基本上是說,你比一些最好的編譯器作者還要更勝一籌。你可能具備這樣的能力。

然而, 這是相當高的要求。

實際上,有許多人們覺得相當不錯的垃圾回收器,如 azul 的記憶體回收器.

背景

“C程式設計師認為記憶體管理太重要了,不能由電腦自己處理。 Lisp 的程式設計師認為記憶體管理太重要了,不能由使用者來處理。“ —— Bjarne Stroustrup

要開始使用 – 大部分現代程式語言都使用自動化的垃圾收集機制。

無論是 Java 或是 C# 都有垃圾收集器。並且,一些解釋型語言也有。

Go 語言的手工記憶體管理

約翰·麥卡錫(John McCarthy)1958年在歸途中發明了一種叫“垃圾回收器”的東西。這是在成功發射 Sputnik 的第二年。之後他還發明瞭別的東西。

所以,這個概念已經有相當長的時間了,但一些開發者似乎仍然對這個概念感到不安。為什麼呢?

要回答這個問題,我們應該看看各種方法的記憶體管理。

記憶體管理的方法

這裡有一些記憶體管理記憶體的方法,你可以:

* 在編譯時分配所有的記憶體

* 手動管理記憶體

* 自動管理記憶體

對於我們的定義,我們認為垃圾是使用完畢的已分配記憶體。同時,也為了這篇部落格,唯一的差別在於,垃圾可以由程式設計師手動清理(或者不清理),或者由程式語言自動清理。

在管理記憶體的過程中,存在許多問題。其中,有些問題是顯而易見的,有些問題則不容易發覺;有些問題不容易解決,而另一些問題甚至無人意識到它的存在。

這通常只是想當然,因為–你通常擁有一個垃圾回收器!!:)

管理記憶體中的問題:

* 引用不再使用的記憶體

* 使用未分配記憶體的指標

* 分配記憶體但從不釋放

* 使用釋放了記憶體的指標

* 沒有分配足夠的記憶體

* 分配記憶體大小錯誤

* 分配或者釋放記憶體太快或者太慢

* 在記憶體中儲存資料之前載入記憶體

* 安全的從記憶體中刪除敏感資料

* 內部碎片

* 外部碎片

實際上–安全行業統計過,關於記憶體管理的錯誤使用,涉及到數十億美元。這顯然是一個問題。

基本的記憶體管理操作

記憶體管理在電腦科學中有它的獨特之處,但是,這裡有兩種最常用和最重要的操作:

分配:這是分配一塊記憶體給指定請求的操作,同時保證分配器不會將此記憶體分配給其它地方,除非你說不。

釋放/重用:這就是當你說–這塊記憶體我已經使用完了,你可以按照你覺得合適的方式,自由的處理它了。記憶體並未“刪除”。記憶體也並未“釋放”到空中,或者其它地方。所做的,只是記憶體做了標記,這樣,下一次請求的時候,它就可以重新使用了。

關於記憶體管理的錯誤假設

預留 vs. 分配

許多人困惑的一件事情是,分配記憶體和預留記憶體的差別。Go 語言 一開始就預留了一大塊記憶體(reserves a large chunk of memory right off the bat) 。這樣做有很多原因–其中之一是,連續vs.非連續的記憶體對映。你希望儘可能的減少碎片。

對於我們而言,我們定義預留是對 malloc 函式的一次呼叫,然而,當我們決定使用這片記憶體的時候,記憶體分配才真正發生。只要存在未預留的記憶體空間,我們就可以隨心所欲的呼叫 malloc 函式。但是,當我們使用記憶體,並消耗完它的時候,我們就會得到記憶體不足(OOM)的錯誤資訊。

作業系統中斷

開發者認為如果他們可以分配和釋放記憶體,他們的程式就不會因 GC 呼叫而中斷和變慢。

如果上述假設是真實的,那麼這種邏輯的問題所在是他們忘記了作業系統本身可以阻塞記憶體的分配,排程程式切換到其他任務, 而且所有的裝置驅動程式都有中斷處理。你的程式碼不是孤立存在的,有太多的因素公導致它產生暫停。

記憶體釋放不一定是免費的

使用一個好的 GC 確實可以減少記憶體分配時間。同時釋放記憶體並不意味著立即釋放記憶體塊,通過你用的任何版本的 malloc 都會把它放在一個釋放列表裡 –  這樣可以幫助阻止碎片化。

當然,釋放記憶體有時間並不是免費的,它可以是隨機的,你需要分頁,導致糟糕的 IO。試想做大量的小記憶體分配,你會導致大量的碎片,為了避免崩潰不得不重新分配堆疊大小。

碎片

我已經提到碎片好幾次了 – 讓我告訴你一個簡單的例子。

比方說,我們有一個地方可以動態分配記憶體。有4個我們可以訪問的槽。但是,每次我們請求記憶體時,我們只能獲取2個插槽。

Go 語言的手工記憶體管理

我們第一次分配,我們搶到了前兩個槽。然後,我們斷言我們之後不再需要第一個槽,所以我們“刪除”了。好吧,太棒了。現在看起來是這樣的。

Go 語言的手工記憶體管理

現在一個大的資料庫作業來了,我們還需要一個槽。太糟糕了,我們的分配機制只允許我們一次獲取2個槽 – 所以會發生什麼?我們得到了接下來的兩個槽。現在,我們已經分配了四分之三的,即使我們只用了其中的2個。這樣繼續下去,直到剩下的全都是我們不能夾這些已經分配的槽中間使用很多的空閒縫隙。

這是讓我們抓狂的碎片。

配置策略

一般來說經典的分配策略可以有兩種形式:{單個列表或列表的陣列}:
我不會介紹陣陣列實現,也不會去談現有的那些更加奇特的策略。
我們說一個與上面的那個類似的連結串列。
現在有幾個關於在單空閒單連結串列找到一個合適的地址(槽)的策略方式供選擇:
首次適應(first fit):我們通過掃描整個連結串列,尋找第一個能夠符合我們的分配請求的位置。
迴圈首次適應(next fit):與首次適應類似,但我們追蹤上衣一次訪問的地方,然後從這裡開始尋找下一個符合請求要求的位置,所以你沒必要每次都從連結串列的頭開始掃描。
最佳策略(best fit):尋找記憶體中符合要求的最小的塊。
最壞策略(worst fit):尋找記憶體中符合要求的最大的塊。

Go的垃圾回收

要立刻實現此種垃圾回收方式,go還是一種很年輕的語言。毫無疑問在未來會有諸多改進,但這將是一個不平凡的任務,並且需要有人來為此努力。我確信並非所有人擁有這樣的才能。
兩種不同的方法在垃圾回收方面佔有很多大優勢:{跟蹤,引用計數}
1.4 經典的STW垃圾回收器。
1.5 引入了一個併發收集器。
這兩者都是標記和清除,這是跟蹤收集器的一個實現。
他們是非移動的,這意味著垃圾回收器將不會像可壓縮的收集器那樣移動引用。
您可以通過設定環境變數關閉垃圾回收器:

GOGC=off ./myproggie

然而,這一點毫無意義,除非你要手動執行收集器 – 並且執行環境自己分配記憶體 – 所以你產生的有垃圾會有人來處理它。你每天都拖垃圾到某處傾倒?

當然不,你有更重要的事情要做。

至於為什麼go有一個垃圾收集器 – 我不認為這點足夠驅使人們不那麼做。其中這個語言的一個主要賣點是併發。傳統的記憶體管理已經很難實現來了,更別說現在要在一個併發的環境實現垃圾回收了。
請看horse’s mouth

反模式

總算切入正題了……

很多開發者都選擇使用高階語言來管理記憶體事務。的許多開發人員經常碰到記憶體問題,因為所有的記憶體管理是從他們隱藏。這並不意味因為大部分記憶體都被影藏了。

但這不意味著他們是拙劣的開發者,只是這些記憶體事務不在他們的思考範疇內。

這也不應該是他們在開發自己的程式碼錢所必須修完的功課。

我猜想,我們所看到的使用者出現的各種問題大部分來自於一些簡單但卻常常被忽略的模式,如下:

字串連線

在 java 中有一個眾所周知的反模式,我們可以使用在一個環結構中連結字串而不是使用字串生成器。好的,go 也有自己的反模式。

需要知曉的事情是,當你 concat 字串的時候,go 分配到了一個新地址給新的值。因此,事情變得如此簡單:

  blah := "stuff" + "more stuff" + "even more stuff"
  for  i:=0; i<10*10*10; i++ {
    blah += "and more stuff... " + "ad infinitum"
  }

多勞者多得。

關於這種方式可以參考 stdlib @strings.Join 。你也可以使用 bytes.Buffer.WriteString

手動管理記憶體用例

你還是要手動管理?好吧。

一些真實用例可能包含以下:

* 真正的大型堆 – 假設大型記憶體資料庫操作;

* 管理相同大小同一型別的物件;

* 實時系統 – 這不是一個好用例,因為這不是go的特長,你最好選擇一個完全不同的語言。

自己寫或者使用它來做

老實講,恕我直言,除非你是這方面的專家(我肯定不是),這兒沒有太多好的用例。如果你是,我可能會建議你繼續使用go工作。

大部分人不會寫底層的裝置指令或者實時軟體。我不是講為實時而生的node.js/websockets 和別的類似軟體,我們講的是特定的作業系統,存在於你手機而不是Linux的第二作業系統。

如果你必須這樣做:

* 你的磁碟有大量碎片;

* 你的磁碟不是執行緒安全;

* 你覺得垃圾回收器比較混亂,所以你可以關閉它,我們已經提到過這會有別的問題。

還是要自己管理?

好了,你可以採用 hacky,管理它可能不像在 C 語言裡那麼有效,僅僅使用 CGO 連結到它。

你可以使用 STDLIB 的東西:
sync.Pool

你也可以使用緩衝通道。
此外,隨著有了越來越多參考資料,看看couchbase 和cloudflare如何實現它們。
監控
還記得在本文的前面時,我說,大多數人都沒有意識到一個問題,直到它變成一個很大的問題或者你應該權衡的事情?
我們只是碰巧如此做,對於 go 來說是主動地:

Go 語言的手工記憶體管理

我們甚至可以提醒你之前,事情變得無法忍受。

Go 語言的手工記憶體管理

此外,由於我們只支援 go,我們工具是為 go 定製的,除了洗碗槽。(譯者補充:“洗碗槽”典故來自二戰時期的成語”everything but the kitchen sink” ,當時是指敵人炮火猛烈(除了洗碗槽外,各式各樣的炮彈齊發),現在指太多的東西)

包裝

go 語言有很多方法手動管理記憶體的方式,單著似乎無法解決我們需要解決的問題。

我認為我們應該關注一下像 Blade paper 提出的真正意義上的垃圾回收的問題的解決方法。

然而你真的沒有必要求助於這些工具。如果你發現自己在抱怨垃圾回收器,那麼你應該立刻拿出 pprof並責難那該死的垃圾回收器。如果真的是這樣,你確定要考慮改進它嗎?

相關文章