Hits

Go中string转[]byte的陷阱

Go中string转[]byte的陷阱

问题引出:下面的例子输出异常引发的思考

package main

import "fmt"

func main() {
    s := []byte("")
    s1 := append(s, 'a')
    s2 := append(s, 'b')
    // 注释1
    // fmt.Println(s1, "==========", s2)
    fmt.Println(string(s1), "==========", string(s2))
}

// 出现个让我理解不了的现象, 注释1时候输出是 b ========== b
// 取消注释输出是 [97] ========== [98] a ========== b 

slice介绍

slice内部结构

slice内部并不会存储真实的值,而是对数组片段的引用,其内部结构是:

type slice struct {
    data uintptr  // 指向数组元素的指针
    len int       // 指slice要引用数组中的元素数量
    cap int       // 指要引用数组中剩余的元素数量
}0
s1 := make([]byte, 5)  //如下图所示slice的示意图

s2 := s1[2:4]  //如下图所示slice的示意图

覆盖前值

由上面slice的结构可知,s:= []byte("")这行代码中的s实际引用了一个byte的数组。其capacity是32,length是0 :

s := []byte("")
fmt.Println(cap(s), "==", len(s))
// 输出: 32 == 0

关键点在于下面代码 s1 := append(s, 'a') 中append,并没有在原slice修改,当然也没办法修改,因为在Go中都是值传递的。当把s传入append函数内时,已经复制出一份s1,然后在s1上追加 a,s1长度是增加了1,但s长度仍然是0:

s := []byte("")
fmt.Println(cap(s), "==", len(s))
s1 := append(s, 'a')
fmt.Println(cap(s1), "==", len(s1))
// 输出
// 32 == 0
// 32 == 1

由于s,s1指向同一份数组,所以在s1上进行append a 操作时(底层数组[0]=a),也是s所指向数组的操作,但s本身不会有任何变化。这也是Go种append写法都是:

s = append(s, 'a')

append函数会返回s1,需要重新赋值给s。如果不赋值的话,s本身记录的数据就滞后了,再次对其append,就会从滞后的数据开始操作。虽然看起是append,实际上的确是把上一次append的值给覆盖了。

所以问题答案是:后append的b,把上次append的a给覆盖了,所以才会输出b b。

s1和s2共用一个内存地址,而导致s2更改了s1的内容。

string

重新分配

再看个下面的题目

s := []byte{}
s1 := append(s, 'a') 
s2 := append(s, 'b') 
fmt.Println(string(s1), ",", string(s2))
fmt.Println(cap(s), len(s))
// a,b  0,0   符合预期

上面一个例子输出的cap为:32,0。看问题的关键在这里,两者差别在于一个是默认[]byte{},另外一个是空字符串转的[]byte("")。其长度都是0,比较好理解,但是容量为什么是32就不合符预期输出了?

因为capacity是数组还能添加多少的容量,在能满足的情况,不会重新分配,所以capacity-length=32,是足够append a,b的。下面用make验证一下:

// append 内会重新分配,输出a,b
s := make([]byte, 0, 0)
// append 内不会重新分配,输出b,b,因为容量为1,足够append
s := make([]byte, 0, 1)
s1 := append(s, 'a')
s2 := append(s, 'b')
fmt.Println(string(s1), ",", string(s2))

重新分配指的是:append会检查slice大小,如果容量不够,会重新创建一个更大的slice,并把原数组复制一份出来。在make([]byte, 0, 0)这情况下,s容量肯定不够用,所以s1,s2使用的都是各自从s赋值出来的数组,结果也自然符合预期a, b了。

测试重新分配后的容量变大,打印s1:

s := make([]byte, 0, 0)
s1 := append(s, 'a')
fmt.Println(cap(s1), len(s1))
// 输出8,1。重新分配后扩大了

二者替换

那为什么空字符串的slice的容量是32?而不是0或者8呢?

只好祭出杀手锏了,翻源码。Go官方提供的工具,可以查到编译后调用的汇编信息,不然在大片源码中搜索也很累。

-gcflags 是传递参数给Go编译器,-S -S是打印汇编调用信息和数据,-S只打印调用信息。

go run -gcflags '-S -S' main.go

// 下面是部分输出:
    0x0000 00000 ()    TEXT    "".main(SB), $264-0
    0x003e 00062 ()   MOVQ    AX, (SP)
    0x0042 00066 ()   XORPS   X0, X0
    0x0045 00069 ()   MOVUPS  X0, 8(SP)
    0x004a 00074 ()   PCDATA  $0, $0
    0x004a 00074 ()   CALL    runtime.stringtoslicebyte(SB)
    0x004f 00079 ()   MOVQ    32(SP), AX
    b , b

Go使用的是plan9汇编语法,虽然整体有些不好理解,但也能看出我们需要的关键点: CALL runtime.stringtoslicebyte(SB)定位源码到src\runtime\string.go:

从stringtoslicebyte函数中可以看出容量32的源头,见注释:

const tmpStringBufSize = 32
type tmpBuf [tmpStringBufSize]byte
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
    var b []byte  
    if buf != nil && len(s) <= len(buf) {
        *buf = tmpBuf{}   // tmpBuf的默认容量是32
        b = buf[:len(s)]  // 创建个容量为32,长度为0的新slice,赋值给b。
    } else {
        b = rawbyteslice(len(s))
    }
    copy(b, s)  // s是空字符串,复制过去也是长度0
    return b
}

那为什么不是走else中rawbyteslice函数?

func rawbyteslice(size int) (b []byte) {
    cap := roundupsize(uintptr(size))
    p := mallocgc(cap, nil, false)
    if cap != uintptr(size) {
        memclrNoHeapPointers(add(p, uintptr(size)), cap-uintptr(size))
    }

    *(*slice)(unsafe.Pointer(&b)) = slice{p, size, int(cap)}
    return
}

如果走else的话,容量就不是32了。假如走的话,也不影响得出的结论(覆盖),可以测试下:

    s := []byte(strings.Repeat("c", 33))
    s1 := append(s, 'a')
    s2 := append(s, 'b')
    fmt.Println(string(s1), ",", string(s2))
    // cccccccccccccccccccccccccccccccccb , cccccccccccccccccccccccccccccccccb

逃逸分析

为啥加上注释就符合预期输出a,b? 还有加上注释为啥连容量都变了?

s := []byte("")
fmt.Println(cap(s), len(s))
s1 := append(s, 'a') 
s2 := append(s, 'b') 
fmt.Println(s1, ",", s2)
fmt.Println(string(s1), ",", string(s2))
//输出
// 0 0
// [97] ========== [98]
// a , b

如果用逃逸分析来解释的话,就比较好理解了,先看看什么是逃逸分析。

提高性能

如果一个函数或子程序内有局部对象,返回时返回该对象的指针,那这个指针可能在任何其他地方会被引用,就可以说该指针就成功“逃逸”了 。 而逃逸分析(escape analysis)就是分析这类指针范围的方法,这样做的好处是提高性能:

  • 最大的好处应该是减少gc的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除。
  • 因为逃逸分析完后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好 同步消除,如果定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。
  • Go在编译的时候进行逃逸分析,来决定一个对象放栈上还是放堆上,不逃逸的对象放栈上,可能逃逸的放堆上 。

逃到堆上

取消注释情况下:Go编译程序进行逃逸分析时,检测到fmt.Println有引用到s,所以在决定堆上分配s下的数组。在进行string转[]byte时,如果分配到栈上就会有个默认32的容量,分配堆上则没有。

用下面命令执行,可以得到逃逸信息,这个命令只编译程序不运行,上面用的go run -gcflags是传递参数到编译器并运行程序。

go tool compile -m main.go

取消注释 fmt.Println(s1, ",", s2) 后([]byte(“”))会逃逸到堆上:

main.go:23:13: s1 escapes to heap 
main.go:20:13: ([]byte)("") escapes to heap // 逃逸到堆上 
main.go:23:18: "," escapes to heap 
main.go:23:18: s2 escapes to heap 
main.go:24:20: string(s1) escapes to heap 
main.go:24:20: string(s1) escapes to heap 
main.go:24:26: "," escapes to heap 
main.go:24:37: string(s2) escapes to heap 
main.go:24:37: string(s2) escapes to heap 
main.go:23:13: main ... argument does not escape 
main.go:24:13: main ... argument does not escape

加上注释//fmt.Println(s1, “,”, s2)不会逃逸到堆上:

go tool compile -m main.go
main.go:24:20: string(s1) escapes to heap
main.go:24:20: string(s1) escapes to heap
main.go:24:26: "," escapes to heap
main.go:24:37: string(s2) escapes to heap
main.go:24:37: string(s2) escapes to heap
main.go:20:13: main ([]byte)("") does not escape  //不逃逸
main.go:24:13: main ... argument does not escape

逃逸分配

接着继续定位调用stringtoslicebyte的地方,在src\cmd\compile\internal\gc\walk.go 文件。 为了便于理解,下面代码进行了汇总:

const (
    EscUnknown        = iota
    EscNone           // 结果或参数不逃逸堆上.
 )  
case OSTRARRAYBYTE:
        a := nodnil()   //默认数组为空
        if n.Esc == EscNone {
            // 在栈上为slice创建临时数组
            t := types.NewArray(types.Types[TUINT8], tmpstringbufsize)
            a = nod(OADDR, temp(t), nil)
        }
        n = mkcall("stringtoslicebyte", n.Type, init, a, conv(n.Left, types.Types[TSTRING]))

不逃逸情况下会分配个32字节的数组 t。逃逸情况下不分配,数组设置为 nil,所以s的容量是0。接着从s上append a,b到s1,s2,其必然会发生复制,所以不会发生覆盖前值,也符合预期结果a,b 。再看stringtoslicebyte就很清晰了。

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
    var b []byte
    if buf != nil && len(s) <= len(buf) { 
        *buf = tmpBuf{}
        b = buf[:len(s)]
    } else {
        b = rawbyteslice(len(s))
    }
    copy(b, s)
    return b
}

大小分配

不逃逸情况下默认32。那逃逸情况下分配策略是?

s := []byte("a")
fmt.Println(cap(s))
s1 := append(s, 'a')
s2 := append(s, 'b')
fmt.Print(s1, s2)

如果是空字符串它的输出:0。”a“字符串时输出:8。

大小取决于src\runtime\size.go 中的roundupsize 函数和 class_to_size 变量。

这些增加大小的变化,是由 src\runtime\mksizeclasses.go生成的。

参考

本文链接:参与评论 »

--EOF--

提醒:本文最后更新于 212 天前,文中所描述的信息可能已发生改变,请谨慎使用。

专题「跟我一起学Go」的其它文章 »

Comments