golang unsafe.Pointer使用原則以及 uintptr 隱藏的坑

unsafe.Pointer 和 uintptr 隱藏的坑

new的對象,內存在哪裏開闢

使用go標準編譯器編譯的代碼,每個協程都會有自己的協程棧,一個協程棧是一個預申請的內存塊。每個協程的初始棧大小比較小(在64位系統上2KB)。 每個棧的大小在協程運行的時候將按照需要增長和收縮( stack grow )。

當我們創建對象申請內存塊的時候,可以從協程棧上申請,也可以從堆上申請。

從協程棧上申請的對象,只能在此協程內部被使用(引用),比如指針指向的對象不能逃逸到協程外,其它協程是無法訪問到這些內存塊的。一個協程不需要使用任何數據同步技術而使用開闢在它的棧上的內存塊上的值。

也可以從堆上申請對象, 開闢在堆上的內存塊可以被多個協程併發地訪問。 在需要的時候需要做併發安全控制。

編譯器在編譯代碼時候會做逃逸分析,關於逃逸分析細節可以參考:golang 逃逸分析與棧、堆分配分析

逃逸分析在編譯階段可以確定一個對象是分配在堆上還是協程棧上。如果編譯器覺察到一個內存塊在運行時將會被多個協程訪問,或者不能輕鬆地斷定此內存塊是否只會被一個協程訪問,則此內存塊將會被開闢在堆上。 也就是說,編譯器將採取保守但安全的策略,使得某些可以安全地被開闢在棧上的內存塊也有可能會被開闢在堆上。

go支持協程棧是爲了提升性能。

  • 從棧上開闢內存塊比在堆上快得多;
  • 開闢在棧上的內存塊不需要被垃圾回收;
  • 開闢在棧上的內存塊對CPU緩存更加友好。

如上所述,目前官方Go編譯器中的逃逸分析器並不十分完美,因此某些可以安全地開闢在棧上的值也可能會逃逸到了堆上。

不過我們可以認爲每個包級變量(常稱全局變量)都被開闢在了堆上,並且它被一個開闢在一個全局內存區上的隱式指針所引用着。 一個開闢在堆上的內存塊可能同時被開闢在若干不同棧上的值部所引用着。

一些事實:

  • 如果一個結構體值的一個字段逃逸到了堆上,則此整個結構體值也逃逸到了堆上。
  • 如果一個數組的某個元素逃逸到了堆上,則此整個數組也逃逸到了堆上。
  • 如果一個切片的某個元素逃逸到了堆上,則此切片中的所有元素都將逃逸到堆上,但此切片值的直接部分(SliceHeader)可能開闢在棧上。
  • 如果一個值部v被一個逃逸到了堆上的值部所引用,則此值部v也將逃逸到堆上。

使用內置new函數開闢的內存可能開闢在堆上,也可能開闢在棧上,也就是不是所有的指針指向的對象都保存在堆上。這是與C++不同的一點。

當一個協程的棧的大小改變(grow)時,一個新的內存段將申請給此棧使用。原先已經開闢在老的內存段上的內存塊將很有可能被轉移到新的內存段上,或者說這些內存塊的地址將改變。 相應地,引用着這些開闢在此棧上的內存塊的指針(它們同樣開闢在此棧上)中存儲的地址也將得到刷新。 這裏很重要,這也是 uintptr 變量不要輕易使用的原因。

unsafe.Pointer 和 uintptr 是什麼

關於這一塊可以參考:golang unsafe實踐與原理

這裏需要再次強調的是: uintptr 就是一個16進制的整數,這個數字表示對象的地址,但是uintptr沒有指針的語義。所以有一些情況:一,如果一個對象只有一個 uintptr 表示的地址表示"引用"關係,那麼這個對象會在GC時被無情的回收掉,那麼uintptr表示一個野地址。二,如果uintptr表示的地址指向的對象發生了copy移動(比如協程棧增長,slice的擴容等),那麼uintptr也表示一個野地址。 但是unsafe.Pointer 有指針語義,可以保護它所指向的對象在“有用”的時候不會被垃圾回收,並且在發生移動時候更新地址值。

正確地使用非類型安全指針

這部分主要是參考unsafe.Pointer的官方文檔和:go101的非類型安全指針一文

一些事實

一:非類型安全指針值(unsafe.Pointer)是指針但uintptr值是整數

每一個非零安全或者不安全指針值均引用着另一個值。但是一個uintptr值並不引用任何值,它被看作是一個整數,儘管常常它存儲的是一個地址的數字表示。

Go的GC會檢查對象引用關係並回收不再被程序中的任何仍在使用中的值所引用的對象。指針在這一過程中扮演着重要的角色。值與值之間和內存塊與值之間的引用關係是通過指針來表徵的。

既然一個uintptr值是一個整數,那麼它可以參與算術運算。

二:不再被使用的內存塊的回收時間點是不確定的

也就是GC的開始時間是不確定的。

下面有個例子:

import "unsafe"

// 假設此函數不會被內聯(inline)。
func createInt() *int {
	return new(int)
}

func foo() {
	p0, y, z := createInt(), createInt(), createInt()
	var p1 = unsafe.Pointer(y) // 和y一樣引用着同一個值
	var p2 = uintptr(unsafe.Pointer(z))

	// 此時,即使z指針值所引用的int值的地址仍舊存儲
	// 在p2值中,但是此int值已經不再被使用了,所以垃圾
	// 回收器認爲可以回收它所佔據的內存塊了。另一方面,
	// p0和p1各自所引用的int值仍舊將在下面被使用。

	// uintptr值可以參與算術運算。
	p2 += 2; p2--; p2--

	*p0 = 1                         // okay
	*(*int)(p1) = 2                 // okay
	*(*int)(unsafe.Pointer(p2)) = 3 // 危險操作!
}

值p2是一個 uintptr, 不具有指針含義而是一個整數,所以不能保證z指針值所引用的int值所佔的內存塊一定還沒有被回收。 換句話說,當*(*T)(unsafe.Pointer(p2))) = 3被執行的時候,此內存塊有可能已經被回收了。 所以,接引p2中存儲的地址可能是接引野指針。

三:一個值的地址在程序運行中可能改變

參考 unsafe.Pointer 和 uintptr 是什麼

這裏我們只需要知道當一個協程的棧的大小改變時,開闢在此棧上的內存塊需要移動,從而相應的值的地址將改變。

四:我們可以將一個值的指針傳遞給runtime.KeepAlive函數調用來確保此值在此調用之前仍然處於被使用中

爲了確保一個值部和它所引用着的值部仍然被認爲在使用中,我們應該將引用着此值的另一個值傳給一個runtime.KeepAlive函數調用。 在實踐中,我們常常將此值的指針傳遞給一個runtime.KeepAlive函數調用。

還是上面 事實二 的例子:

func foo() {
	p0, y, z := createInt(), createInt(), createInt()
	var p1 = unsafe.Pointer(y)
	var p2 = uintptr(unsafe.Pointer(z))

	p2 += 2; p2--; p2--

	*p0 = 1
	*(*int)(p1) = 2
	*(*int)(unsafe.Pointer(p2))) = 3 // 轉危爲安!

	runtime.KeepAlive(z) // 確保z所引用的值仍在使用中
}

這裏通過最後添加一個runtime.KeepAlive(z)調用,表明在調用runtime.KeepAlive(z)之前,z指針指向的地址都不會被GC回收。 那麼*(*int)(unsafe.Pointer(p2))) = 3可以被安全地執行了。

五:一個值的可被使用範圍可能並沒有代碼中看上去的大

比如下面這個例子,值t仍舊在使用中並不能保證被值t.y所引用的值仍在被使用。

 uintptr(unsafe.Pointer(&t.y[0]))

	... // 使用t.x和t.y

	// 一個聰明的編譯器能夠覺察到值t.y將不會再被用到,
	// 所以認爲t.y值所佔的內存塊可以被回收了。

	*(*byte)(unsafe.Pointer(p)) = 1 // 危險操作!

	println(t.x) // ok。繼續使用值t,但只使用t.x字段。
}

六:*unsafe.Pointer是一個類型安全指針類型

是的,類型*unsafe.Pointer是一個類型安全指針類型。 它的基類型爲unsafe.Pointer。 既然它是一個類型安全指針類型,根據上面列出的類型轉換規則,它的值可以轉換爲類型unsafe.Pointer,反之亦然。

正確使用非類型安全的指針的一些模式

unsafe標準庫包的文檔中列出了六種非類型安全指針的使用模式。

模式一:將類型T1的一個值轉換爲非類型安全指針值,然後將此非類型安全指針值轉換爲類型T2。

利用前面列出的非類型安全指針相關的轉換規則,我們可以將一個T1值轉換爲類型T2,其中T1和T2爲兩個任意類型。 然而,我們只有在T1的尺寸不大於T2並且此轉換具有實際意義的時候才應該實施這樣的轉換。

模式二:將一個非類型安全指針值轉換爲一個uintptr值,然後使用此uintptr值。

此模式不是很有用。一般我們將最終的轉換結果uintptr值輸出到日誌中用來調試,但是有很多其它安全的途徑也可以實現此目的。

這個模式也不是很推薦,因爲 uintptr 指向的地址是不穩定的。

模式三:將一個非類型安全指針轉換爲一個uintptr值,然後此uintptr值參與各種算術運算,再將算術運算的結果uintptr值轉回非類型安全指針。

例子如下:

package main

import "fmt"
import "unsafe"

type T struct {
	x bool
	y [3]int16
}

const N = unsafe.Offsetof(T{}.y)
const M = unsafe.Sizeof([3]int16{}[0])

func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(&t)
	// "uintptr(p) + N + M + M"爲t.y[2]的內存地址。
	ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
	fmt.Println(*ty2) // 789
}

在上面這個例子中,地址轉換代碼 unsafe.Pointer(uintptr(p) + N + M + M) 必須要用一行運算。

假設拆成兩行:

addr := uintptr(p) + N + M + M
// 從這裏到下一行代碼執行之前,t值將不再被任何值
// 引用,所以垃圾回收器認爲它可以被回收了。一旦
// 它真得被回收了,下面繼續使用t.y[2]值的曾經
// 的地址是非法和危險的!另一個危險的原因是
// t的地址在執行下一行之前可能改變(見事實三)。
// 另一個潛在的危險是:如果在此期間發生了一些
// 導致協程堆棧大小改變的情況,則記錄在addr中
// 的地址將失效。當然,此危險對於這個特定的例子
// 並不存在。
ty2 := (*int16)(unsafe.Pointer(addr))
fmt.Println(*ty2)

這樣的bug是非常微妙和很難被覺察到的,並且爆發出來的機率是相當得低。 一旦這樣的bug爆發出來,將很讓人摸不到頭腦。這是爲什麼使用非類型安全指針是危險的原因之一。

如果我們確實希望將上面提到的轉換拆成兩行,我們應該在拆分後的兩行後添加一條runtime.KeepAlive函數調用並將(直接或間接)引用着t.y[2]值的一個值傳遞給此調用做爲實參。 比如:

func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(t)
	addr := uintptr(p) + N + M + M
	ty2 := (*int16)(unsafe.Pointer(addr))
	// 下面這條調用將確保整個t值的內存
	// 在此時刻不會被回收。
	runtime.KeepAlive(p)
	fmt.Println(*ty2)
}

但是並不推薦在此使用模式中使用此runtime.KeepAlive技巧。具體原因見上面的註釋中提到的潛在的危險。 因爲存在着這樣一種可能:當Go運行時爲變量ty2開闢內存的時候,當前協程的棧的大小需要進行增大調整。調整之後t的地址將改變,但是存儲在變量addr中的地址值卻未得到更新(因爲只有開闢在棧上的指針類型的值纔會被更新,而變量addr的類型爲整數類型uintptr)。這直接導致存儲在變量ty2的地址值時無效的(野指針)。 但是,實事求是地講,如果上例中的代碼使用官方標準編譯器編譯,則此潛在的危險並不存在。 原因是在官方標準編譯器的實現中,一個runtime.KeepAlive調用將使它的實參和被此實參引用的值開闢到堆上,並且開闢在堆上的內存塊從不會被移動。

模式四:將非類型安全指針值轉換爲uintptr值並傳遞給syscall.Syscall函數調用。

過對上一個使用模式的解釋,我們知道像下面這樣含有uintptr類型的參數的函數定義是危險的。

// 假設此函數不會被內聯。
func DoSomething(addr uintptr) {
	// 對處於傳遞進來的地址處的值進行讀寫...
}

上面這個函數是危險的原因在於此函數本身不能保證傳遞進來的地址處的內存塊一定沒有被回收。 如果此內存塊已經被回收了或者被重新分配給了其它值,那麼此函數內部的操作將是非法和危險的。

然而,syscall標準庫包中的Syscall函數的原型爲:

func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)

那麼此函數是如何保證處於傳遞給它的地址參數值a1、a2和a3處的內存塊在此函數執行過程中一定沒有被回收和被移動呢? 此函數無法做出這樣的保證。事實上,是編譯器做出了這樣的保證。 這是syscall.Syscall這樣的函數的特權。其它自定義函數無法享受到這樣的待遇。

我們可以認爲編譯器針對每個syscall.Syscall函數調用中的每個被轉換爲uintptr類型的非類型安全指針實參添加了一些指令,從而保證此非類型安全指針所引用着的內存塊在此調用返回之前不會被垃圾回收和移動。

模式五:將reflect.Value.Pointer或者reflect.Value.UnsafeAddr方法的uintptr返回值轉換爲非類型安全指針。

reflect標準庫包中的Value類型的Pointer和UnsafeAddr方法都返回一個uintptr值,而不是一個unsafe.Pointer值。 這樣設計的目的是避免用戶不引用unsafe標準庫包就可以將這兩個方法的返回值(如果是unsafe.Pointer類型)轉換爲任何類型安全指針類型。

這樣的設計需要我們將這兩個方法的調用的uintptr結果立即轉換爲非類型安全指針。 否則,將出現一個短暫的可能導致處於返回的地址處的內存塊被回收掉的時間窗。 此時間窗是如此短暫以至於此內存塊被回收掉的機率非常之低,因而這樣的編程錯誤造成的bug的重現機率亦十分得低。

比如,下面這個調用是安全的:

p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer()))

而下面這個調用是危險的:

// 返回的u類型是uintptr
u := reflect.ValueOf(new(int)).Pointer()
// 在這個時刻,處於存儲在u中的地址處的內存塊
// 可能會被回收掉。
p := (*int)(unsafe.Pointer(u))

模式六:將一個reflect.SliceHeader或者reflect.StringHeader值的Data字段轉換爲非類型安全指針,以及其逆轉換。

和上一小節中提到的同樣的原因,reflect標準庫包中的SliceHeader和StringHeader類型的Data字段的類型被指定爲uintptr,而不是unsafe.Pointer。

參考官方文檔的一句話:

In general, reflect.SliceHeader and reflect.StringHeader should be used only as *reflect.SliceHeader and *reflect.StringHeader pointing at actual slices or strings, never as plain structs. A program should not declare or allocate variables of these struct types.

對於reflect.SliceHeader and reflect.StringHeader,只期待使用*reflect.SliceHeader and *reflect.StringHeader 指針,而不期待訪問裏面的成員變量,因爲裏面的Data屬性是一個 uintptr。

一般說來,我們只應該從一個已經存在的字符串值得到一個reflect.StringHeader指針, 或者從一個已經存在的切片值得到一個reflect.SliceHeader指針, 而不應該從一個StringHeader值生成一個字符串,或者從一個SliceHeader值生成一個切片。 比如,下面的代碼是不安全的:

var hdr reflect.StringHeader
hdr.Data = uintptr(unsafe.Pointer(new([5]byte)))
// 在此時刻,上一行代碼中剛開闢的數組內存塊已經不再被任何值
// 所引用,所以它可以被回收了。
hdr.Len = 5
s := *(*string)(unsafe.Pointer(&hdr)) // 危險!

下面是一個展示瞭如何通過使用非類型安全途徑將一個字符串轉換爲字節切片的例子。 和使用類型安全途徑進行轉換不同,使用非類型安全途徑避免了複製一份底層字節序列。

package main

import (
	"fmt"
	"unsafe"
	"reflect"
	"runtime"
	"strings"
)

func String2ByteSlice(str string) (bs []byte) {
	strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	sliceHdr.Data = strHdr.Data
	sliceHdr.Len = strHdr.Len
	sliceHdr.Cap = strHdr.Len
	// 下面的KeepAlive是必要的。
	runtime.KeepAlive(&str)
	return
}

func main() {
	str := strings.Join([]string{"Go", "land"}, "")
	s := String2ByteSlice(str)
	fmt.Printf("%s\n", s) // Goland
	s[5] = 'g'
	fmt.Println(str) // Golang
}

reflect標準庫包中SliceHeader和StringHeader類型的文檔提到這兩個結構體類型的定義不保證在以後的版本中不發生改變。 好在目前的兩個主流Go編譯器(標準編譯器和gccgo編譯器)都認可當前版本中的定義。 這也可以看作是使用非類型安全指針的另一個潛在風險。

我們可以使用類似的實現來將一個字節切片轉換爲字符串。 然而,當前(Go 1.13),參考 strings.Builder.String()方法,有一個更簡單和更有效的方法來實現這一轉換:

// String returns the accumulated string.
func (b *Builder) String() string {
	return *(*string)(unsafe.Pointer(&b.buf))
}

此實現利用了上述模式一,從SliceHeader的定義和StringHeader的定義,可以看到SliceHeader的size是大於StringHeader的,並且StringHeader和SliceHeader屬性分佈是相似的,都是data在第一個屬性(這個不能更改),基於模式一,可以把slice byte轉換成string, 而不發生內存拷貝。但是注意:這種方法是不可逆的,也就是這種病方法不能用於將string 零拷貝 成 []byte。

事實上,爲了避免因爲忘記調用runtime.KeepAlive函數而造成的危險,在日常編程中更推薦使用我們自定義Data字段的類型爲unsafe.PointerSliceHeader和StringHeader結構體。比如:

type SliceHeader struct {
	Data unsafe.Pointer
	Len  int
	Cap  int
}

type StringHeader struct {
	Data unsafe.Pointer
	Len  int
}

func String2ByteSlice(str string) (bs []byte) {
	strHdr := (*StringHeader)(unsafe.Pointer(&str))
	sliceHdr := (*SliceHeader)(unsafe.Pointer(&bs))
	sliceHdr.Data = strHdr.Data
	sliceHdr.Len = strHdr.Len
	sliceHdr.Cap = strHdr.Len
	
	// 此KeepAlive調用變得不再必需。
	//runtime.KeepAlive(&str)
	return
}

reflect.SliceHeader爲啥不要使用於獲取底層數組指針

我們看看 reflect包裏面的定義:

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

可以看到Data域是一個uintptr, 表示一個整數語義,有幾種case:

  1. 如果slice的元素都分配在協程棧上,如果協程棧發生了擴容,slice就是移動,那麼Data就會失效,變成野地址。
  2. 如果slice發生了擴容(不管分配到堆還是棧),都會導致Data就會失效,變成野地址。
  3. 如果slice分配在堆上,而且沒有對象引用slice對象,那麼GC會回收,Data域也會失效。

所以真正來說,這個 reflect.SliceHeader 只能用於可讀。

真正安全的string零拷貝

參考上面的模式六,推薦使用自定義的 SliceHeader 和 StringHeader,如下:

// SliceHeader is a safe version of SliceHeader used within this project.
type SliceHeader struct {
	Data unsafe.Pointer
	Len  int
	Cap  int
}

// StringHeader is a safe version of StringHeader used within this project.
type StringHeader struct {
	Data unsafe.Pointer
	Len  int
}

具體轉換過程就不說了,參考文章:golang unsafe實踐與原理 https://louyuting.blog.csdn.net/article/details/100178972

總結

使用uintptr始終要注意兩點:

  1. GC回收導致內存地址不可用
  2. 協程棧上對象移動導致內存地址不可用(目前(go 1.13)GC算法堆上對象不會移動)

keepAlive的調用保證指針地址所指向對象在調用keepAlive之前部分對象不被回收,並且對象分配在堆上。

參考文獻

unsafe包官方文檔:https://golang.google.cn/pkg/unsafe/#Pointer
go101 非類型安全指針 https://gfw.go101.org/article/unsafe.html
go101 內存塊 https://gfw.go101.org/article/memory-block.html#where-to-allocate
Golang升級到1.7後,之前正確的函數出現錯誤,分析原因及解決辦法: https://zhuanlan.zhihu.com/p/22782641?utm_campaign=studygolang.com&utm_medium=studygolang.com&utm_source=studygolang.com

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章