每天寫 Go 的人十有八九都碰過bytes.Buffer。協議解析要緩衝、日誌拼裝要緩衝、HTTP body 讀取也要緩衝,很多人只是把它當成一個會自動長大的[]byte來用,但真要我講清楚裡面的off、lastRead、tryGrowByReslice到底幹了什麼,不少人就卡殼了。Golang bytes 包 Buffer 源碼分析這個話題,其實沒有想像中那麼難,標準庫就那麼幾百行 C 風格很濃的 Go 程式碼,關鍵是理解它把「已讀」和「未讀」分開的思路。這篇文章適合已經能寫 Go、但對標準庫內部沒仔細看過的人,也適合面試前想臨時把 Buffer 搞明白的人。我會按讀寫路徑、擴容策略、記憶體釋放和實戰坑點一層層拆開。
buffer 讀寫的核心不是靠複雜的鏈表,也不是每個 Read 都拷貝一次,而是靠一個「游標」把同一段底層陣列切成兩塊:前面是已經讀過、可以丟掉的資料,後面是還沒讀的資料。理解了這件事,再看Write、Grow、Truncate、WriteTo這些方法,邏輯全都是通的。
1. 先看資料結構:Buffer 怎麼記住讀到哪裡
1.1 核心欄位:buf、off、lastRead
bytes.Buffer的結構非常樸素,去掉註釋後是這樣:
type Buffer struct { buf []byte off int lastRead readOp rune [utf8.UTFMax]byte }真正的主角只有buf和off。
buf是底層的位元組陣列,所有寫入的資料都存在這裡。off是「已經讀了多少」的偏移量,也可以理解成游標。未讀資料永遠是buf[off:],而已讀資料是buf[:off],這段空間在後續的讀寫中可以被丟棄或重用。
lastRead記錄上一個操作是讀、寫還是無效狀態,主要給UnreadByte、UnreadRune用。rune欄位則是專門存ReadRune解碼出來的最後一個字元,這樣UnreadRune才能把狀態恢復回去。
所以Len()不是len(b.buf),而是len(b.buf)-b.off。換句話說,Len()返回的是「還沒讀的資料量」。Cap()則直接返回底層陣列容量cap(b.buf),跟游標位置無關。
這幾個欄位的關係可以看下面這張示意圖:
底層陣列 buf(cap = 8) +---+---+---+---+---+---+---+---+ | A | B | C | D | E | | | | +---+---+---+---+---+---+---+---+ ↑ ↑ 已讀區域 未讀區域起點 buf[:off] buf[off:] = B C D E假設初始寫入了 A、B、C、D、E,然後讀掉了 A,此時off = 1,len(buf) = 5,Len() = 4。看起來 B、C、D、E 還是排在陣列後面,只是游標移到 1 的位置,沒有真正刪除任何資料。
1.2 零值 Buffer 為什麼可以直接用
var b bytes.Buffer的零值是安全的,這是 Go 標準庫少數幾個「零值可用」的容器。原理很簡單:所有欄位都是零值,buf是 nil,off是 0,lastRead是opInvalid,第一次寫入時會走正常的初始化路徑。
第一次寫入小資料時,tryGrowByReslice會發現len(buf)+n <= cap(buf)不成立,因為cap(nil) = 0,於是進入grow。grow看到buf == nil且n <= smallBufferSize,會直接分配一塊帶預留空間的 slice:
b.buf = make([]byte, n, smallBufferSize)這裡的smallBufferSize是 64。也就是說,你往一個零值 Buffer 寫 10 個位元組,底層會直接配一個長度 10、容量 64 的陣列。這個預留空間是標準庫故意做的,為了避免頻繁的小寫入反覆觸發擴容。
要特別強調:不是所有容器零值都能用。比如map和slice可以零值操作,但bytes.Buffer之所以能零值用,是因為它自己不持有鎖、沒有外部資源,所有欄位都是可寫的普通值。如果你把bytes.Buffer放進結構體,零值也能直接當成io.Writer傳出去用。
1.3 off 只會增加,不會每次讀都「搬資料」
很多人第一次看Read實作時會覺得奇怪:為什麼讀完 A 之後,不把 B、C、D、E 往前搬一格?
答案很簡單:搬資料是 O(n) 操作,如果每次都搬,讀 N 個位元組就是 O(N²)。Buffer 的做法是讓off一直往後走,只要底層陣列還沒滿,寫入的新資料就會接在len(buf)的後面,不會去動已讀區域和未讀區域。
舉個例子,初始有 B、C、D、E 未讀,off = 1,len(buf) = 5。此時寫入 F,Write會先擴展buf到長度 6,再把 F 拷到索引 5 的位置。底層陣列變成:
+---+---+---+---+---+---+ | A | B | C | D | E | F | +---+---+---+---+---+---+ ↑ ↑ off=1 len=6Len()變成 5,Bytes()返回buf[1:],也就是 B、C、D、E、F。因為新資料本來就在未讀資料後面,所以是不需要搬移的,天然就是連續的。
當off追上len(buf),也就是所有資料都讀完了,下一次Read會直接Reset(),把off歸零、len(buf)歸零。底層容量不變,但等於宣告這塊記憶體可以重新從頭開始寫。
這套設計讓 Buffer 在「頻繁讀一點、寫一點」的場景下非常高效:大部分操作都只是移動整數、複製一小段資料,很少做整體搬移。
2. 寫入路徑:Write、WriteString 與擴容策略
2.1 Write 的 reslice 快速路徑
Buffer.Write的程式碼非常短,核心思路是先嚐試不分配記憶體,直接在底層陣列的剩餘空間裡擴展:
func (b *Buffer) Write(p []byte) (n int, err error) { b.lastRead = opInvalid m, ok := b.tryGrowByReslice(len(p)) if !ok { m = b.grow(len(p)) } return copy(b.buf[m:], p), nil }tryGrowByReslice做的事情是判斷目前容量是否夠用:
func (b *Buffer) tryGrowByReslice(n int) (int, bool) { if l := len(b.buf); l+n <= cap(b.buf) { b.buf = b.buf[:l+n] return l, true } return 0, false }如果len(buf)+n不超過cap(buf),那就只需要把buf切長一點,不需要分配新陣列。返回值l是舊長度,也就是新資料要拷貝的起始位置。
這條路徑非常常見。比如你Grow(1024)之後,後面連續寫好幾段小資料,只要總量沒超過容量,就永遠不會發生第二次分配。
還有一個容易被忽略的細節:Write開頭會把lastRead設為opInvalid。這是為了讓UnreadByte知道「你上一步是寫不是讀,不能回退」。就算寫入資料量是 0,狀態也要被清掉。
WriteString和Write的邏輯幾乎一樣,只是第二個參數從[]byte變成string。標準庫沒有把 string 先轉成[]byte,而是直接copy(b.buf[m:], s),這樣就省掉一次不必要的切片轉換和可能的分配。
2.2 grow 的「兩倍擴容」與資料規整
當容量真的不夠時,tryGrowByReslice會失敗,接下來就是grow的工作:
func (b *Buffer) grow(n int) int { m := b.Len() if m == 0 && b.off != 0 { b.Reset() } if i, ok := b.tryGrowByReslice(n); ok { return i } if b.buf == nil && n <= smallBufferSize { b.buf = make([]byte, n, smallBufferSize) return 0 } c := cap(b.buf) if n <= c/2 { c = 2*c + n } else { c = 2*(c+n+1) } buf := makeSlice(c) copy(buf, b.buf[b.off:]) b.buf = buf[:m+n] b.off = 0 return m }我第一次看這段程式碼時,最困惑的是資料拷貝。擴容明明是為了多寫 n 個位元組,為什麼copy只拷貝b.buf[b.off:]?
因為off之前的資料已經讀過了,不需要保留。擴容的同時,順便把未讀資料搬到新陣列的開頭,off歸零。這樣一來,之後的Len()依然等於m,未讀資料的位置變簡單了,已讀部分留下的空洞也被順手清掉。
容量計算也很有講究。如果n比較小,新容量是2*c + n,這保證了均攤的 O(1) 擴充成本;如果n很大,超過了舊容量的一半,就直接2*(c+n+1),避免為了塞一個大資料塊而不斷翻倍。
程式碼前面還有一個「如果 Buffer 已經空了,先 Reset 回收空間」的步驟。假設你讀完所有資料後,off == len(buf),此時即使容量足夠,繼續在尾巴上追加資料也能用,但已讀區域佔著前面的空間不划算,所以grow會先 Reset,讓後續的寫入從頭開始用這塊陣列。
這裡的makeSlice本質上就是make([]byte, c),標準庫單獨抽一個函數出來,是為了方便追蹤內部緩衝區的分配情況,對外部使用者來說沒有特殊行為。
2.3 Grow 是預留空間,不是立即填資料
很多人會把Grow誤解成「先塞 n 個零位元組進去」,其實它只是「保證接下來至少有 n 個位元組的可用空間」:
func (b *Buffer) Grow(n int) { if n < 0 { panic("bytes.Buffer.Grow: negative count") } m := b.grow(n) b.buf = b.buf[:m] }注意最後一行:grow(n)內部可能把b.buf的長度擴展到了m+n,但Grow又把它縮回m。這樣做的結果是,容量增加了,長度不變,Len()也不會突然多出一堆假資料。
Grow適合你明確知道接下來要寫多少資料的場景,例如要序列化一個長度為 4KB 的結構體時,先Grow(4096),後面再多次Write就能避免反覆擴容。既然擴容是拷貝未讀資料的過程,預留空間能省掉的不只是記憶體分配,還有拷貝成本。
Go 1.21 之後,bytes.Buffer還多了Available()和AvailableBuffer(),作用是直接暴露底層陣列尾部的空閒空間。AvailableBuffer()返回一個長度為 0、容量等於剩餘空間的切片,你可以把它當作暫存區使用,組好資料後再b.Write(buf)提交。這個 API 對寫自訂編碼器的人比較友好,不過日常開發中用得沒那麼頻繁。
3. 讀取路徑:Read、Unread 與記憶體整理
3.1 Read 是移動游標,不是刪資料
Read的實作同樣非常精簡:
func (b *Buffer) Read(p []byte) (n int, err error) { b.lastRead = opInvalid if len(p) == 0 { return 0, nil } if b.off >= len(b.buf) { b.Reset() return 0, io.EOF } n = copy(p, b.buf[b.off:]) b.off += n if n > 0 { b.lastRead = opRead } return n, nil }讀取時,資料從buf[off:]拷到呼叫者傳進來的p,然後off += n。底層陣列裡被讀過的資料還在原地,只是游標越過去了。
如果off已經大於等於len(buf),代表沒有未讀資料了,此時會先Reset(),再返回io.EOF。這就造成一個小細節:當你讀完 Buffer 的最後一個位元組後,下一次Read才會觸發 Reset;如果你永遠不呼叫第二次Read,off和buf會保持原樣。
Read支援部分讀取:如果p的長度小於未讀資料量,就只拷len(p)個位元組,剩下的留在 Buffer 裡。這和io.Reader的語義一致。
3.2 Next、ReadByte 與 lastRead 的回退機制
除了Read,Buffer 還有幾個低階讀方法。Next(n)直接返回內部切片的一部分並前進off,完全不做拷貝:
func (b *Buffer) Next(n int) []byte { m := b.Len() if n > m { n = m } data := b.buf[b.off : b.off+n] b.off += n if n > 0 { b.lastRead = opRead } return data }這對解析 protocol header 非常方便:先Next(4)拿長度欄位,再根據長度Next(rest)。但要注意,返回的切片直接指向 Buffer 底層陣列,如果你把它存到別處,又繼續往 Buffer 裡寫資料,後續內容可能被覆蓋。
UnreadByte是lastRead存在的意義。標準庫不允許你隨便回退,只有最近一次操作是成功的讀操作時,才能UnreadByte。如果中間發生過Write,lastRead已經變成opInvalid,呼叫UnreadByte會返回ErrInvalidUnread。
ReadRune更複雜一點,它會用utf8.DecodeRune解碼一個 UTF-8 字元,把解碼後的位元組存在 Buffer 的rune欄位裡。之後呼叫UnreadRune時,就可以從rune欄位還原上一次讀到的字元。這個狀態也是單一的,你不能連續兩次UnreadRune。
要注意的是,lastRead只能記住「最近一次」操作。如果你ReadRune之後又ReadByte,再呼叫UnreadRune就會失敗,因為最近一次成功讀操作不是ReadRune。
3.3 記憶體整理不是每次 Read 都做
很多從 C 語言過來的人會擔心:一直讀一直讀,off會不會無限變大?底層陣列會不會被已讀垃圾佔滿?
答案是不會無限佔滿,但整理時機不是「每次 Read」這麼簡單。
真正的大整理有兩個時機:
第一個時機是資料全部讀完後,off >= len(buf),下一次讀或下一次grow會 Reset 整個 Buffer。此時buf長度歸零,底層容量還在,後續寫入從頭開始用同一塊記憶體。
第二個時機是擴容時。grow需要新陣列,它只會拷貝buf[off:]的未讀內容,已讀內容直接丟棄。這等於把資料「壓縮」到新陣列開頭,同時off歸零。
所以在典型的「邊寫邊讀」場景中,Buffer 會自動把記憶體控制在合理範圍,不需要你手動整理。真正要警惕的不是off變大,而是你一直不讀完資料、又持續寫入導致的容量膨脹。這種情況下 Buffer 為了容納更多未讀資料,會不斷擴容,屬於正常的空間換時間行為。
4. 常用方法的實作細節:Bytes、String、Truncate、Reset
4.1 Bytes 返回裸切片,String 卻一定要拷貝
Bytes()的實作是:
func (b *Buffer) Bytes() []byte { return b.buf[b.off:] }這是一個零拷貝操作,直接返回底層陣列中未讀部分的切片。
所以它很快,但有代價:返回的切片只是 Buffer 內部記憶體的「視圖」。官方文檔明確定義,這個切片只在下次修改 Buffer 之前有效。如果你在Write之後繼續使用舊的Bytes()結果,讀到的可能不是你想像中的資料。
String()就不一樣了:
func (b *Buffer) String() string { if b == nil { return "<nil>" } return string(b.buf[b.off:]) }這看起來也是返回一個視圖,但 Go 語言裡[]byte轉string的語義是「複製一份資料」,因為 string 是不可變的。假如String()直接返回底層陣列的 unsafe 指標,你之後再往 Buffer 裡寫資料,之前已經拿到的 string 內容就會改變,這完全違背了 string 的不可變承諾。
所以String()每次呼叫都可能有一次記憶體分配和拷貝。如果你在一個熱點路徑上頻繁呼叫String(),效能會很難看。我自己踩過一次:迴圈裡每次處理一小段資料都呼叫String()存到 map,結果 GC 壓力和 CPU 使用率同時爆炸。正確做法是只轉換一次,或者直接用strings.Builder來拼字串。
strings.Builder和bytes.Buffer長得很像,但Builder.String()在內部用 unsafe 做到了零拷貝,因為 Builder 設計上就不允許你讀取底層資料,所以可以保證字串不可變。如果只是拼字串,優先選 Builder;如果要同時做讀寫、io.Reader/Writer對接,才選 Buffer。
4.2 Truncate 與 Reset 到底保留了什麼
Truncate(n)的語義是「丟棄未讀資料中除了前 n 個位元組之外的所有內容」。n 為 0 等於 Reset,n 大於當前Len()會 panic。
實作上它是把buf的長度縮短到off+n:
func (b *Buffer) Truncate(n int) { if n == 0 { b.Reset() return } if n < 0 || n > b.Len() { panic("bytes.Buffer: truncation out of range") } b.buf = b.buf[:b.off+n] }注意off沒有變,而是buf總長度縮短了。所以Len()仍然是len(buf)-off = n。這種設計讓已讀資料還留在原地,但新寫入可能覆蓋它,或者下一次Read讀完後 Reset。
Reset()則是把off和buf長度都歸零:
func (b *Buffer) Reset() { b.buf = b.buf[:0] b.off = 0 b.lastRead = opInvalid }Reset不會把底層陣列清空,也不會釋放容量。這是高效複用 Buffer 的關鍵:你可以在迴圈裡反覆Reset、Write、Bytes,完全不需要重新分配記憶體,因為底層的 cap 一直在。
但這個特性也是記憶體洩漏的來源。如果一個 Buffer 曾經緩衝過 100MB 的資料,Reset 之後它仍然佔著大約 100MB 的容量。只要外部還持有這個 Buffer 指標,GC 就沒辦法回收那塊記憶體。長時間存活的結構體裡如果藏著一個大 Buffer,就要特別小心。
4.3 WriteTo 與 ReadFrom 的隱藏行為
WriteTo(w io.Writer)把未讀資料寫到 w:
func (b *Buffer) WriteTo(w io.Writer) (n int64, err error) { if nBytes := b.Len(); nBytes > 0 { m, e := w.Write(b.buf[b.off:]) b.off += m n = int64(m) if e != nil { return n, e } if m != nBytes { return n, io.ErrShortWrite } } b.Reset() return n, nil }它會一次把整塊未讀資料交給w.Write,如果w.Write沒有寫完就回ErrShortWrite。寫完之後 Reset,表示整塊資料已經消費完畢。
ReadFrom(r io.Reader)正好相反,它從 r 讀資料填進 Buffer。這裡有一個隱藏的擴容策略:標準庫不會每次只讀一點點,而是一邊讀一邊保證尾部至少有MinRead的空間,MinRead預設是 512。如果尾部空間不夠,它會先把未讀資料往前搬,或者直接擴容。
用ReadFrom讀一個無限大的 reader 時,Buffer 會因為擴容策略出現「短時間佔用兩倍容量」的現象。如果你明確知道要讀的資料很大,而且最終要寫到檔案或網路,用io.Copy搭配固定大小的[]byte緩衝區通常更可控。Buffer 適合的是「資料整體在記憶體裡處理」的場景,不適合當作大檔案的傳輸通道。
5. 實戰中我踩過的那些坑與效能優化技巧
5.1 Buffer 不是併發安全的
bytes.Buffer沒有任何鎖,所有方法都是直接操作底層buf和off。兩個 goroutine 同時寫一個 Buffer,輕則資料交錯,重則直接 panic,因為 slice 的擴容和讀取可能同時發生。
標準庫對它的使用規則很簡單:同一個 Buffer 同一時間只能被一個 goroutine 使用。如果你需要多個 goroutine 協作,常見做法有三種:
- 加
sync.Mutex或sync.RWMutex把bytes.Buffer包一層; - 用 channel 把資料送到唯一一個消費 goroutine,Buffer 只在這個 goroutine 內使用;
- 每個 goroutine 各自用一個 Buffer,最後再把結果合併或寫到輸出。
在網路服務裡最常見的錯誤是:每個連線一個 goroutine,大家共用一個全域 Buffer 來拼回應。這個問題用go test -race很容易抓,但很多人上線前根本沒跑。
5.2 長時間存活的 Buffer 如何釋放底層記憶體
前面說 Reset 不釋放容量,這在短生命週期場景沒問題,但在以下情況會變成記憶體炸彈:
- Buffer 是結構體欄位,長時間存活;
- 業務高峰時緩衝過特別大的資料;
- 高峰過後,結構體還一直存在。
解法很樸素:如果確定這個 Buffer 不再需要大容量,就重新賦值一個新 Buffer:
buf = bytes.NewBuffer(nil)這樣舊 Buffer 失去引用,底層大陣列可以被 GC 回收。
如果你在用sync.Pool管理 Buffer,也要小心:Pool 裡的物件不會主動清理,一個緩衝過 64MB 資料的 Buffer 會一直在 Pool 裡佔著記憶體。建議在Put回去前檢查Cap(),超過某個閾值就丟棄,不要放回 Pool:
if buf.Cap() > 4<<20 { return } pool.Put(buf)這個小習慣可以讓服務在「高峰大包、低峰小包」的場景下,記憶體不會被釘在高水位。
5.3 高效使用模式:Reset、Grow 與 ReadFrom 搭配
我自己做網路協議解析時,最常用的模式是:一個 Buffer 物件反覆用,配合Grow預留空間,最後用Bytes()讀取未讀資料。
var buf bytes.Buffer for { buf.Reset() buf.Grow(4096) _, err := io.Copy(&buf, conn) if err != nil { break } handle(buf.Bytes()) }這裡每次迴圈先 Reset,再把 4KB 的空間預留好,後續io.Copy寫入時就不會頻繁擴容。注意handle裡如果有把buf.Bytes()保存到其他 goroutine 的需求,必須先複製:
data := append([]byte(nil), buf.Bytes()...)因為buf.Bytes()指向的是內部陣列,下一次迴圈 Reset 和 Write 可能直接覆蓋它。
如果你要把一個io.Reader的資料完整收進 Buffer,直接用ReadFrom通常比手動Read再Write更快,因為它知道如何利用 Buffer 尾部的空閒空間,也能用MinRead減少系統呼叫次數。只是要注意,讀超大資料時它可能短暫佔用兩倍記憶體。
5.4 常見問題速查表
| 問題 | 原因 | 解法 |
|---|---|---|
Bytes()拿到的資料過一段時間變了 | 返回的是內部切片,後續寫入會覆蓋 | 需要長期持有時用append([]byte(nil), b.Bytes()...)複製 |
String()呼叫很多,效能變差 | []byte轉string會分配並拷貝 | 只轉換一次;純拼字串改用strings.Builder |
Grow(-1)panic | Grow只接受非負數 | 呼叫前判斷大小,或把 panic 當 bug 處理 |
Truncate超出長度 panic | n 大於Len() | 先檢查b.Len()再截斷 |
| 多 goroutine 共用 Buffer 出錯 | Buffer 無鎖,非併發安全 | 加鎖、用 channel 串列化,或每個 goroutine 各自持有 |
| Reset 後記憶體沒降下來 | Reset 保留 cap | 重新賦值新 Buffer,或放入 Pool 前檢查 Cap |
用NewBuffer(existing)後原始切片被改 | NewBuffer 不複製,直接使用傳入切片 | 需要獨立資料時先append([]byte(nil), data...) |
最後再說一個我實際使用中的體會:bytes.Buffer的源碼是學習 Go slice 底層機制非常好的教材。它不依賴任何黑魔法,只是把append、copy、reslice這幾招用到了極致。真正讀懂off和grow之後,你寫自訂緩衝區、解析二元協議、設計記憶體複用池都會有完全不同的視角。如果在效能調查中發現 Buffer 相關程式碼佔比高,先別急著換第三方庫,通常問題出在String()重複拷貝、Bytes()被意外保留,或者容量一直沒有釋放這三件事上。