Golang strings.Builder 原理解析

背景

在很多场景中,我们都会进行字符串拼接操作。

最开始的时候,你可能会使用如下的操作:

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, "?")
        _ = buf.String()
    }
}

func BenchmarkBuilder(b *testing.B) {
    var builder strings.Builder
    for i := 0; i < b.N; i++ {
        fmt.Fprint(&builder, "?")
        _ = 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

阳春三月在成都

这是一篇月末总结,如果你是不小心被标题吸引误入,那么我更加建议你把这篇月志读完。既来之,则安之?

三月工作上发生了挺大变化,很多老朋友离开了,很多新同学加入了。无论是离开还是加入的人,里面都有自己喜欢和欣赏的人。成都的圈子只有这么大,鄙人都祝福他们。

自己又回到3年前的类似位置,做着类似的事情,此乃物是。三年前犯过的错误、苦恼纠结的事情,无一缺席,但是已经没有了当初面对类似事情的不确定性,此乃人非。

这个月重新找到了写字的愉悦感。在某些平台居然还被特别推荐过,我跟妞儿说这是一件funny的事情,没有开心的成分。算是把延期了好几年的课程补上了一小步。

在这个过程中,鄙人也开始逐渐体会到了自己订阅的公众号作者写作的内心感受。写作的目的可能有很多种,但是对于某一类人来说,写作就是一种习惯性的分配特定时间进行思考。这种思考可能在写作开始之前已经在潜意识中发生。写作就是讲这些思考的尘埃落定和梳理的过程。另外,根据我的观察,一般擅长写作的人,大多都是逻辑清晰且沟通起来很愉快的人。

三月挺幸运,包括今天。接触到了很多交流起来很愉快的人。鄙人跟妞儿说,遇到聪明的人的时候,有一种发自内心的欣赏和热爱。这种热爱跟基情没有关系,对于没有那么熟络的人,珍惜交流的效率和时间;对于熟悉的人,会聊很多平时不会聊的话题和看法。要知道,其实这个世界上,能够一起沟通和讨论一些深度想法的人太少太少了,遇到一个,怎能不喜?有那么一群人,注定就是寂寞。

马上而立之年了,责任随不至于越来越重,但是却只越来越真实。这个月似乎也硬着头皮处理了很多家庭上的事情。也许有时候过于追求效率,以及当面揭穿一些人的套路伎俩吧,给人的感觉是脾气太冲,经常惹恼很多人,包括家人。在这个点上,我从来不抱怨什么路长且累呀,什么不理解呀。我挺同意韩路的观点:如果你对目标足够清晰和强壮,那么做一些事的时候必然会有取舍和付出。想通了这件事,很多事情就是理所当然的,哪里还有什么抱怨。我花了挺长时间想清楚这个问题,也会有心情郁闷的时候,也会有到家以后,熄火在车里闷两分钟的时候,但是只要踏入家门,我必然是女儿的好爸爸,妞儿的好丈夫,父母的好儿子,而这一切,内心是举重若轻,毫无纠结的。

三月:

  • 最喜欢的物品:飞行棋手机架
  • 最喜欢的书:《原则》
  • 最惺惺相惜的人:至今不知道他的名字,但是却有过一次非常深入、愉快的交流
  • 财务目标一个都没有达到,且存在浮亏的款项
  • 独立项目新开了一个项目,看能走多远吧

三月开始,南方逐渐进入一年中最好的日子。在期待四月中,2018已经过去25%.