『每週譯 Go』談談 Go 中的內存

今天的文章來自於最近的 Go 代碼測試。請看下面的基準測試代碼。1

func BenchmarkSortStrings(b *testing.B) {
        s := []string{"heart""lungs""brain""kidneys""pancreas"}
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
                sort.Strings(s)
        }
}

作爲 sort.Sort(sort.StringSlice(s)) 的一個封裝,sort.Strings 對輸入進行了原地排序,所以它不應該被分配內存(至少 43% 的 tweeps 迴應者是這麼認爲的)。然而,事實證明至少在最近的 Go 版本中,benchmark 的每一次迭代都會導致一次堆內存分配。爲什麼會發生這種情況?

所有 Go 程序員都應該知道,接口是以兩個變量的結構來實現的。每個接口值都包含一個保存接口內容類型的字段,以及一個指向接口內容的指針。2

用 Go 的僞代碼來表述,一個接口可能看起來是這樣的:

type interface struct {
        // the ordinal number for the type of the value
        // assigned to the interface 
        type uintptr

        // (usually) a pointer to the value assigned to
        // the interface
        data uintptr
}

interface.data 在大多數情況下可以容納一個 8 字節變量,但[]string 是 24 個字節;其中一個變量指向切片底層數組的指針;一個變量是長度;還有一個變量是底層數組的剩餘容量,那麼 Go 是如何將 24 個字節裝入 8 個字節的呢?使用書中最古老的技巧 - 引用。例如,[]string 是 24 個字節,但 *[]string - 一個指向字符串切片的指針 - 只有 8 字節。

逃逸到堆

爲了使這個例子更加明確,我們去掉了 sort.Strings 函數:

func BenchmarkSortStrings(b *testing.B) {
        s := []string{"heart""lungs""brain""kidneys""pancreas"}
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
                var ss sort.StringSlice = s
                var si sort.Interface = ss // allocation
                sort.Sort(si)
        }
}

爲了使接口發揮作用,編譯器將賦值改寫爲 var si sort.Interface = &ss ss 的地址被分配給了接口值。現在的情況就變成接口值持有一個指向 ss 的指針,但它指向哪裏呢?ss 在內存中的什麼位置呢?

從 benchmark 報告中來看,似乎 ss 被分配到了堆上。

 Total:    296.01MB   296.01MB (flat, cum) 99.66%
      8            .          .           func BenchmarkSortStrings(b *testing.B) { 
      9            .          .            s := []string{"heart""lungs""brain""kidneys""pancreas"} 
     10            .          .            b.ReportAllocs() 
     11            .          .            for i := 0; i < b.N; i++ { 
     12            .          .             var ss sort.StringSlice = s 
     13     296.01MB   296.01MB             var si sort.Interface = ss // allocation 
     14            .          .             sort.Sort(si) 
     15            .          .            } 
     16            .          .           }

發生分配的原因是編譯器不能說服自己 sssi 存活時間長。Go 編譯器的黑客們的普遍態度好像是:這一點可以改進,但這又是另外一個話題了。目前,ss 被分配在堆中。因此問題就變成了,每次迭代分配多少個字節?我們可以用 testing 來看看。

% go test -bench=. sort_test.go 
goos: darwin
goarch: amd64 
cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHz 
BenchmarkSortStrings-4 12591951 91.36 ns/op 24 B/op 1 allocs/op 
PASS 
ok command-line-arguments 1.260s

在 amd64 平臺上使用 Go 1.16 beta1,每次操作分配 24 個字節。4 然而,在同一平臺上,之前的 Go 版本每次操作消耗 32 個字節。

% go1.15 test -bench=. sort_test.go 
goos: darwin 
goarch: amd64 BenchmarkSortStrings-4 11453016 96.4 ns/op 32 B/op 1 allocs/op 
PASS 
ok command-line-arguments 1.225s

這將把我們帶回這篇文章的主題,即 Go 1.16 中的一個有趣的改進。但在談論它之前,我需要討論一下內存尺寸級別 (size classes)。

內存尺寸級別 - Size classes

要解釋什麼是 size classes,讓我們思考一下 Go 運行時如何在堆上分配 24 字節。一個簡單的方法是用一個指向堆上最後分配的字節的指針來跟蹤到目前爲止分配的所有內存。要分配 24 個字節,堆的指針則要增加 24,並將前一個值返回給調用者。只要請求 24 字節的代碼不寫超這個標記,這個機制就沒有開銷。遺憾的是,在現實生活中,內存分配器並不只是分配內存,有時他們還必須釋放內存。

最終 Go 運行時將不得不釋放這 24 個字節,但從運行時的角度來看,它知道的是它給調用者的起始地址。它不知道這個地址之後分配了多少字節。爲了釋放內存,我們假設的 Go 運行時分配器必須爲堆上的每次分配記錄其長度。這些長度的分配在哪裏?當然是在堆上。

在我們的方案中,當運行時想分配內存時,它可以請求比被請求稍多一點的內存,並使用它來存儲所請求的數量。對於我們的切片例子,當我們請求 24 字節時,需要消耗 24 字節再加上一些開銷來存儲數字 24。這個開銷有多大?事實證明,最小的量是一個字面量。5

要記錄一個 24 字節的分配,開銷是 8 個字節。25% 不是很大,但也不小,隨着分配大小的增加,開銷會變得微不足道。然而,如果我們只想在堆上存儲 1 個字節,會發生什麼?開銷是請求數量的八倍,是否有更有效的方法來解決堆上少量數據的分配?

如果所有相同大小的內存都存儲在一起,而不是將長度與分配的內存存儲在一起,會發生什麼?如果所有長度爲 24 字節的內存都存儲在一起,那麼運行時將自動知道它們有多大。運行時只需要一個位來指示一個 24 字節的區域是否被使用。在 Go 中,這些區域被稱爲 size classes,因爲所有相同大小的內存都存儲在一起(想想學校的班級–所有的學生都是同一年級的,而不是 C++ 班級)。當運行時需要分配少量內存時,它會使用能容納請求內存的最小的 size classes 來執行操作。

不限大小的 size classes

現在我們知道了 size classes 的工作原理,還有一個問題是,它們被存儲在哪裏呢?毫不奇怪,size classes 的內存來自於堆。爲了最大限度地減少開銷,運行時從堆中分配一個較大的數量(通常是系統頁大小的倍數),然後將該空間用於分配單一尺寸的內存。但是,有一個問題。

如果分配大小的數量是固定的 (最好是小的),那麼分配一個大區域來存儲相同尺寸的東西是很好的,但是在通用語言中,程序可以在運行時分配任何尺寸。

例如,假設向運行時請求 9 字節。9 字節是一個不常見的大小,所以很可能需要爲 9 字節大小的內存建立一個新的 size classes。由於 9 字節的情況並不常見,因此很可能會浪費剩餘的 4 KB 或更大的分配空間。正因爲如此,size classes 的集合是固定的。如果沒有確切數額的 size classes 可用,則分配被四捨五入到下一個大小的 size classes。在我們的例子中,9 個字節可能被分配到 12 個字節的 size classes 中。3 字節的開銷總比整個 size classes 分配了大部分未使用的字節好。

總結

這是最後的總結。Go 1.15 沒有 24 字節大小的 size classes,所以 ss 的堆分配是在 32 字節大小的 size classes 中的。多虧了 Martin Möhrmann 的工作,Go 1.16 有了一個 24 字節大小的 size classes,這對分配給接口的 slice 值來說是非常合適的。

  1. 這不是對排序函數進行基準測試的正確方法,因爲在第一次迭代之後,輸入已經被排序。

  2. 這個聲明的準確性取決於所使用的 Go 版本。例如,Go 1.15 增加了將一些整數直接存儲在接口值中,省去了分配和引用的功能。然而,對於大多數的值,如果它不是已經有了指針類型,它的地址就會被存儲在接口值中。

  3. 編譯器在接口值的類型字段中會進行跟蹤,所以它記得分配給 si 的類型是 sort.StringSlice,而不是 *sort.StringSlice。

  4. 在 32 位平臺上,這個數字會減半,但是我們不回頭看。

  5. 如果你將分配限制在 4 G 或 64 kb,你可以使用較少的內存來存儲分配的大小,但這意味着分配的第一個字不是自然對齊的,所以在實踐中,使用少於一個字來存儲長度頭並不會達到有效節省的效果。

  6. 將相同大小的東西存儲在一起也是對抗內存碎片化的有效策略

  7. 這並不是一個牽強的場景,字符串有大小各不同,生成一個新大小的字符串可以像附加一個空格一樣簡單。

相關文章

  1. I’m talking about Go at DevFest Siberia 2017

  2. If aligned memory writes are atomic, why do we need the sync/atomic package?

  3. A real serial console for your Raspberry Pi

  4. Why is a Goroutine’s stack infinite ?

原文地址:

https://dave.cheney.net/2021/01/05/a-few-bytes-here-a-few-there-pretty-soon-youre-talking-real-memory

原文作者:dave

本文永久鏈接:https://github.com/gocn/translator/blob/master/2021/w38_memory_allocate.md

譯者:咔嘰咔嘰

校對:Cluas

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fF6LJBPvd-GXKGG3EhXM_A