面試題:讓你捉摸不透的 Go reslice

haohongfan發表於2021-04-22

面試題:

package main

func a() []int {
	a1 := []int{3}
	a2 := a1[1:]
	return a2
}

func main() {
	a()
}

看到這個題, 你的第一反應是啥?

(A) 編譯失敗
(B) panic: runtime error: index out of range [1] with length 1
(C) []
(D) 其他

第一感覺: 肯定能編譯過, 但是執行時一定會panic的. 但事與願違竟然能夠正常執行, 結果是:[]

疑問

a1 := []int{3}
a2 := a1[1:]
fmt.Println("a[1:]", a2)

a1 和 a2 共享同樣的底層陣列, len(a1) = 1, a1[1]絕對會panic, 但是a[1:]卻能正常輸出, 這是為何?

從表面入手

整體上看下整體的情況

a1 := []int{3}
fmt.Printf("len:%d, cap:%d", len(a1), cap(a1))
fmt.Println("a[0:]", a1[0:])
fmt.Println("a[1:]", a1[1:])
fmt.Println("a[2:]", a1[2:])

結果:

len:1, cap:1
a[0:]: [1]
a[1:] []
panic: runtime error: slice bounds out of range [2:1]

從表面來看, 從a[2:]才開始panic, 到底是誰一手造成這樣的結果呢?

彙編上看一目瞭然

"".a STEXT size=87 args=0x18 locals=0x18
	// 省略...
	0x0028 00040 (main.go:6)	CALL	runtime.newobject(SB)
	0x002d 00045 (main.go:6)	MOVQ	8(SP), AX  // 將slice的資料首地址載入到AX暫存器
	0x0032 00050 (main.go:6)	MOVQ	$3, (AX)   // 把3放入到AX暫存器中, 也就是a1[0]
	0x0039 00057 (main.go:8)	MOVQ	AX, "".~r0+32(SP)
	0x003e 00062 (main.go:8)	XORPS	X0, X0     // 初始化 X0 暫存器
	0x0041 00065 (main.go:8)	MOVUPS	X0, "".~r0+40(SP) // 將X0放入返回值
	0x0046 00070 (main.go:8)	MOVQ	16(SP), BP
	0x004b 00075 (main.go:8)	ADDQ	$24, SP
	0x004f 00079 (main.go:8)	RET
	// 省略....

其實主要關心這兩行即可.

0x003e 00062 (main.go:8)	XORPS	X0, X0     // 初始化 X0 暫存器
0x0041 00065 (main.go:8)	MOVUPS	X0, "".~r0+40(SP) // 將X0放入返回值

是不是很神奇, a[1:] 沒有呼叫runtime.panicSliceB(SB), 而是返回的是一個空的slice. 這是為何呢?

持著懷疑態度, 去 github 提上一枚 issue. https://github.com/golang/go/issues/42069

reslice

結論: 這是故意的, 單純為了保持reslice對稱而已. 這也就解釋了返回一個空的slice的原因.

reslice 原理

上面的問題已經解釋清楚了, 回過頭來看正常 reslice 的例子

func a() []int {
	a1 := []int{3, 4, 5, 6, 7, 8}
	a2 := a1[2:]
	return a2
}

用簡單的圖來描述這段程式碼裡, a1 和 a2 之間的reslice 關係. 可以看到 a1 和 a2 是共享底層陣列的.

reslice

如果你知道這些, 那麼 slice 的使用基本上不會出現問題.

下面這些問題你考慮過嗎 ?

  1. a1, a2 是如何共享底層陣列的?
  2. a1[low:high]是如何實現的?

繼續來看這段程式碼的彙編:

"".a STEXT size=117 args=0x18 locals=0x18
	// 省略...
	0x0028 00040 (main.go:4)	CALL	runtime.newobject(SB)
	0x002d 00045 (main.go:4)	MOVQ	8(SP), AX
	0x0032 00050 (main.go:4)	MOVQ	$3, (AX)
	0x0039 00057 (main.go:4)	MOVQ	$4, 8(AX)
	0x0041 00065 (main.go:4)	MOVQ	$5, 16(AX)
	0x0049 00073 (main.go:4)	MOVQ	$6, 24(AX)
	0x0051 00081 (main.go:4)	MOVQ	$7, 32(AX)
	0x0059 00089 (main.go:4)	MOVQ	$8, 40(AX)
	0x0061 00097 (main.go:5)	ADDQ	$16, AX
	0x0065 00101 (main.go:6)	MOVQ	AX, "".~r0+32(SP)
	0x006a 00106 (main.go:6)	MOVQ	$4, "".~r0+40(SP)
	0x0073 00115 (main.go:6)	MOVQ	$4, "".~r0+48(SP)
	0x007c 00124 (main.go:6)	MOVQ	16(SP), BP
	0x0081 00129 (main.go:6)	ADDQ	$24, SP
	0x0085 00133 (main.go:6)	RET
	// 省略....
  • 第4行: 將 AX 棧頂指標下移 8 位元組, 指向了 a1 的 data 指向的地址空間裡
  • 第5-10行: 將 [3,4,5,6,7,8] 放入到 a1 的 data 指向的地址空間裡
  • 第11行: AX 指標後移 16 個位元組. 也就是指向元素 5 的位置
  • 第12行: 將 SP 指標下移 32 位元組指向即將返回的 slice (其實就是 a2 ), 同時將 AX 放入到 SP. 注意 AX 放入 SP 裡的是一個指標, 也就造成了a1, a2是共享同一塊記憶體空間的
  • 第13行: 將 SP 指標下移 40 位元組指向了 a2 的 len, 同時 把 4 放入到 SP, 也就是 len(a2) = 4
  • 第14行: 將 SP 指標下移 48 位元組指向了 a2 的 cap, 同時 把 4 放入到 SP, 也就是 cap(a2) = 4

下圖是 slice 的 棧圖, 可以配合著上面的彙編一塊看.

slice stack

看到這裡是不是一目瞭然了. 於是有了下面的這些結論:

  1. reslice 完全是利用匯編實現的
  2. reslice 時, slice 的 data 通過指標的移動完成, 造成了共享相同的底層資料, 同時將新的 len, cap 放入對應的位置

至此, golang reslice的原理基本已經闡述清楚了.

參考資料

  1. 深入Go的底層,帶你走近一群有追求的人
  2. 彙編角度看 Slice,一個新的世界
  3. Why slice not painc
  4. Slice expressions
  5. A Quick Guide to Go's Assembler
  6. plan9 assembly 完全解析

相關文章