Go中string轉[]byte的陷阱

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
  • 1. 背景
  • 2. slice
    • 2.1 內部結構
    • 2.2 覆蓋前值
  • 3. string
    • 3.1 重新分配
    • 3.2 二者轉換
  • 4. 逃逸分析
    • 4.1 提高效能
    • 4.2 逃到堆上
    • 4.3 逃逸分配
    • 4.4 大小分配
  • 5. 版本差異
  • 6. 結論
    • 6.1 參考

1. 背景

上周四小夥伴發了Go社區一個文章下hej8875的回複,如下:

package mainimport "fmt"func main() {s := []byte("")s1 := append(s, 'a')s2 := append(s, 'b')//fmt.Println(s1, "==========", s2)fmt.Println(string(s1), "==========", string(s2))}// 出現個讓我理解不了的現象, 注釋時候輸出是 b ========== b// 取消注釋輸出是 [97] ========== [98] a ========== b 

這個回複比原貼有意思,也很有迷惑性。作者測試了下,確實如此,於是和小夥伴們討論深究下。開始以為應該挺簡單的,理解後,發現涉及挺多知識點,值得跟大家分享下過程。

2. slice

2.1 內部結構

先拋去注釋的這行代碼//fmt.Println(s1, "==========", s2),後面在講。 當輸出 b ========== b時,已經不符合預期結果a和b了。我們知道slice內部並不會儲存真實的值,而是對數組片段的引用,其內部結構是:

type slice struct {    data uintptr    len int    cap int}

其中data是指向數組元素的指標,len是指slice要引用數組中的元素數量。cap是指要引用數組中(從data指向開始計算)剩餘的元素數量,這個數量減去len,就是還能向這個slice(數組)添加多少元素,如果超出就會發生資料的複製。slice的:

s := make([]byte, 5)// 

s = s[2:4]  //會重建新的slice,並賦值給s。與底層數組的引用也發生了改變

2.2 覆蓋前值

回到問題上,由此可以推斷出: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。

假設底層數組是arr,如注釋:

go s := []byte("") s1 := append(s, 'a') // 等同於 arr[0] = 'a' s2 := append(s, 'b') // 等同於 arr[0] = 'b' fmt.Println(string(s1), "==========", string(s2)) // 只是把同一份數組列印出來了

3. string

3.1 重新分配

老濕,能不能再給力一點?可以,我們繼續,先來看個題:

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,符合預期。

上面2.2章節例子中輸出的是:32,0。看來問題關鍵在這裡,兩者差別在於一個是預設[]byte{},另外個是Null 字元串轉的[]byte("")。其長度都是0,比較好理解,但為什麼容量是32就不符合預期輸出了?

因為 capacity 是數組還能添加多少的容量,在能滿足的情況,不會重新分配。所以 capacity-length=32,是足夠appenda,b的。我們用make來驗證下:

// append 內會重新分配,輸出a,bs := make([]byte, 0, 0)// append 內不會重新分配,輸出b,b,因為容量為1,足夠appends := 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。重新分配後擴大了

3.2 二者轉換

那為什麼Null 字元串轉的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 = 32type tmpBuf [tmpStringBufSize]bytefunc 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是Null 字元串,複製過去也是長度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

4. 逃逸分析

老濕,能不能再給力一點?什麼時候該走else?老濕你說了大半天,坑還沒填,為啥加上注釋就符合預期輸出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

如果用逃逸分析來解釋的話,就比較好理解了,先看看什麼是逃逸分析。

4.1 提高效能

如果一個函數或子程式內有局部對象,返回時返回該對象的指標,那這個指標可能在任何其他地方會被引用,就可以說該指標就成功“逃逸”了 。 而逃逸分析(escape analysis)就是分析這類指標範圍的方法,這樣做的好處是提高效能:

  • 最大的好處應該是減少gc的壓力,不逃逸的對象分配在棧上,當函數返回時就回收了資源,不需要gc標記清除。
  • 因為逃逸分析完後可以確定哪些變數可以分配在棧上,棧的分配比堆快,效能好
  • 同步消除,如果定義的對象的方法上有同步鎖,但在運行時,卻只有一個線程在訪問,此時逃逸分析後的機器碼,會去掉同步鎖運行。

Go在編譯的時候進行逃逸分析,來決定一個對象放棧上還是放堆上,不逃逸的對象放棧上,可能逃逸的放堆上 。

4.2 逃到堆上

取消注釋情況下:Go編譯器進行逃逸分析時,檢測到fmt.Println有引用到s,所以在決定堆上分配s下的數組。在進行string轉[]byte時,如果分配到棧上就會有個預設32的容量,分配堆上則沒有。

用下面命令執行,可以得到逃逸資訊,這個命令只編譯器不運行,上面用的go run -gcflags是傳遞參數到編譯器並運行程式。

go tool compile -m main.go

取消注釋fmt.Println(s1, ",", s2) 後 ([]byte)("")會逃逸到堆上:

shell 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.gomain.go:24:20: string(s1) escapes to heapmain.go:24:20: string(s1) escapes to heapmain.go:24:26: "," escapes to heapmain.go:24:37: string(s2) escapes to heapmain.go:24:37: string(s2) escapes to heapmain.go:20:13: main ([]byte)("") does not escape  //不逃逸main.go:24:13: main ... argument does not escape

4.3 逃逸分配

接著繼續定位調用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}

4.4 大小分配

不逃逸情況下預設32。那逃逸情況下分配策略是?

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

如果是Null 字元串它的輸出:0。”a“字串時輸出:8。

大小取決於src\runtime\size.go 中的roundupsize 函數和 class_to_size 變數。

這些增加大小的變化,是由 src\runtime\mksizeclasses.go產生的。

5. 版本差異

老濕,能不能再給力一點? 老濕你講的全是錯誤的,我跑的結果和你是反的。對,你沒錯,作者也沒錯,畢竟我們在用Go寫程式,如果Go底層發生變化了,肯定結果不一樣。作者在調研過程中,發現另外部落格得到的stringtoslicebyte源碼是:

func stringtoslicebyte(s String) (b Slice) {    b.array = runtime·mallocgc(s.len, 0, FlagNoScan|FlagNoZero);    b.len = s.len;    b.cap = s.len;    runtime·memmove(b.array, s.str, s.len);}

上面版本的源碼,得到的結果,也是符合預期的,因為不會預設分配32位元組的數組。

繼續翻舊版代碼,到1.3.2版是這樣:

func stringtoslicebyte(s String) (b Slice) {    uintptr cap;    cap = runtime·roundupsize(s.len);    b.array = runtime·mallocgc(cap, 0, FlagNoScan|FlagNoZero);    b.len = s.len;    b.cap = cap;    runtime·memmove(b.array, s.str, s.len);    if(cap != b.len)        runtime·memclr(b.array+b.len, cap-b.len);}

1.6.4版:

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

更古老的:

struct __go_open_array__go_string_to_byte_array (String str){  uintptr cap;  unsigned char *data;  struct __go_open_array ret;  cap = runtime_roundupsize (str.len);  data = (unsigned char *) runtime_mallocgc (cap, 0, FlagNoScan | FlagNoZero);  __builtin_memcpy (data, str.str, str.len);  if (cap != (uintptr) str.len)    __builtin_memset (data + str.len, 0, cap - (uintptr) str.len);  ret.__values = (void *) data;  ret.__count = str.len;  ret.__capacity = str.len;  return ret;}

作者在1.6.4版本上測試,得到的結果確實是反的,注釋了反而得到預期結果 a, b。 本文中使用的是1.10.2

6. 結論

老濕,能不能再給力一點?,再繼續一天時間都沒了。

總結下:

  1. 注釋時輸出b,b。是因為沒有逃逸,所以分配了預設32位元組大小的數組,2次append都是在數組[0]賦值,後值覆蓋前值,所以才是b,b。
  2. 取消注釋時輸出a,b。是因為fmt.Println引用了s,逃逸分析時發現需要逃逸並且是Null 字元串,所以分配了空數組。2次append都是操作各自重新分配後的新slice,所以輸出a,b。

注意:

  1. 源碼目錄中的gcGo compiler的意思,而不是Garbage Collectiongcflags中的gc也是同樣意思。
  2. 另外這種寫法是沒意義的,也極不推薦。應該把 []byte("string")當成唯讀來用,不然就容易出現難排查的bug。

6.1 參考

原帖是:https://gocn.io/question/1852

https://gocn.io/article/355

https://go-review.googlesource.com/c/gofrontend/+/30827

http://golang-examples.tumblr.com/post/86403044869/conversion-between-byte-and-string-dont-share

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.