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 的類型安全限制。
參考: