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

Dropbox Bandaid 微服务反向代理/Sevice Mesh 代理解析

随着微服务架构以及的广泛普及,很多公司都会使用或者自行开发自己的API Gateway, 甚至在内部服务也会应用Service Mesh.

不久前,看到了一篇Dropbox公司介绍其内部服务代理Bandaid的文章: Meet Bandaid, the Dropbox service proxy. 不得不说设计细节一贯独角兽风格,非常有收获,对于改进我们自己设计的proxy 也有一定参考意义。这里做一个简单的读后笔记。英语好的同学可以直接阅读原文。(插一句,Dropbox不久前在美股上市了,在中美贸易战中为数不多逆势上涨的股票之一,玩美股的同学可以关注一下。再插一句:股市有风险,投资须谨慎。)

Bandaid 诞生的背景

Bandaid 是有公司内部的反向代理服务演变而来,使用Golang实现。反向代理有很多成熟的解决方案,之所以选择自行开发主要有以下几个原因:

  • 更好的与与内部的基础设施集成
  • 可以复用公司内部基础库(更好的与内部代码集成)
  • 减少对外部的依赖,团队可以灵活的按需开发
  • 更适合公司内某些特殊使用场景

上面的大部分因素根我们进行微服务组件开发时候的考量基本一致。这也是我们当初没有使用Go kit 这种工具套装进行架构微服务改造的顾虑,当然,那个时候还没有这些工具链。

Bandaid 的特性

  • 支持多种负载均衡策略 (round-robin, least N random choices, absolute least connection, pinning peer)
  • 支持 https to http
  • 上下游支持 http2
  • 路由改写
  • 缓存请求与响应
  • host级别的逻辑隔离
  • 配置热加载
  • 服务发现
  • 路由信息统计
  • 支持gRPC代理
  • 支持HTTP/gRPC健康检查
  • 流量支持按权重分配和金丝雀测试

丰富的负载均衡策略,以及对HTTP/2和gRPC的支持实例亮点。要知道,nginx 从 1.13.10才开始支持gRPC.

另一方面,从支持的特性看,如果要求不是特别多,直接用来作为Service Mesh也是相当不错的。而且由于是使用Go开发,对于本来就在使用Go作为技术栈的团队来说,无论是使用还是二次开发,门槛和学习成本都是很低的。

Bandaid 设计解析

整体架构

请求队列设计

接收的请求按照LIFO 后入先出的方式进行处理。这个设计有点反直觉,但却是合理的:

  • 绝大部分情况下,队列应该是空或者接近空的状态。因此LIFO的策略并不会恶化队列最大等待时间。
  • 根据业务类型,可以配置队列的优先级和长度。可以非常方便的实现服务限流、服务降级和熔断器。
  • Bandaid 采用总是接收TCP连接,并将连接交由用户态管理的策略。结合LIFO有一个很大的好处:
    • 与内核态管理连接比较,如果客户端发送请求后意外关闭了TCP连接,Bandaid 是无法马上获取到该错误的,需要等到读取完该请求,然后处理请求后开始写response时才会触发错误,发现这个连接其实已经关闭。因此,处理这类请求是在无谓的消耗服务器资源。而采用LIFO和用户态管理连接的话,Bandaid 可以根据配置的超时策略,一定程度上drop掉这类请求,减少处理这种「dead request」的数量。

Worker 设计

Worker采取固定大小的工作池设计,一方面可以精确的控制并发数量,另一方面,也避免频繁创建worker的开销。不过文中也承认,池的大小在设置的时候需要结合业务考虑清楚,否则可能不能充分利用服务器资源,错误的触发服务降级。

worker在处理队列中的请求时,支持按照优先和权重处理。因此,可以非常容易的实现金丝雀发布和逻辑上的upstream隔离。

负载均衡策略

  • RR, 均匀撒胡椒面。优点是足够简单,缺点是没有考虑不同后端服务实例的数量和接口处理时长差异,会导致大量Bandaid 服务资源被极少数的慢服务和接口消耗掉。
  • least N random choices: 先随机选择N个候选upstream host, 然后选择连接数最少的host (认为是当前负载最低的)作为最终目标host.

这种方式在在大部分时候可以工作得很好,但对于那种快速失败的小服务会失效。因为这种服务有很大概率被选中,但是并不意味着其当前负载较低。缓解该策略的方式是absolute least connection.

  • absolute least connection: 从全局host中选择连接数最少的host作为目标host.

  • pinning peer: 将worker与host绑定。这种方式可以避免慢服务过量消耗Bandaid资源的问题,但是请求调度不够灵活。

总结

从 Bandaid 的设计看,无论是作为 reverse proxy 还是 service mesh 都有不错的潜力。不过 Dropbox 团队当前还没有公开Bandaid性能测试数据,代码也还没有开源。因此,猴急的同学可能还需要等一段时间。

论一个合格的车载手机支架的自我修养之什么是最好的车载手机支架

作为一个酷爱驾驶的人和一名手机党,给座驾安装一个手机支架是非常重要的事情。然而,要寻找一个还用的车载手机支架真的是一件比想象要困难的多的事情。

手机支架自己买过很多了。但是都在使用一段时间后(甚至体验几分钟后)发现不能接受的设计问题而丢垃圾桶。

大概5年前,购买了第一个手机支架,他大概长这样(姑且称之为第一代车载手机支架吧):

这个手机架使用上没有什么太大问题,功能上一直表现不错,因此使用了挺长一段时间。但是,后来换的手机屏幕更大了,继续使用这个手机支架的话会使得手机上沿会挡住汽车自己的一部分显示屏。此外,这个手机支架下端只依靠一个塑料架作为之巅,把出风口的下方已经磕出了历史的痕迹?

于是换了当时最流行的磁吸式车载手机支架:

这种支架需要在手机或者手机壳上安装一个贴片(也叫做引磁片),然后往空调出风口上的支架一放就可以靠磁力吸住。应该说,使用传统的支架换到这个磁吸支架的头两天,心里还是默默为发明这种之家的人点赞的。

它的支架结构非常简单,在车里非常简洁,而且调整引磁片的位置是的手机固定在一个自己最喜欢的位置。但是,这种简洁却是以牺牲手机的外观为代价的。且不说很多引磁片上面有品牌logo, 且logo极丑,关键是无论多么薄的引磁片贴在手机上以后都让以前光滑平整的手机背面不在平整,非常变扭。

另一方面,在使用一段时间以后,发现指南针和陀螺仪都不再准确,不过导航似乎一直都还正常。推测是这两个部件因为长期使用这种强磁设备影响已经被磁化。作为一个相信科学的人居然在这里翻车,想来也是挺自嘲的一件事情。

然而,事实是「磁吸式手机之间是否影响手机」这个事情目前都没有定论。我在购买前也担心过这个事情。专门在蛤乎还转了一圈,我估计大家找到的大概也是这个答案:

然是有影响的,表面磁力超过一定范围.现在市面上的手机基本上都用到了磁铁,其内部隔磁都做到了200GS以下,越好的手机内部隔磁越好.

所以,对于手机支架,表面磁力(接触手机的部分)超过200GS就会对手机产生影响,甚至损害.就现在市面上的磁性手机支架而言,都在2000GS以上,是标准值的10倍以上.

这样导致的结果就是干扰手机的电磁信号和磁化手机内部的含铁部件,逐渐导致收集导航的失准和失灵、通话音质的失真、运行卡顿、数据丢失、屏幕花屏或者黑屏、间歇性死机,直至报废。

以上的现象一般会在1到4个月内相继出现,根据手机的不同也会出现不同的情况。这个也有专业的测试机构,但鉴定需要最少一年的时间才能判别对手机的影响到底多大,只有专业的磁铁生产厂家和手机生产厂家能自己用仪器测出具体值。

建议购买磁性支架应选择盖上铁片(贴在手机背面的那片,没有不行)后表面磁力在200GS以下的。

给一个简单的测试方法:在手机支架上盖上铁在手机背面的铁片,把一圆硬币从上面滚落,不会吸住的基本上都只有几百GS,过大的都会吸住硬币,则不建议购买。

但是,请相信我,我也做过这个硬币实验,硬币滚落了,于是我以为自己购买的产品是OK的。但是现实却是打脸的……

我看过很多所谓汽车媒体的评测,结论这种磁铁不影响手机。理由无非亮点:

  1. 厂家使用了多颗磁铁,形成的是闭合磁场,因此厂家肯定是做了充分测试的。
  2. 苹果的smart cover外设也是使用磁铁作为屏幕感应开关的,因此磁铁是不会影响电子设备的。

最开始,我还挺相信上面的结论的。但是,后来一想两个理由都有点滑稽:

  1. 学过初中物理的都应该知道,这个所谓的闭合磁场是限制磁场在一个立体区域,引磁片才一毫米厚,闭合个屁呢。另外,生产这这些支架的厂商都是一些作坊而已,这里根本没有什么信用可以背书。
  2. 苹果的确在smart cover中使用了磁铁,但是你发现那个磁铁在iPad屏幕的最右侧边缘位置了吗?而你的引磁片是贴在近乎手机中心的位置……而长期跟磁场如此近距离接触,含铁的零件被磁化无非是快慢的事罢了

抛弃了磁吸式手机支架后,我有购买了最时尚的重力感应手机支架:

第一眼看到的时候,有没有觉得创意很赞呀?最开始我也是这样认为的。于是我一股脑买了两个。但是……拿到手后就不得不说,这什么鬼呀……

所谓成也重力,败也重力。因为使用重力收紧两边的手臂夹紧手机,因此三个臂都是可以活动的。由于,手机重量一般只有一百多克,因此三个臂基本没有阻尼。这就意味着,整个手机支架非常松垮。尤其是你没有在支架上放手机的时候,开起来的过坑洼路段的时候感觉整个车都要散架一样。

这个结构的手机支架跟品牌没有关系,设计原理导致它的噪音是无解的。淘宝上到处刷评的第一卫一样垃圾,一样有这个问题。

找呀找呀找支架,于是我找到了这个目前为止最满意的车载手机支架

有没有感觉又回到了最古老的那款手机支架的感觉?这就对了。那款支架我使用时间最长,而这款支架把我之前认为的设计缺陷都解决掉了:

  1. 从结构看,它把第一款手机支架的固定方式修改为与磁吸式的固定方式一致,加入了软性塑料的夹子及不会产生异响,同时也不会在支架下部支点位置处留下历史的痕迹。
  2. 将固定手机的方框结构修改为圆形无下部支脚的弹力感应结构,这就解决了老款支架手机上下不可调,导致遮挡车载屏幕的问题。

总结下来就是,它既有普通支架的通用可靠,同时又有磁吸式支架的简洁灵活,并且不需要在手机上安装引磁片,免去磁铁影响手机的担忧。

这款手机支架使用已经有段时间,等过段时间有一次长途体验以后在来盖棺定论。