深入理解 go unsafe

Go 是支持指針的語言,但是爲了保持簡潔、安全,Go 的指針有很多限制,但是一些場景又需要繞過這些安全限制,因此提供了 unsafe 包,unsafe 可以繞過:指針運算、不同的指針類型不能轉換、任意類型指針等限制,進而可以實現更高級的功能。

下面通過對比可以更直觀的瞭解 unsafe 的特點:

支持指針運算

Go 的指針是不支持指針運算的,指針運算在直接操作內存有很大作用,例如:通過指針運算訪問數組元素、或者通過指針運算訪問結構體的導出字段等等。

go 指針

var arr  = [3]int{1, 2, 3}
ptr := &arr
fmt.Println(ptr++) // 報錯

$ go run main.go
//./main.go:8:17: syntax error: unexpected ++, expecting comma or )

unsafe.Pointer

var arr  = [3]int{1, 2, 3}
ptr := (unsafe.Pointer(uintptr(unsafe.Pointer(&arr)) + unsafe.Sizeof(arr[0])))
fmt.Println(*(*int)ptr)

$ go run main.go
2

支持不同的指針類型轉換

go 指針

// go 指針
type MyInt int

func main() {
    var num int = 5
        var miPtr *MyInt
        miPtr = &num
}

$ go run main.go
./main.go:12:8: cannot use &num (type *int) as type *MyInt in assignment

unsafe.Pointer

// unsafe.Pointer
type MyInt int

func main() {
    var num int = 5
        var miPtr *MyInt
        miPtr = (*MyInt)(unsafe.Pointer(&num))
        fmt.Println(*miPtr)
}
$ go run main.go
5

支持任意類型指針

unsafe.Pointer 的定義如下,其語義是任意類型的指針:

type Pointer *ArbitraryType

unsafe.Pointer 在 Go 源碼中有廣泛的應用,例如接口的底層數據結構:

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

type eface struct {
    _type *_type
    data  unsafe.Pointer
}

data 字段就是 unsafe.Pointer 類型,意味着 data 可以存儲任意類型的數據。

什麼是 go unsafe

unsafe 是 Go 語言中的一個包,用於進行一些底層的、不安全的操作。

在 Go 語言中,通常強調的是安全性和內存安全性,以防止常見的編程錯誤,比如緩衝區溢出和懸空指針等。但在某些特定的場景下,可能需要突破這些安全限制來實現一些特殊的功能或優化性能。

使用 unsafe 包時需要非常謹慎,因爲不正確的使用可能會導致程序出現難以調試的錯誤,甚至破壞程序的穩定性和安全性,應僅在有充分理由和完全理解其風險的情況下使用。

Unsafe 包比較簡單,主要是通過 Pointer、Sizeof、Offsetof、Alignof 來實現一些高級特性。

unsafe.Pointer

可以說 unsafe.Pointer 是 unsafe 包裏的重中之重,沒有 unsafe.Pointer unsafe 包就沒有存在的意義。我們先看一下 unsafe.Pointer 的定義:

// ArbitraryType is here for the purposes of documentation only and is not actually
// part of the unsafe package. It represents the type of an arbitrary Go expression.
type ArbitraryType int

type Pointer *ArbitraryType

unsafe.Pointer 表示指向任意類型的指針。

什麼是 uintptr

其實 unsafe.Pointer 本身並不支持指針運算,需要藉助 uintptr 來實現指針運算,uintptr 的定義如下:

// uintptr 是一種整數類型,具有足夠大小,可以保存任何指針類型。
type uintptr uintptr

uintptr 在源碼中有廣泛的應用,一般用來保存整數形式的內存地址,因爲 uintptr 是整數類型所以它可以進行運算:

func main() {
    var num1 int = 5
    var num2 int = 5
    p1 := uintptr(unsafe.Pointer(&num1))
    p2 := uintptr(unsafe.Pointer(&num2))

    fmt.Println("num1 的內存地址:", p1)
    fmt.Println("num2 的內存地址:", p2)
}

$ go run main.go
num1 的內存地址: 824634183432
num2 的內存地址: 824634183424

但是有一點需要注意:uintptr 並不具有指針語義,也就是 uintptr 保存的內存地址中的內容是可能被 GC 回收的。

unsafe.Pointer 類型轉換

unsafe.Pointer 類型有四種特殊操作:

  1. 任何類型的指針值都可以轉換爲 unsafe.Pointer。

  2. unsafe.Pointer 也可以轉換爲任何類型的指針值。

  3. uintptr 可以轉換爲 unsafe.Pointer。

  4. unsafe.Pointer 可以轉換爲 uintptr。

因此,unsafe.Pointer 允許程序突破類型系統並讀寫任意內存,應謹慎使用 unsafe.Pointer。

unsafe.Pointer 使用模式

unsafe.Pointer 如同雙刃劍,在賦予強大功能的同時也伴隨着風險。官方包中提供了幾種相對安全的使用模式,然而即使遵循這些模式,也無法確保絕對的安全性。

使用 “go vet” 可以幫助找到不符合這些模式的 Pointer 的使用,但是 “go vet” 的沉默並不能保證代碼有效。

**(1)將 T1 轉換爲指向 T2 的指針。

如果想將 *T1 轉換爲 *T2 需要滿足兩個條件:

  1. 假設 unsafe.Sizeof(T2)  小於等於 unsafe.Sizeof(T1);

  2. T1 和 T2 具有相同的內存佈局(不是完全相同,保證 T2 大小範圍內佈局相同就行)。

type T1 struct {
    Name     string
    Age      int
    Language string
}

type T2 struct {
    Name string
    Age  int
}

func main() {
    t1 := &T1{Name: "xiaoming", Age: 18, Language: "golang"}
    t2 := (*T2)(unsafe.Pointer(t1))

    fmt.Println(t2.Name, t2.Age)
}

$ go run main.go
xiaoming 18

(2)將指針轉換爲 uintptr(但不能轉換回指針)。

將 Pointer 轉換爲 uintptr 會得到 Pointer 指向的內存地址,是一個整數。但是,uintptr 轉換回 Pointer 通常無效。uintptr 是一個整數,而不是一個引用。將指針轉換爲 uintptr 會創建一個沒有指針語義的整數值。 即使 uintptr 持有某個對象的地址,如果該對象移動,垃圾收集器也不會更新該 uintotr 的值, 也不會阻止該對象被回收。

如下面這種,我們取得了變量的地址 p,然後做了一些其他操作,最後再從這個地址裏面讀取數據:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var a int = 10
    var p = uintptr(unsafe.Pointer(&a))
    // ... 其他代碼
    // 下面這種轉換是危險的,因爲有可能 p 指向的對象已經被垃圾回收器回收
    fmt.Println(*(*int)(unsafe.Pointer(p)))
}

(3)通過算術運算,將 unsafe.Pointer 轉換爲 uintptr 並轉換回來。

如果 p 指向一個已分配的對象,我們可以將 p 轉換爲 uintptr 然後加上一個偏移量,再轉換回 Pointer。如:

p = unsafe.Pointer(uintptr(p) + offset)

此模式最常見的用途是訪問結構中的字段或數組中的元素:

// equivalent to f := unsafe.Pointer(&s.f)
f := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f))

// equivalent to e := unsafe.Pointer(&x[i])
e := unsafe.Pointer(uintptr(unsafe.Pointer(&x[0])) + i*unsafe.Sizeof(x[0]))

這種模式有幾個注意點:

  1. 將指針加上一個超出其原始分配的內存區域的偏移量是無效的:
// INVALID: end points outside allocated space.
var s thing
end = unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Sizeof(s))

// INVALID: end points outside allocated space.
b := make([]byte, n)
end = unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(n))
  1. Pointer => uintptr, uintptr => Pointer 兩種轉換必須出現在同一個表達式中,並且它們之間只有中間的算術:
// INVALID: uintptr cannot be stored in variable
// before conversion back to Pointer.
u := uintptr(p)
p = unsafe.Pointer(u + offset)
  1. unsafe.Pointer 必須指向分配的對象,因此它不能爲零。
// INVALID: conversion of nil pointer
u := unsafe.Pointer(nil)
p := unsafe.Pointer(uintptr(u) + offset)

(4)調用 syscall.Syscall 時將指針轉換爲 uintptr。

syscall 包中的 Syscall 函數將其 uintptr 參數直接傳遞給操作系統,然後操作系統可能會根據調用的細節將其中一些參數重新解釋爲指針。也就是說,系統調用實現會隱式地將某些參數從 uintptr 轉換回指針。

如果一個指針參數必須轉換爲 uintptr 以用作參數,那麼該轉換必須出現在調用表達式本身中:

syscall.Syscall(SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(p)), uintptr(n))

(5)將 reflect.Value.Pointer 或 reflect.Value.UnsafeAddr 的結果從 uintptr 轉換爲 Pointer。

reflect.Value 的 Pointer 和 UnsafeAddr 方法返回類型 uintptr 而不是 unsafe.Pointer, 從而防止調用者在未引入 unsafe 包的情況下將結果更改爲任意類型。(這是爲了防止開發者對 Pointer 的誤操作。) 然而,**這也意味着這個返回的結果是脆弱的,我們必須在調用之後立即轉換爲 **Pointer(如果我們確切的需要一個 Pointer):

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

與上述情況一樣,存儲轉換之前的結果是無效的:

// INVALID: uintptr cannot be stored in variable
// before conversion back to Pointer.
u := reflect.ValueOf(new(int)).Pointer() // u 指向的內存可能被回收
p := (*int)(unsafe.Pointer(u))

(6)reflect.SliceHeader 或 reflect.StringHeader 數據字段與 unsafe.Pointer 互轉

與前一種情況一樣,反射數據結構 SliceHeader 和 StringHeader 將字段 Data 聲明爲 uintptr,以防止調用者在未先引入 “unsafe” 的情況下將結果更改爲任意類型。但是,這意味着 SliceHeader 和 StringHeader 僅在解釋實際切片或字符串值的內容時有效。

var s string
hdr := (*reflect.StringHeader)(unsafe.Pointer(&s)) // case 1
hdr.Data = uintptr(unsafe.Pointer(p))              // case 6 (this case)
hdr.Len = n

unsafe.Sizeof

// src/unsafe/unsafe.go
func Sizeof(x ArbitraryType) uintptr

Sizeof 返回任意類型變量 x 所佔用內存字節大小,這個大小不包含 x 底層引用的內存大小。例如,如果 x 是一個切片,那麼 Sizeof 返回的是切片描述符的大小,而非切片所引用的內存的大小。

func main() {
    var num1 int8 = 5
    var num2 int16 = 5
    
    var slice1 []int = []int{1, 2, 3}
    var slice2 []string = []string{"hello""wrold"}
    
    fmt.Println("num1 sizeof: ", unsafe.Sizeof(num1))
    fmt.Println("num2 sizeof: ", unsafe.Sizeof(num2))
    fmt.Println("slice1 sizeof: ", unsafe.Sizeof(slice1))
    fmt.Println("slice2 sizeof: ", unsafe.Sizeof(slice2))
}

$ go run main.go
num1 sizeof:  1
num2 sizeof:  2
slice1 sizeof:  24
slice2 sizeof:  24

unsafe.Offsetof

// src/unsafe/unsafe.go
func Offsetof(x ArbitraryType) uintptr

unsafe.Offsetof 返回 x 表示的字段在結構體中的偏移量,該字段必須採用 structValue.field 形式。換句話說,它返回結構體開頭和字段開頭之間的字節數。

type Programmer struct {
    Name     string
    Age      int
}

func main() {
    p := Programmer{}
    NameOffset, AgeOffset := unsafe.Offsetof(p.Name), unsafe.Offsetof(p.Age)
    fmt.Println("name offset: ", NameOffset)
    fmt.Println("age offset: ", AgeOffset)
}

$ go run main.go
name offset:  0
age offset:  16

unsafe.Alignof

// src/unsafe/unsafe.go
func Alignof(x ArbitraryType) uintptr

Alignof 返回某一個類型的對齊係數,就是對齊一個類型的時候需要多少個字節。 它與 reflect.TypeOf(x).Align() 返回的值相同。作爲一種特殊情況,如果變量 s 是結構體類型並且 f 是該結構體中的字段,則 Alignof(s.f) 將返回結構體中該類型字段所需的對齊方式。這種情況與 reflect.TypeOf(s.f).FieldAlign() 返回的值相同。

type Programmer struct {
    Name     string
    Age      int
}

func main() {
    p := Programmer{}
    fmt.Println("age Alignof: ", unsafe.Offsetof(p.Age))
}

$ go run main.go
age Alignof:  16

後面會有專門文章討論內存對齊,這裏不詳細展開。

unsafe 示例

[]byte 與 string 高效互轉

我們知道 slice 和 string 的底層數據結構非常類似,並且 []byte 和 string 都是基於字符數組實現的,所以通過共享底層字符數組技能實現零拷貝的轉換。

slice 和 string 的底層數據結構:

type StringHeader struct {
        Data uintptr
        Len  int
}

type SliceHeader struct {
        Data uintptr
        Len  int
        Cap  int
}

[]byte 與 string 高效互轉的實現:

func string2bytes(s string) []byte {
    stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))
    bh := reflect.SliceHeader{
            Data: stringHeader.Data,
            Len:  stringHeader.Len,
            Cap:  stringHeader.Len,
    }

    return *(*[]byte)(unsafe.Pointer(&bh))
}

func bytes2string([]byte) string{
    sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&b))
    sh := reflect.StringHeader{
            Data: sliceHeader.Data,
            Len:  sliceHeader.Len,
    }

    return (string)(unsafe.Pointer(&sh))
}

但是需要注意的是使用 string2bytes 時如果入參字符串所引用字符數組是不可更改的情況,轉換後 []byte 也是不可修改的:

package main

import (
    "fmt"
    "unsafe"
    "reflect"
)

func main() {
        s := "hello" // 字符串字面量被存儲在只讀的數據段中。
        bs := string2bytes(s)
        bs[0] = 'a' // 這塊會編譯報錯,不可修改
        
        fmt.Println(bs[0])
}

$ go run main.go

直接訪問數組的元素

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    arr := [5]int{1, 2, 3, 4, 5}
    ptr := unsafe.Pointer(&arr[0])
    for i := 0; i < len(arr); i++ {
        value := *(*int)(unsafe.Pointer(uintptr(ptr) + uintptr(i)*unsafe.Sizeof(arr[0])))
        fmt.Println(value)
    }
}

通過數組的起始地址 + 每個元素的偏移量就能訪問數組元素。

直接訪問切片的元素

訪問切片元素和訪問數組元素的方式差不多,但是需要注意的是:要是使用 &slice[0] 代表切片的起始位置,而數組直接使用數組指針就行。

arr := [5]int{1, 2, 3, 4, 5}
slice := []int{1, 2, 3, 4, 5}

如下圖所示:&arr 代表數組的起始位置,&slice 代表切片結構體的起始位置, &slice[0] 代表切片底層數組的起始位置。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    slice := []int{1, 2, 3, 4, 5}
    // 獲取切片底層數組的指針
    ptr := unsafe.Pointer(&slice[0])
    ptr = unsafe.Pointer(uintptr(ptr) + uintptr(2)*unsafe.Sizeof(slice[0]))
    // 直接修改底層數組的值
    *(*int)(ptr) = 10
    fmt.Println(slice)
}

$ go run main.go
[1 2 10 4 5]

訪問結構體未導出字段

同樣是通過起始地址加上偏移量就能訪問結構體字段:

// 其他包裏面定義的結構體
type Programmer struct {
    name     string
    age      int
    language string
}
// 在 main 包裏引用結構體
func main() {
    p := programmer.Programmer{}
    fmt.Println(p)

    name := (*string)(unsafe.Pointer(&p))
    age := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + unsafe.Sizeof(string(""))))
    lang := (*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + unsafe.Sizeof(int(0)) + unsafe.Sizeof(string(""))))

    *name = "Tom"
    *age = 23
    *lang = "Golang"

    fmt.Println(p)
}

$ go run main.go
{ 0 }
{Tom 23 Golang}

由於我們訪問的是未導出字段,因此無法使用 unsafe.Offsetof,只能通過將字段大小相加的方式來計算偏移量。然而,這樣做存在一個問題:在計算偏移量時,如果存在內存對齊,那麼計算出的偏移量就不準確了。因此,我們還需要加上內存對齊的偏移量。不同平臺上的內存對齊結果也可能不同。

繞過類型檢查

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var num1 int = 10
    var num2 float64
    // 繞過類型檢查進行賦值
    num2 = *(*float64)(unsafe.Pointer(&num1))
    fmt.Println(num2)
}

$ go run main.go
5e-323

這種轉換的結果往往不是我們想要的,大家一定要小心使用。

參考

https://www.cnblogs.com/qcrao-2018/p/10964692.html

https://pkg.go.dev/unsafe

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/4MTFa9Lr6xcU_HXkefEJcg