Go語言源碼分析之unsafe
Go語言源碼分析之unsafe
1.什麼是unsafe
unsafe 庫讓 golang 可以像C語言一樣操作電腦記憶體,但這並不是golang推薦使用的,能不用盡量不用,就像它的名字所表達的一樣,它繞過了golang的記憶體安全原則,是不安全的,容易使你的程式出現莫名其妙的問題,不利於程式的擴展與維護。
先簡單介紹下Golang指針類型:
*類型
:普通指針,用於傳遞對象地址,不能進行指針運算。unsafe.Pointer
:通用指針類型,用於轉換不同類型的指針,不能進行指針運算。uintptr
:用於指針運算,GC 不把 uintptr 當指針,uintptr 無法持有對象,uintptr 類型的目標會被回收。
unsafe.Pointer 可以和 普通指針 進行相互轉換。
unsafe.Pointer 可以和 uintptr 進行相互轉換。
也就是說 unsafe.Pointer 是橋樑,可以讓任意類型的指針實現相互轉換,也可以將任意類型的指針轉換為 uintptr 進行指針運算。
unsafe底層源碼如下:
兩個類型:
// go 1.14 src/unsafe/unsafe.go
type ArbitraryType int
type Pointer *ArbitraryType
ArbitraryType是int的一個別名,在Go中對ArbitraryType賦予特殊的意義。代表一個任意Go表達式類型。
Pointer 是 int指針類型 的一個別名,在Go中可以把Pointer類型,理解成任何指針的父類型。
三個函數:
func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr
通過分析發現,這三個函數的參數均是ArbitraryType類型,就是接受任何類型的變數。
Sizeof
返回類型 x 所佔據的位元組數,但不包含 x 所指向的內容的大小。例如,對於一個指針,函數返回的大小為 8 位元組(64位機上),一個 slice 的大小則為 slice header 的大小。Offsetof
返回變數指定屬性的偏移量,這個函數雖然接收的是任何類型的變數,但是有一個前提,就是變數要是一個struct類型,且還不能直接將這個struct類型的變數當作參數,只能將這個struct類型變數的屬性當作參數。Alignof
返回變數對齊位元組數量
2.unsafe包的操作
2.1大小Sizeof
unsafe.Sizeof函數返回的就是uintptr類型的值,表示所佔據的位元組數(表達式,即值的大小):
package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
var a int32
var b = &a
fmt.Println(reflect.TypeOf(unsafe.Sizeof(a))) // uintptr
fmt.Println(unsafe.Sizeof(a)) // 4
fmt.Println(reflect.TypeOf(b).Kind()) // ptr
fmt.Println(unsafe.Sizeof(b)) // 8
}
對於 a
來說,它是int32
類型,在記憶體中佔4個位元組,而對於b
來說,是*int32
類型,即底層為ptr
指針類型,在64位機下佔8位元組。
2.2偏移Offsetof
對於一個結構體,通過 Offset 函數可以獲取結構體成員的偏移量,進而獲取成員的地址,讀寫該地址的記憶體,就可以達到改變成員值的目的。
這裡有一個記憶體分配相關的事實:結構體會被分配一塊連續的記憶體,結構體的地址也代表了第一個欄位的地址。
舉個例子:
package main
import (
"fmt"
"unsafe"
)
type user struct {
id int32
name string
age byte
}
func main() {
var u = user{
id: 1,
name: "xiaobai",
age: 22,
}
fmt.Println(u)
fmt.Println(unsafe.Offsetof(u.id)) // 0 id在結構體user中的偏移量,也是結構體的地址
fmt.Println(unsafe.Offsetof(u.name)) // 8
fmt.Println(unsafe.Offsetof(u.age)) // 24
// 根據偏移量修改欄位的值 比如將id欄位改為1001
// 因為結構體的地址相當於第一個欄位id的地址
// 直接用unsafe包自帶的Pointer獲取id指針
id := (*int)(unsafe.Pointer(&u))
*id = 1001
// 更加相對於id欄位的偏移量獲取name欄位的地址並修改其內容
// 需要用到uintptr進行指針運算 然後再利用unsafe.Pointer這個媒介將uintptr類型轉換成一般的指針類型*string
name := (*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&u)) + unsafe.Offsetof(u.name)))
*name = "花花"
// 同理更改age欄位
age := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&u)) + unsafe.Offsetof(u.age)))
*age = 33
fmt.Println(u)
}
2.3對齊Alignof
要了解這個函數,你需要了解數據對齊
。簡單的說,它讓數據結構在記憶體中以某種的布局存放,是該數據的讀取性能能夠更加的快速。
CPU 讀取記憶體是一塊一塊讀取的,塊的大小可以為 2、4、6、8、16 位元組等大小。塊大小我們稱其為記憶體訪問粒度。
普通欄位的對齊值
fmt.Printf("bool align: %d\n", unsafe.Alignof(bool(true)))
fmt.Printf("int32 align: %d\n", unsafe.Alignof(int32(0)))
fmt.Printf("int8 align: %d\n", unsafe.Alignof(int8(0)))
fmt.Printf("int64 align: %d\n", unsafe.Alignof(int64(0)))
fmt.Printf("byte align: %d\n", unsafe.Alignof(byte(0)))
fmt.Printf("string align: %d\n", unsafe.Alignof("EDDYCJY"))
fmt.Printf("map align: %d\n", unsafe.Alignof(map[string]string{}))
輸出結果:
bool align: 1
int32 align: 4
int8 align: 1
int64 align: 8
byte align: 1
string align: 8
map align: 8
在 Go 中可以調用 unsafe.Alignof
來返回相應類型的對齊係數。通過觀察輸出結果,可得知基本都是 2n,最大也不會超過 8
。這是因為我們的64位編譯器默認對齊係數是 8,因此最大值不會超過這個數。
對齊規則
- 結構體的成員變數,第一個成員變數的偏移量為 0。往後的每個成員變數的對齊值必須為編譯器默認對齊長度(
#pragma pack(n)
)或當前成員變數類型的長度(unsafe.Sizeof
),取最小值作為當前類型的對齊值。其偏移量必須為對齊值的整數倍 - 結構體本身,對齊值必須為編譯器默認對齊長度或結構體的所有成員變數類型中的最大長度,取最大數的最小整數倍作為對齊值
結合以上兩點,可得知若編譯器默認對齊長度超過結構體內成員變數的類型最大長度時,默認對齊長度是沒有任何意義的
結構體的對齊值
下面來看一下結構體的對齊:
type part struct {
a bool // 1
b int32 //4
c int8 // 1
d int64 // 8
e byte // 1
}
func main() {
var p part
fmt.Println(unsafe.Sizeof(p)) // 32
}
按照普通欄位(結構體內成員變數)的對齊方式,我們可以計算得出,這個結構體的大小占1+4+1+8+1=15
個位元組,但是用unsafe.Sizeof
計算髮現part結構體
占32
位元組,是不是有點驚訝😮
這裡面就涉及到了記憶體對齊,下面我們來分析一下:
成員變數 | 類型 | 偏移量 | 自身佔用 |
---|---|---|---|
a | bool | 0 | 1 |
數據對齊 | – | 1 | 3 |
b | int32 | 4 | 4 |
c | int8 | 8 | 1 |
數據對齊 | – | 9 | 7 |
d | in64 | 16 | 8 |
e | byte | 24 | 1 |
數據對齊 | – | 25 | 7 |
總佔用大小 | – | – | 32 |
-
對於變數a而言
類型是bool;大小/對齊值本身為1位元組;偏移量為0,佔用了第0位;此時記憶體中表示為
a
-
對於變數b而言
類型是int32;大小/對齊值本身為4位元組;根據對齊規則一,偏移量必須為對齊值4的整數倍,故這裡的偏移量為4,佔用了第47位**,則**第13位用padding位元組填充;此時記憶體中表示為
a---|bbbb
,(|
只起到分隔作用,表示方便一些) -
對於變數c而言
類型是int8;大小/對齊值本身為1位元組;當前偏移量為8,無需擴充,佔用了第8位;此時記憶體中表示為
a---|bbbb|c
-
對於變數d而言
類型是int64;大小/對齊值本身為8位元組;根據對齊規則一,偏移量必須為對齊值8的整數倍,故這理的偏移量為16,佔用了第1623位**,則**第915為用padding位元組填充;此時記憶體中表示為
a---|bbbb|c---|----
-
對於變數e而言
類型是byte;大小/對齊值本身為1位元組;當前偏移量為24,無需擴充,佔用了第24位;此時記憶體中表示為
a---|bbbb|c---|----|e
這裡計算後,發現總共佔用25位元組,哪裡又來的32位元組呢?😳 :flushed:
再讓我們回顧一下對齊原則的第二點,「結構體本身,對齊值必須為編譯器默認對齊長度或結構體的所有成員變數類型中的最大長度,取最大數的最小整數倍作為對齊值」
-
這裡編譯器默認對齊長度為8位元組(64位機)
-
結構體中所有成員變數類型的最大長度為int64,8位元組
-
取二者最大數的最小整數倍作為對齊值,我們算的part結構體大小為25位元組,不是8位元組的整數倍,故還需要填充到32位元組。
綜上,part結構體在記憶體中表示為a---|bbbb|c---|----|e----|----
擴展
讓我們改變一下part結構體中欄位的順序看看(part結構體完全相同)
type part struct {
a bool // 1
c int8 // 1
e byte // 1
b int32 //4
d int64 // 8
}
func main() {
var p part
fmt.Println(unsafe.Sizeof(p)) // 16
}
這時候再用unsafe.Sizeof
查看會發現,part結構體的記憶體佔用只有16
位元組,瞬間減少了一般的記憶體空間,大家可以按照前面的步驟分析一下~
這裡建議在構建結構體時,按照欄位大小的升序進行排序,會減少一點的記憶體空間。
反射包的對齊方法
反射包也有某些方法可用於計算對齊值:
unsafe.Alignof(w)等價於reflect.TypeOf(w).Align
unsafe.Alignof(w.i)等價於reflect.Typeof(w.i).FieldAlign()
總結
-
unsafe 包繞過了 Go 的類型系統,達到直接操作記憶體的目的,使用它有一定的風險性。但是在某些場景下,使用 unsafe 包提供的函數會提升程式碼的效率,Go 源碼中也是大量使用 unsafe 包。
-
unsafe 包定義了 Pointer 和三個函數:
type ArbitraryType int type Pointer *ArbitraryType func Sizeof(x ArbitraryType) uintptr func Offsetof(x ArbitraryType) uintptr func Alignof(x ArbitraryType) uintptr
通過三個函數可以獲取變數的大小、偏移、對齊等資訊。
-
uintptr 可以和 unsafe.Pointer 進行相互轉換,uintptr 可以進行數學運算。這樣,通過 uintptr 和 unsafe.Pointer 的結合就解決了 Go 指針不能進行數學運算的限制。
-
通過 unsafe 相關函數,可以獲取結構體私有成員的地址,進而對其做進一步的讀寫操作,突破 Go 的類型安全限制。
參考: