這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
Go中實現手動記憶體配置的坑
2016-07-10
你一定想到過,分配一塊大的記憶體,然後從裡面切小的對象出來,手動管理對象分配。分配的開銷非常小,就是offset加一下。尤其是有些情境,釋放時直接把offset重設,就可以重用這塊空間了。實現手動記憶體配置的好處是,減少小對象數目,從而減少記憶體回收時的掃描開銷,降低延遲和提升整個效能。
想到不代表做過,做過會踩坑,這篇文章會把你可能要踩的坑都說一遍。不過先說結論:別這麼幹,不作死就不會死!
TL;DR
擴容
開始很容易想用make([]byte)
分配空間,如果大小不夠時,還可以進行擴容。這是第一個陷阱。
不要append,別讓它擴容。一旦發生擴容,會分配一塊新的空間,而舊的slice將不再有任何變數引用它,於是會被記憶體回收掉。等等!之前分配的對象還在裡面呢,被回收掉豈不傻逼了?
所以建議直接用固定大小的數組,而不是slice。如果想做成可增長的,用一個鏈表串起來。
const blockSize = 32*1024*1024 - 16type node struct { block [blockSize]byte off int next *node}type Allocator { head *node tail *node}
初始化
初始化是很容易漏掉的地方。重用之前的記憶體空間,如果忘記了初始化,分配出來的對象不是乾淨的。
一種方式是C的malloc語義,分配的對象空間就是不初始化的,使用者自己去處理。比如:
t := (*T)(ac.Alloc(sizeT))*t = T{a:3, b:5}
另一種做法可以在Reset的時候把整塊空間清除一遍,這樣分配出去的都是初始化為零的。
對象內部存在引用
現在分配器的介面是這樣子的:
func (ac *Allocator) Alloc(size int) unsafe.Pointer
你覺得沒什麼問題了,拿它來指派至,結果使用時卻遇到莫名奇妙的記憶體錯誤。為什麼呢?
假設用它來指派至T:
type T struct { s *S}t := (*T)(ac.Alloc(sizeT))t.s = &S{}
T對象的空間是從一塊數組裡面划出來的,記憶體回收其實並不知道T這個對象。不過只要Allocator裡面的大塊記憶體不被回收,T對象還是安全的。但是,對於T裡面的S,它是標準方式分配的,這就會有問題了。
假設發生記憶體回收了,GC會以為那塊記憶體空間就是一個大的數組,而不會被掃描對象T,那麼t.s的空間未被任何對象引用到,它會被清理掉。最後t.s就變成一個懸掛指標了!
這樣實現的分配器只能處理兩種情況,一種是用於指派至裡面不包含其它引用。另一種,對象裡包含引用,但引用的對象空間也是在這個分配器裡面。
string的處理
我們的分配器不能分配包含引用的對象,這條限制是很嚴格的。假設T是:
type T struct { name string}
這樣子都是不行的!string其實就是典型參考型別,它是一個指標加一個長度,指標指向實現的資料。你明白了吧,這樣的約束之後分配器幾乎就不可用了。
為了能處理引用,需要改造一下。我們加一個Prevent介面:
func (ac *Allocator) Prevent(v interface{}) { ac.ref = append(ac.ref, v)}
在Allocator裡面加一個ref []interface
,把引用的對象都加進去,這樣子記憶體回收就不會把引用到的資料清掉了。
slice的處理
slice也是參考型別,處理起來更複雜一些。坑也更深,留點空間給大家去想了。
最後,當你把這些都考慮足夠充分後,就發現跟初衷相違了。
本希望是一個簡單的分配器來手動管理記憶體,可以減少對象分配,可以減少記憶體回收的掃描----但是不掃描就可能把還在使用的對象回收掉。為了處理,我們必須把對象的引用再加回去,減少對象掃描的努力成了無用功。再注意到Prevent的介面是interface類型,傳參時其實會產生一個臨時對象的,於是減少對象分配也沒做到。