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

Golang 获取 goroutine id 完全指南

在Golang中,每个goroutine协程都有一个goroutine id (goid),该goid没有向应用层暴露。但是,在很多场景下,开发者又希望使用goid作为唯一标识,将一个goroutine中的函数层级调用串联起来。比如,希望在一个http handler中将这个请求的每行日志都加上对应的goid以便于对这个请求处理过程进行跟踪和分析。

关于是否应该将goid暴露给应用层已经争论多年。基本上,Golang的开发者都一致认为不应该暴露goid(faq: document why there is no way to get a goroutine ID),主要有以下几点理由:

  1. goroutine设计理念是轻量,鼓励开发者使用多goroutine进行开发,不希望开发者通过goid做goroutine local storage或thread local storage(TLS)的事情;
  2. Golang开发者Brad认为TLS在C/C++实践中也问题多多,比如一些使用TLS的库,thread状态非常容易被非期望线程修改,导致crash.
  3. goroutine并不等价于thread, 开发者可以通过syscall获取thread id,因此根本不需要暴露goid.

官方也一直推荐使用context作为上下文关联的最佳实践。如果你还是想获取goid,下面是我整理的目前已知的所有获取它的方式,希望你想清楚了再使用。

  1. 通过stack信息获取goroutine id.
  2. 通过修改源代码获取goroutine id.
  3. 通过CGo获取goroutine id.
  4. 通过汇编获取goroutine id.
  5. 通过汇编获取goroutine id.

在开始介绍各种方法前,先看一下定义在src/runtime/runtime2.go中保存goroutine状态的g结构:

其中goid int64字段即为当前goroutine的id。

1. 通过stack信息获取goroutine id

原理非常简单,将stack中的文本信息”goroutine 1234″匹配出来。但是这种方式有两个问题:

  1. stack信息的格式随版本更新可能变化,甚至不再提供goroutine id,可靠性差。
  2. 性能较差,调用10000次消耗>50ms。

如果你只是想在个人项目中使用goid,这个方法是可以胜任的。维护和修改成本相对较低,且不需要引入任何第三方依赖。同时建议你就此打住,不要继续往下看了。

2. 通过修改源代码获取goroutine id

既然方法1效率较低,且不可靠,那么我们可以尝试直接修改源代码src/runtime/runtime2.go中添加Goid函数,将goid暴露给应用层:

这个方式能解决法1的两个问题,但是会导致你的程序只能在修改了源代码的机器上才能编译,没有移植性,并且每次go版本升级以后,都需要重新修改源代码,维护成本较高。

3. 通过CGo获取goroutine id

那么有没有性能好,同时不影响移植性,且维护成本低的方法呢?那就是来自Dave Cheney的CGo方式:

文件id.c:

文件id.go:

完整代码参见junk/id.

这种方法的问题在于你需要开启CGo, CGo存在一些缺点,具体可以参见这个大牛的cgo is not Go. 我相信在你绝大部分的工程项目中,你是不希望开启CGo的。

4. 通过汇编获取goroutine id

如果前面三种方法我们都不能接受,有没有第四种方法呢?那就是通过汇编获取goroutine id的方法。原理是:通过getg方法(汇编实现)获取到当前goroutine的g结构地址,根据偏移量计算出成员goid int的地址,然后取出该值即可。

项目goroutine实现了这种方法。需要说明的是,这种方法看似简单,实际上因为每个go版本几乎都会有针对g结构的调整,因此goid int64的偏移并不是固定的,更加复杂的是,go在编译的时候,传递的编译参数也会影响goid int64的偏移值,因此,这个项目的作者花了非常多精力来维护每个go版本g结构偏移的计算,详见hack目录。

这个方法性能好,原理清晰,实际使用上稳定性也不错(我们在部分不太重要的线上业务使用了这种方法)。但是,维护这个库也许真的太累了,最近发现作者将这个库标记为“DEPRECATED”,看来获取goroutine id是条越走越远的不归路?

5. 通过汇编获取goroutine id

虽然方法4从原理和实际应用上表现都不错,但是毕竟作者弃坑了。回到我们要解决的问题上:我们并不是真的一定要获取到goroutine id,我们只是想获取到goroutine的唯一标识。那么,从这个角度看的话,我们只需要解决goroutine标识唯一性的问题即可。

显然,上面作者也想清楚了这个问题。他新开了一个库go-tls, 这个库实现了goroutine local storage,其中获取goroutine id的方式是:用方法4的汇编获取goroutine的地址,然后自己管理和分配goroutine id。由于它获取到的并不是真正的goroutine id,因此我将之称为goroutine id。其实现的核心代码如下:

  1. 获取g结构地址。
  2. 分配伪goroutine id.

这种方式基本没有什么不能接受的hack实现,从原理上来说也更加安全。但是获取到不是你最开始想要的goroutine id,不知你能否接受?

小结

获取goroutine id是一条不归路,目前也没有完美的获取它的方式。如果你一定要使用goroutine id,先想清楚你要解决的问题是什么,如果没有必要,建议你不要走上这条不归路。尽早在团队中推广使用context, 越早使用越早脱离对goroutine id的留恋和挣扎。

Credit

Golang多级内存池设计与实现

Golang多级内存池设计与实现

上个月,牙膏厂intel因为MeltdownSpectre两个bug需要给CPU固件和系统打了补丁。我们生产环境使用的是阿里云,打完补丁后,几台IO密集型的机器性能下降明显,从流量和cpu load估计,性能影响在50%左右,不是说好的最多下降30%麽?

在跑的业务是go写的,使用go pprof对程序profiling了一下,无意中发现,目前的系统gc和malloc偏高。其中ioutil.ReadAll占用了可观的CPU时间。

ioutil.ReadAll为什么慢?

这个函数的签名原型是func ReadAll(r io.Reader) ([]byte, error). 团队的小伙伴非常喜欢用这个函数,其中一个原因是这个函数可以将r中的数据一次性读完返回,不需要关心内存如何分配、如果分配的内存不够了,如何进行内存扩张等。作为一个util函数,这样设计是完全没问题的。但是,IO密集场景下,这个函数的开销就是你需要关心的了。这个函数实际调用realAll读取数据:

其中,capacity是常量值512. realAll函数在调用buf.ReadFrom进行数据读取:

看到这里,原因就非常清楚了:如果要读取的数据大小超过了初始buf大小(默认初始大小为512 bytes), 则会重新分配内存,并拷贝内容到新的buffer中。如果要读取的数据非常大,则会重复多次上述操作。那么优化的问题就转化为如何降低内存重分配和拷贝。

多级内存池的设计和实现

  1. 内存池被按照大小被分为多级。如上图所示,(0, 1024]使用level 0, (1024, 2048]使用level 1. 内存池分级有两个好处:
    1. 可以灵活的规划不同级别内存池的总大小和item数量,适应不同业务。
    2. 实现层面上,可以将一把内存池大锁拆分成多个小锁,减少锁争抢。
  2. 当已分配的内存池耗尽需要扩张时,一次性申请一大块内存,提高扩张效率。如level 0所示。
  3. 代码实现gmmpool,bench结果显示性能提高约19倍:

小结

对于频繁进行内存分配和释放的场景,使用内存池可以显著降低golang运行时的开销。同时也要注意,内存池的内存交给了用户管理,你需要小心检查是否存在内存泄露问题。如果你对性能要求没有这么苛刻,只是想复用一些小对象,那么我们推荐你使用标准库的sync.Pool.

另外,开头提到的阿里云性能问题,即使使用了内存池优化,结果还是非常悲剧。最后阿里云帮我们更换了没有打牙膏厂补丁的机器解决。是不是非常惊喜??