Golang strings.Builder 原理解析

AlexaMa發表於2018-04-08

背景

在很多場景中,我們都會進行字串拼接操作。

最開始的時候,你可能會使用如下的操作:

package main

func main() {
    ss := []string{
        "A",
        "B",
        "C",
    }

    var str string
    for _, s := range ss {
        str += s
    }

    print(str)
}

與許多支援 string 型別的語言一樣,golang 中的 string 型別也是隻讀且不可變的。因此,這種拼接字串的方式會導致大量的 string 建立、銷燬和記憶體分配。如果你拼接的字串比較多的話,這顯然不是一個正確的姿勢。

在 Golang 1.10 以前,你可以使用bytes.Buffer來優化:

package main

import (
    "bytes"
    "fmt"
)

func main() {
    ss := []string{
        "A",
        "B",
        "C",
    }

    var b bytes.Buffer
    for _, s := range ss {
        fmt.Fprint(&b, s)
    }

    print(b.String())
}

這裡使用 var b bytes.Buffer 存放最終拼接好的字串,一定程度上避免上面 str 每進行一次拼接操作就重新申請新的記憶體空間存放中間字串的問題。

但這裡依然有一個小問題: b.String() 會有一次 []byte -> string 型別轉換。而這個操作是會進行一次記憶體分配和內容拷貝的。

使用 strings.Builder 進行字串拼接

如果你現在已經在使用 golang 1.10, 那麼你還有一個更好的選擇:strings.Builder:

package main

import (
    "fmt"
    "strings"
)

func main() {
    ss := []string{
        "A",
        "B",
        "C",
    }

    var b strings.Builder
    for _, s := range ss {
        fmt.Fprint(&b, s)
    }

    print(b.String())
}

Golang 官方將strings.Builder作為一個 feature 引入,想必是有兩把刷子。不信跑個分?簡單來了個 benchmark:

package ts

import (
    "bytes"
    "fmt"
    "strings"
    "testing"
)

func BenchmarkBuffer(b *testing.B) {
    var buf bytes.Buffer
    for i := 0; i < b.N; i++ {
        fmt.Fprint(&buf, "ABC")
        _ = buf.String()
    }
}

func BenchmarkBuilder(b *testing.B) {
    var builder strings.Builder
    for i := 0; i < b.N; i++ {
        fmt.Fprint(&builder, "ABC")
        _ = builder.String()
    }
}
╰─➤  go test -bench=. -benchmem                                                                                                                         2 ↵
goos: darwin
goarch: amd64
pkg: test/ts
BenchmarkBuffer-4         300000        101086 ns/op      604155 B/op          1 allocs/op
BenchmarkBuilder-4      20000000            90.4 ns/op        21 B/op          0 allocs/op
PASS
ok      test/ts 32.308s

效能提升感人。要知道諸如 C#, Java 這些自帶 GC 的語言很早就引入了string builder, Golang 在 1.10 才引入,時機其實不算早,但是巨大的提升終歸沒讓人失望。下面我們看一下標準庫是如何做到的。

strings.Builder 原理解析

strings.Builder的實現在檔案strings/builder.go中,一共只有 120 行,非常精煉。關鍵程式碼摘選如下:

type Builder struct {
    addr *Builder // of receiver, to detect copies by value
    buf  []byte // 1
}

// Write appends the contents of p to b's buffer.
// Write always returns len(p), nil.
func (b *Builder) Write(p []byte) (int, error) {
    b.copyCheck()
    b.buf = append(b.buf, p...) // 2
    return len(p), nil
}

// String returns the accumulated string.
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))  // 3
}

func (b *Builder) copyCheck() {
    if b.addr == nil {
        // 4
        // This hack works around a failing of Go's escape analysis
        // that was causing b to escape and be heap allocated.
        // See issue 23382.
        // TODO: once issue 7921 is fixed, this should be reverted to
        // just "b.addr = b".
        b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
    } else if b.addr != b {
        panic("strings: illegal use of non-zero Builder copied by value")
    }
}

  1. byte.Buffer思路類似,既然 string 在構建過程中會不斷的被銷燬重建,那麼就儘量避免這個問題,底層使用一個 buf []byte 來存放字串的內容。
  2. 對於寫操作,就是簡單的將 byte 寫入到 buf 即可。
  3. 為了解決bytes.Buffer.String()存在的[]byte -> string型別轉換和記憶體拷貝問題,這裡使用了一個unsafe.Pointer的存指標轉換操作,實現了直接將buf []byte轉換為 string 型別,同時避免了記憶體充分配的問題。
  4. 如果我們自己來實現 strings.Builder, 大部分情況下我們完成前 3 步就覺得大功告成了。但是標準庫做得要更近一步。我們知道 Golang 的堆疊在大部分情況下是不需要開發者關注的,如果能夠在棧上完成的工作逃逸到了堆上,效能就大打折扣了。因此,copyCheck 加入了一行比較 hack 的程式碼來避免 buf 逃逸到堆上。關於這部分內容,你可以進一步閱讀 Dave Cheney 的關於Go’s hidden #pragmas.

就此止步?

一般 Golang 標準庫中使用的方式都是會逐步被推廣,成為某些場景下的最佳實踐方式。

這裡使用到的*(*string)(unsafe.Pointer(&b.buf))其實也可以在其他的場景下使用。比如:如何比較string[]byte是否相等而不進行記憶體分配呢?似乎鋪墊寫得太明顯了,大家應該都會寫了,直接給出程式碼吧:

func unsafeEqual(a string, b []byte) bool {
    bbp := *(*string)(unsafe.Pointer(&b))
    return a == bbp
}

擴充套件閱讀

The State of Go 1.10

Originally published on liudanking.com

更多原創文章乾貨分享,請關注公眾號
  • Golang strings.Builder 原理解析
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章