Go Golang slice append 實作細節

用 Golang 刷 leetcode 題目時,如果不太清楚 Golang slice 與相關 function 的運作原理,很容易踩到坑,尤其是使用其他高階語言的開發者,剛轉換到 Golang 時會覺得為什麼同樣的程式邏輯,但是出來的結果卻不一樣。因此本篇簡單說明 Golang 最常使用到的 slice append function 運作原理,並且使用 objdump 來觀察記憶體操作狀況。

Slice internal

首先推薦先閱讀 Golang blog Go Slices: usage and internals,雖然距離文章發布的日期已久遠,但是 slice internal 結構還是可以參考。文中很重要的一個概念是:A slice is a descriptor of an array segment., 雖然我們使用 slice 方式很像 array ,但實際上 slice 是一個 descriptor struct ,其中會有一個 array pointer 指向真正存放數值的 array 中。

1type slice struct {
2	array unsafe.Pointer
3	len   int
4	cap   int
5}

此時,如果我們要把一個既有的 slice assign 給新的 slice 變數:

1nums := make([]int, 0)
2nums = append(nums, 1)
3nums = append(nums, 2)
4nums = append(nums, 3)
5nums = append(nums, 4)
6nums = append(nums, 5)
7
8newNums := nums

上述的 code 看起來好像是新建立了一個新的 slice newNums,不過剛剛有提到 slice 是 descriptor struct 型態,因此 newNums 其實是複製 nums struct member 的值,以致 newNumsnums 擁有相同的 length、capacity、array pointer,所以雖然是兩個 slice,但底層卻是共用同一個 array 記憶體區段。

1fmt.Printf("%p \n", &newNums[0]) // 0xc000018030 
2fmt.Printf("%p \n", &nums[0]) // 0xc000018030 

而這個概念在 slice 指向的 array 空間不夠,需要擴充 array 空間時,情況就會不一樣了。在 Go Slices: usage and internals 中有提到:

To increase the capacity of a slice one must create a new, larger slice and copy the contents of the original slice into it.

遇到這種情況,剛剛提及的 numsnewNums 變數,就會因為新 allocate 一塊較大的 array 記憶體區段的擴充流程,導致 numsnewNums 的 array pointer 不再是指向同一個 array 記憶體,而是會一個指向新的 array 記憶體、另一個指向舊的:

 1nums := make([]int, 0)
 2nums = append(nums, 1)
 3newNums := nums // both of them share the same array pointer
 4
 5fmt.Printf("%p \n", &newNums[0]) // 0xc000018030 
 6fmt.Printf("%p \n", &nums[0]) // 0xc000018030
 7
 8newNums = append(newNums, 2) // Growing slices, creating a new array area for newNums
 9
10fmt.Printf("%p \n", &newNums[0]) // 0xc000018050
11fmt.Printf("%p \n", &nums[0]) // 0xc000018030

至於 array 擴充後的記憶體空間是怎麼計算的呢?我們可以從 Golang source coderuntime.growslice 找到答案:

 1func growslice(et *_type, old slice, cap int) slice {
 2  newcap := old.cap
 3	doublecap := newcap + newcap
 4	if cap > doublecap {
 5		newcap = cap
 6	} else {
 7		const threshold = 256
 8		if old.cap < threshold {
 9			newcap = doublecap
10		} else {
11			// Check 0 < newcap to detect overflow
12			// and prevent an infinite loop.
13			for 0 < newcap && newcap < cap {
14				// Transition from growing 2x for small slices
15				// to growing 1.25x for large slices. This formula
16				// gives a smooth-ish transition between the two.
17				newcap += (newcap + 3*threshold) / 4
18			}
19			// Set newcap to the requested cap when
20			// the newcap calculation overflowed.
21			if newcap <= 0 {
22				newcap = cap
23			}
24		}
25	}
26}

在 capacity < 256 情況下, capacity 擴充是用 2 倍的方式來增加,也就是 1 -> 2 -> 4 -> 8 -> 16 等,而當 capacity 超過 256 時,此時擴充所需要的記憶體空間相對較大,因此僅會以 1.25 倍的方式增加。

Slice append function

有了上述的概念後,可以知道關鍵在於 slice 指向的 array 擴充時間點,其中最常見的應用場景就是我們使用 slice append function 在 slice 中加入新的數值,此時如果指向的 array 空間不足,就會觸發 growslice allocate 一個新的 array 空間。

而在 Go Slices: usage and internals 有提及簡單的 append function 實作流程概念:

 1func AppendByte(slice []byte, data ...byte) []byte {
 2    m := len(slice)
 3    n := m + len(data)
 4    if n > cap(slice) {
 5        newSlice := make([]byte, (n+1)*2)
 6        copy(newSlice, slice)
 7        slice = newSlice
 8    }
 9    slice = slice[0:n]
10    copy(slice[m:n], data) // append data to the existing array
11    return slice
12}

除了 grow 流程之外,如果當前 array capacity 足夠的情況下,會將資料直接 append 到既有 array 空間中,並且將既有的 slice 回傳回去。

我們透過 objdump 出來的 machine code 來看 append function 具體的實作方式:

1108a245: 48 8d 73 01                  	leaq	1(%rbx), %rsi # length value += 1
2108a249: 48 8b 5c 24 40               	movq	64(%rsp), %rbx
3108a24e: eb 00                        	jmp	0x108a250 <_main.main+0xb0>
4108a250: 48 8d 14 d8                  	leaq	(%rax,%rbx,8), %rdx # %rax store the array pointer
5108a254: 48 8d 52 08                  	leaq	8(%rdx), %rdx # append 2 value to array
6108a258: 48 c7 02 02 00 00 00         	movq	$2, (%rdx) # append 2 value to array
7108a25f: 48 89 44 24 68               	movq	%rax, 104(%rsp) # update array pointer of slice descriptor
8108a264: 48 89 74 24 70               	movq	%rsi, 112(%rsp) # update length value of slice descriptor
9108a269: 48 89 4c 24 78               	movq	%rcx, 120(%rsp) # update capacity of slice descriptor

如果有觸發 growslice 擴充 array 空間,則會在建立新的 array 後,將資料 append 到新 array 中,並且更新 slice descriptor 的 array pointer 、length、以及 capacity;反之,如果原有的 array 空間足夠,則會將資料 append 到原有的 array 中,並調整 length 的數值。

這次我們一樣建立 numsnewNums 兩個 slice,並且搭配 append function,來觀察其底層 array 的變化:

1nums := make([]int, 0)
2nums = append(nums, 1)
3nums = append(nums, 2)
4nums = append(nums, 3)
5nums = append(nums, 4)
6nums = append(nums, 5)
7newNums := append(nums, 6)
8newNums = append(newNums, 7)

其中,我們觀察 append number 5 和 6 之間的 machine code:

 1108a343: 49 c7 00 05 00 00 00         	movq	$5, (%r8)
 2108a34a: 48 89 44 24 68               	movq	%rax, 104(%rsp) # nums struct
 3108a34f: 48 89 54 24 70               	movq	%rdx, 112(%rsp)
 4108a354: 48 89 4c 24 78               	movq	%rcx, 120(%rsp)
 5108a359: 48 8d 72 01                  	leaq	1(%rdx), %rsi
 6108a35d: 0f 1f 00                     	nopl	(%rax)
 7108a360: 48 39 f1                     	cmpq	%rsi, %rcx
 8108a363: 73 02                        	jae	0x108a367 <_main.main+0x1c7>
 9108a365: eb 02                        	jmp	0x108a369 <_main.main+0x1c9>
10108a367: eb 27                        	jmp	0x108a390 <_main.main+0x1f0>
11108a369: 48 89 54 24 40               	movq	%rdx, 64(%rsp)
12108a36e: 48 89 c3                     	movq	%rax, %rbx
13108a371: 48 89 cf                     	movq	%rcx, %rdi
14108a374: 48 8d 05 85 73 00 00         	leaq	29573(%rip), %rax # setup args for growslice
15108a37b: 48 89 d1                     	movq	%rdx, %rcx # setup args for growslice
16108a37e: 66 90                        	nop
17108a380: e8 fb 9f fb ff               	callq	0x1044380 <_runtime.growslice>
18108a385: 48 8d 73 01                  	leaq	1(%rbx), %rsi # %rbx is the return value from growslice
19108a389: 48 8b 54 24 40               	movq	64(%rsp), %rdx # retrive the length of slice
20108a38e: eb 00                        	jmp	0x108a390 <_main.main+0x1f0>
21108a390: 48 8d 14 d0                  	leaq	(%rax,%rdx,8), %rdx # append value to slice
22108a394: 48 c7 02 06 00 00 00         	movq	$6, (%rdx) # append value to slice
23108a39b: 48 89 84 24 80 00 00 00      	movq	%rax, 128(%rsp) # newNums
24108a3a3: 48 89 b4 24 88 00 00 00      	movq	%rsi, 136(%rsp)
25108a3ab: 48 89 8c 24 90 00 00 00      	movq	%rcx, 144(%rsp)

nums 和 newNums 是兩個 slice 變數,可以看到其表示的地址也有所不同,因此在 newNums = append(newNums, 7) 僅會更新 newNums 的 pointer、length、與 capacity。但是,即使他們的 length 和 capacity 的數值是各自儲存,卻有可能是共用同一個 array,這也會造成如果誤用 slice asign 和 append function,將有可能會覆蓋掉既有 array 中的資料,進而造成後續結果不如預期

舉一個簡單的例子:

 1nums := make([]int, 0)
 2nums = append(nums, 1)
 3nums = append(nums, 2)
 4nums = append(nums, 3)
 5nums = append(nums, 4)
 6nums = append(nums, 5)
 7
 8newNums := append(nums, 6)
 9nums = append(nums, 7)
10newNums2 := append(newNums, 8)

如果不清楚 Golang append function 的實作方式,有可能會誤以為上述的 code 其結果會是:

1nums = [1 2 3 4 5 7]
2newNums = [1 2 3 4 5 6]
3newNums2 = [1 2 3 4 5 6 8]

但實際上出來的結果會是:

1nums = [1 2 3 4 5 7]
2newNums = [1 2 3 4 5 7]
3newNums2 = [1 2 3 4 5 7 8]

其結果不如預期的原因在於 numsnewNumsnewNums2 三個 slice 變數的 array pointer 都指向同一個 array,這也導致在執行 nums = append(nums, 7) 時,會將上一步 newNums := append(nums, 6) append 到 array 的 6 給覆蓋掉成 7,最終造成 6 資料消失的情況。

而要避免這樣的錯誤發生,就是要確保不同的 slice 擁有不同的 array pointer,可以透過新建立一個 newNums slice (因為其 pointer 指向的 array 地址也會不同),並且將 nums slice 的資料複製到 newNums slice,再進行 append:

 1nums := make([]int, 0)
 2nums = append(nums, 1)
 3nums = append(nums, 2)
 4nums = append(nums, 3)
 5nums = append(nums, 4)
 6nums = append(nums, 5)
 7
 8newNums := make([]int, len(nums), len(nums)+1)
 9copy(newNums, nums)
10newNums = append(newNums, 6)
11nums = append(nums, 7)

由於 numsnewNums 擁有獨立的 array 空間,所以會得出我們預期的結果。

Happy New Year!