Go語言的手工內存管理

pykde 9年前發布 | 38K 次閱讀 Go語言 Google Go/Golang開發

介紹

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

我們從大量的 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并責難那該死的垃圾回收器。如果真的是這樣,你確定要考慮改進它嗎?

 本文由用戶 pykde 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!