11 19
Go黑技巧

最近因为项目需要, 打算根据profile对自己的代码进行优化,于是重新阅读了「达达的黑魔法」

这里总结整理一下在用Go coding的时候,一些不常见也不常用的技巧,通过这些技巧更加深入了解一下go的底层知识。

一、字符串与byte数组

字符串(string)作为一种不可变类型,在与字节数组(slice, [ ]byte)转换时需付出 “沉重” 代价, 根本原因是对底层字节数组的复制。这种代价会在以万为单位的高并发压力下迅速放大,所以对它的优化常变成 “必须” 行为。

所有的string在底层都是这样的一个结构体

stringStruct{str: str_point, len: str_len}

string结构体的str指针指向的是一个字符常量的地址, 这个地址里面的内容是不可以被改变的,因为它是只读的,但是这个指针可以指向不同的地址,因为string的指针指向的内容是不可以更改的,所以每更改一次字符串,就得重新分配一次内存,之前分配空间的还得由gc回收,这是导致string操作低效的根本原因.

Go字符串的指针,它本质上是一个reflect.StringHeader,我们先看下go源码的定义:

reflect/value.go 1734行

// StringHeader is the runtime representation of a string.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.

type StringHeader struct {
    Data uintptr
    Len  int
}

字节数组([]byte),同理与slice一样,本质是一个reflect.SliceHeader.

// SliceHeader is the runtime representation of a slice.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

所以,根据这两种结构,我们就可以使用一些比较tricky的技巧,来进行转化。

首选是字符串转[]byte, string 可看做 [2]uintptr,但slice的结构明显多了一个Cap属性,于是我们可以通过指针用Len当做是Cap填充我们的转换结构。


//方法一
func str2bytes(s string) []byte {
    x := (*[2]uintptr)(unsafe.Pointer(&s))
    h := [3]uintptr{x[0], x[1], x[1]}
    return *(*[]byte)(unsafe.Pointer(&h))
}

//方法二
var o []byte
sliceHeader := (*reflect.SliceHeader)((unsafe.Pointer(&o)))
sliceHeader.Cap = length
sliceHeader.Len = length
sliceHeader.Data = uintptr(ptr)

然后是[]byte转字符串,这个就比较简单了,直接转换成指针类型,自动忽略Cap属性。

func bytes2str(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}

根据上面的黑技巧,我们来写一个基准测试程序,测试一下内存是否有所改善:

package stringbyte

import (
    "strings"
    "testing"
)

var s = strings.Repeat("a", 1024)

func test() {
    b := []byte(s)
    _ = string(b)
}

func test2() {
    b := str2bytes(s)
    _ = bytes2str(b)
}

func BenchmarkTest(b *testing.B) {
    for i := 0; i < b.N; i++ {
        test()
    }
}

func BenchmarkTestBlock(b *testing.B) {
    for i := 0; i < b.N; i++ {
        test2()
    }
}

在终端运行 $ go test -v -bench . -benchmem 后, 输出的信息如下:

BenchmarkTest-4          3000000               523 ns/op            2048 B/op          2 allocs/op
BenchmarkTestBlock-4    500000000                3.87 ns/op            0 B/op          0 allocs/op

可以看到,性能有所上升,内存分配上几乎是 zero-garbage。但我们也必须要小心,因为这样转换是很危险的:

  • string[]byte的内存结构不一定兼容

  • 第二是会干扰GC工作

  • 第三是把string变成可变的,破坏了string不可变的约束。这三点都可能导致程序异常甚至崩溃

正常情况下,追求数据质量的项目,以上这个技巧我觉得要慎用。

那什么时候使用字符串,什么时候用[]byte?

  • string可以直接比较,而[]byte不可以,所以[]byte不可以当map的key值。

  • 因为无法修改string中的某个字符,需要粒度小到操作一个字符时,用[]byte。

  • string值不可为nil,所以如果你想要通过返回nil表达额外的含义,就用[]byte。

  • []byte切片灵活,想要用切片的特性就用[]byte。

  • 需要大量字符串处理的时候用[]byte

二、struct与byte数组

以一些接口调用的程序为例,我们基本会遇到[]byte和结构体的互转,以为我一般会先转到json再转结构,或者使用gob进行转换,但也感觉这样的效率其实不高。

有见及此,我们再次利用slice的本质是SliceHeader的特定,我们来开发自己的一个转换器

package main

import (
    "fmt"
    "reflect"
    "unsafe"
)

type Person struct {
    Name     string
    Age      int
    PhoneNum string
}

var sizeOfPerson = int(unsafe.Sizeof(Person{}))

func PersonToBytes(p *Person) []byte {
    var x reflect.SliceHeader
    x.Len = sizeOfPerson
    x.Cap = sizeOfPerson
    x.Data = uintptr(unsafe.Pointer(p))
    return *(*[]byte)(unsafe.Pointer(&x))
}

func BytesToPerson(b []byte) *Person {
    return (*Person)(unsafe.Pointer(
        (*reflect.SliceHeader)(unsafe.Pointer(&b)).Data,
    ))
}

func main() {
    p := &Person{
        Name:     "domac",
        Age:      25,
        PhoneNum: "12345678",
    }

    pb := PersonToBytes(p)
    bp := BytesToPerson(pb)

    fmt.Println(bp.Name)
    fmt.Println(bp.Age)
    fmt.Println(bp.PhoneNum)
}

程序输出:

$ go run example.go
domac
25
12345678

我们接下来把它和我们的json包进行一下性能的比较

package structbyte

import (
    "encoding/json"
    "github.com/pquerna/ffjson/ffjson"
    "testing"
)

func testJson() {
    p := &Person{
        Name:     "domac",
        Age:      25,
        PhoneNum: "12345678",
    }

    b, _ := json.Marshal(p)
    np := Person{}
    json.Unmarshal(b, &np)
}

func testffJson() {
    p := &Person{
        Name:     "domac",
        Age:      25,
        PhoneNum: "12345678",
    }

    b, _ := ffjson.Marshal(p)
    np := Person{}
    ffjson.Unmarshal(b, &np)
}

func testMagic() {
    p := &Person{
        Name:     "domac",
        Age:      25,
        PhoneNum: "12345678",
    }

    pb := PersonToBytes(p)
    BytesToPerson(pb)
}

func BenchmarkTestMarshalJson(b *testing.B) {
    for i := 0; i < b.N; i++ {
        testJson()
    }
}

func BenchmarkTestMarshalFFJson(b *testing.B) {
    for i := 0; i < b.N; i++ {
        testffJson()
    }
}

func BenchmarkTestMarshalMagic(b *testing.B) {
    for i := 0; i < b.N; i++ {
        testMagic()
    }
}


程序运行:

$ go test -v -bench . -benchmem
testing: warning: no tests to run
BenchmarkTestMarshalJson-4        500000        3552 ns/op        544 B/op        9 allocs/op
BenchmarkTestMarshalFFJson-4      300000        4959 ns/op        544 B/op        9 allocs/op
BenchmarkTestMarshalMagic-4     200000000       6.68 ns/op        0 B/op          0 allocs/op
PASS

It works ! 可以看到,通过黑技巧,处理速度和内存分配上都有很大的提升。

三、slice与array

值传递的方式贯穿go调用路线,我们在coding的时候,每逢遇到传参的时候,心里面也会问自己,究竟slice和array传进去的话,哪个的性能更好呢?

网上大部分的答案都是这样总结的:

建议用 slice 代替 array,企图避免数据拷贝,提升性能

所以在这里,我们尝试检验一下

函数代码

package main

const capacity = 1024

func array() [capacity]int {
    var d [capacity]int

    for i := 0; i < len(d); i++ {
        d[i] = 1
    }

    return d
}

func slice() []int {
    d := make([]int, capacity)

    for i := 0; i < len(d); i++ {
        d[i] = 1
    }

    return d
}

测试代码:

package main

import (
    "testing"
)

func BenchmarkArray(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = array()
    }
}

func BenchmarkSlice(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = slice()
    }

}

性能测试结果:

$ go test -v -bench . -benchmem
testing: warning: no tests to run
BenchmarkArray-4         1000000              1171 ns/op               0 B/op          0 allocs/op
BenchmarkSlice-4          500000              2073 ns/op            8192 B/op          1 allocs/op
PASS

array 非但拥有更好的性能,还避免了堆内存分配,也就是说减轻了 GC 压力: 整个array函数完全在栈上完成,而slice函数则需执行 makeslice, 继而在堆上分配内存,这就是问题所在。

四、闭包调用

go语言的闭包是由函数及其相关引用环境组合而成的实体(即:闭包=函数+引用环境),换句话说闭包隐式携带上下文环境变量。

一个简单的闭包代码

func f(i int) func() int {
    return func() int {
        i++
        return i
    }
}

函数f返回了一个函数,返回的这个函数,返回的这个函数就是一个闭包。这个函数中本身是没有定义变量i的,而是引用了它所在的环境(函数f)中的变量i。

c1 := f(0)
c2 := f(0)
c1()    // reference to i, i = 0, return 1
c2()    // reference to another i, i = 0, return 1

Go语言能通过逃逸分析识别出变量的作用域,自动将变量在堆上分配 ,闭包环境变量在堆上分配是Go实现闭包的基础。返回闭包时并不是单纯返回一个结构体,而是返回了一个结构体,记录下函数返回地址和引用的环境中的变量地址。

测试代码

package closure

import (
    "testing"
)

func simple(x int) int {
    return x * 2
}

func BenchmarkTest(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = simple(i)
    }
}

func BenchmarkTestAnonymous(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = func(x int) int {
            return x * 2
        }(i)
    }
}

func BenchmarkTestCloseure(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = func() int {
            return i * 2
        }()
    }
}

运行结果:

$ go test -v -bench . -benchmem
testing: warning: no tests to run
BenchmarkTest-4                 2000000000       0.35 ns/op        0 B/op       0 allocs/op
BenchmarkTestAnonymous-4        1000000000       2.81 ns/op        0 B/op       0 allocs/op
BenchmarkTestCloseure-4         1000000000       2.78 ns/op        0 B/op       0 allocs/op
PASS

注意: 闭包引用原环境变量,导致变量逃逸到堆上,这必然增加了 GC 扫描和回收对象的数量