Go 語言 例子:Goroutine ID

2023-03-22 15:02 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-08-goroutine-id.html


3.8 例子:Goroutine ID

在操作系統(tǒng)中,每個(gè)進(jìn)程都會有一個(gè)唯一的進(jìn)程編號,每個(gè)線程也有自己唯一的線程編號。同樣在 Go 語言中,每個(gè) Goroutine 也有自己唯一的 Go 程編號,這個(gè)編號在 panic 等場景下經(jīng)常遇到。雖然 Goroutine 有內(nèi)在的編號,但是 Go 語言卻刻意沒有提供獲取該編號的接口。本節(jié)我們嘗試通過 Go 匯編語言獲取 Goroutine ID。

3.8.1 故意設(shè)計(jì)沒有 goid

根據(jù)官方的相關(guān)資料顯示,Go 語言刻意沒有提供 goid 的原因是為了避免被濫用。因?yàn)榇蟛糠钟脩粼谳p松拿到 goid 之后,在之后的編程中會不自覺地編寫出強(qiáng)依賴 goid 的代碼。強(qiáng)依賴 goid 將導(dǎo)致這些代碼不好移植,同時(shí)也會導(dǎo)致并發(fā)模型復(fù)雜化。同時(shí),Go 語言中可能同時(shí)存在海量的 Goroutine,但是每個(gè) Goroutine 何時(shí)被銷毀并不好實(shí)時(shí)監(jiān)控,這也會導(dǎo)致依賴 goid 的資源無法很好地自動回收(需要手工回收)。不過如果你是 Go 匯編語言用戶,則完全可以忽略這些借口。

3.8.2 純 Go 方式獲取 goid

為了便于理解,我們先嘗試用純 Go 的方式獲取 goid。使用純 Go 的方式獲取 goid 的方式雖然性能較低,但是代碼有著很好的移植性,同時(shí)也可以用于測試驗(yàn)證其它方式獲取的 goid 是否正確。

每個(gè) Go 語言用戶應(yīng)該都知道 panic 函數(shù)。調(diào)用 panic 函數(shù)將導(dǎo)致 Goroutine 異常,如果 panic 在傳遞到 Goroutine 的根函數(shù)還沒有被 recover 函數(shù)處理掉,那么運(yùn)行時(shí)將打印相關(guān)的異常和棧信息并退出 Goroutine。

下面我們構(gòu)造一個(gè)簡單的例子,通過 panic 來輸出 goid:

package main

func main() {
    panic("goid")
}

運(yùn)行后將輸出以下信息:

panic: goid

goroutine 1 [running]:
main.main()
    /path/to/main.go:4 +0x40

我們可以猜測 Panic 輸出信息 goroutine 1 [running] 中的 1 就是 goid。但是如何才能在程序中獲取 panic 的輸出信息呢?其實(shí)上述信息只是當(dāng)前函數(shù)調(diào)用棧幀的文字化描述,runtime.Stack 函數(shù)提供了獲取該信息的功能。

我們基于 runtime.Stack 函數(shù)重新構(gòu)造一個(gè)例子,通過輸出當(dāng)前棧幀的信息來輸出 goid:

package main

import "runtime"

func main() {
    var buf = make([]byte, 64)
    var stk = buf[:runtime.Stack(buf, false)]
    print(string(stk))
}

運(yùn)行后將輸出以下信息:

goroutine 1 [running]:
main.main()
    /path/to/main.g

因此從 runtime.Stack 獲取的字符串中就可以很容易解析出 goid 信息:

func GetGoid() int64 {
    var (
        buf [64]byte
        n   = runtime.Stack(buf[:], false)
        stk = strings.TrimPrefix(string(buf[:n]), "goroutine")
    )

    idField := strings.Fields(stk)[0]
    id, err := strconv.Atoi(idField)
    if err != nil {
        panic(fmt.Errorf("can not get goroutine id: %v", err))
    }

    return int64(id)
}

GetGoid 函數(shù)的細(xì)節(jié)我們不再贅述。需要補(bǔ)充說明的是 runtime.Stack 函數(shù)不僅僅可以獲取當(dāng)前 Goroutine 的棧信息,還可以獲取全部 Goroutine 的棧信息(通過第二個(gè)參數(shù)控制)。同時(shí)在 Go 語言內(nèi)部的 net/http2.curGoroutineID 函數(shù)正是采用類似方式獲取的 goid。

3.8.3 從 g 結(jié)構(gòu)體獲取 goid

根據(jù)官方的 Go 匯編語言文檔,每個(gè)運(yùn)行的 Goroutine 結(jié)構(gòu)的 g 指針保存在當(dāng)前運(yùn)行 Goroutine 的系統(tǒng)線程的局部存儲 TLS 中。可以先獲取 TLS 線程局部存儲,然后再從 TLS 中獲取 g 結(jié)構(gòu)的指針,最后從 g 結(jié)構(gòu)中取出 goid。

下面是參考 runtime 包中定義的 get_tls 宏獲取 g 指針:

get_tls(CX)
MOVQ g(CX), AX     // Move g into AX.

其中 get_tls 是一個(gè)宏函數(shù),在 runtime/go_tls.h 頭文件中定義。

對于 AMD64 平臺,get_tls 宏函數(shù)定義如下:

#ifdef GOARCH_amd64
#define	get_tls(r)	MOVQ TLS, r
#define	g(r)	0(r)(TLS*1)
#endif

將 get_tls 宏函數(shù)展開之后,獲取 g 指針的代碼如下:

MOVQ TLS, CX
MOVQ 0(CX)(TLS*1), AX

其實(shí) TLS 類似線程局部存儲的地址,地址對應(yīng)的內(nèi)存里的數(shù)據(jù)才是 g 指針。我們還可以更直接一點(diǎn):

MOVQ (TLS), AX

基于上述方法可以包裝一個(gè) getg 函數(shù),用于獲取 g 指針:

// func getg() unsafe.Pointer
TEXT ·getg(SB), NOSPLIT, $0-8
    MOVQ (TLS), AX
    MOVQ AX, ret+0(FP)
    RET

然后在 Go 代碼中通過 goid 成員在 g 結(jié)構(gòu)體中的偏移量來獲取 goid 的值:

const g_goid_offset = 152 // Go1.10

func GetGroutineId() int64 {
    g := getg()
    p := (*int64)(unsafe.Pointer(uintptr(g) + g_goid_offset))
    return *p
}

其中 g_goid_offset 是 goid 成員的偏移量,g 結(jié)構(gòu)參考 runtime/runtime2.go。

在 Go1.10 版本,goid 的偏移量是 152 字節(jié)。因此上述代碼只能正確運(yùn)行在 goid 偏移量也是 152 字節(jié)的 Go 版本中。根據(jù)湯普森大神的神諭,枚舉和暴力窮舉是解決一切疑難雜癥的萬金油。我們也可以將 goid 的偏移保存到表格中,然后根據(jù) Go 版本號查詢 goid 的偏移量。

下面是改進(jìn)后的代碼:

var offsetDictMap = map[string]int64{
    "go1.10": 152,
    "go1.9":  152,
    "go1.8":  192,
}

var g_goid_offset = func() int64 {
    goversion := runtime.Version()
    for key, off := range offsetDictMap {
        if goversion == key || strings.HasPrefix(goversion, key) {
            return off
        }
    }
    panic("unsupported go version:"+goversion)
}()

現(xiàn)在的 goid 偏移量已經(jīng)終于可以自動適配已經(jīng)發(fā)布的 Go 語言版本。

3.8.4 獲取 g 結(jié)構(gòu)體對應(yīng)的接口對象

枚舉和暴力窮舉雖然夠直接,但是對于正在開發(fā)中的未發(fā)布的 Go 版本支持并不好,我們無法提前知曉開發(fā)中的某個(gè)版本的 goid 成員的偏移量。

如果是在 runtime 包內(nèi)部,我們可以通過 unsafe.OffsetOf(g.goid) 直接獲取成員的偏移量。也可以通過反射獲取 g 結(jié)構(gòu)體的類型,然后通過類型查詢某個(gè)成員的偏移量。因?yàn)?g 結(jié)構(gòu)體是一個(gè)內(nèi)部類型,Go 代碼無法從外部包獲取 g 結(jié)構(gòu)體的類型信息。但是在 Go 匯編語言中,我們是可以看到全部的符號的,因此理論上我們也可以獲取 g 結(jié)構(gòu)體的類型信息。

在任意的類型被定義之后,Go 語言都會為該類型生成對應(yīng)的類型信息。比如 g 結(jié)構(gòu)體會生成一個(gè) type·runtime·g 標(biāo)識符表示 g 結(jié)構(gòu)體的值類型信息,同時(shí)還有一個(gè) type·*runtime·g 標(biāo)識符表示指針類型的信息。如果 g 結(jié)構(gòu)體帶有方法,那么同時(shí)還會生成 go.itab.runtime.g 和 go.itab.*runtime.g 類型信息,用于表示帶方法的類型信息。

如果我們能夠拿到表示 g 結(jié)構(gòu)體類型的 type·runtime·g 和 g 指針,那么就可以構(gòu)造 g 對象的接口。下面是改進(jìn)的 getg 函數(shù),返回 g 指針對象的接口:

// func getg() interface{}
TEXT ·getg(SB), NOSPLIT, $32-16
    // get runtime.g
    MOVQ (TLS), AX
    // get runtime.g type
    MOVQ $type·runtime·g(SB), BX

    // convert (*g) to interface{}
    MOVQ AX, 8(SP)
    MOVQ BX, 0(SP)
    CALL runtime·convT2E(SB)
    MOVQ 16(SP), AX
    MOVQ 24(SP), BX

    // return interface{}
    MOVQ AX, ret+0(FP)
    MOVQ BX, ret+8(FP)
    RET

其中 AX 寄存器對應(yīng) g 指針,BX 寄存器對應(yīng) g 結(jié)構(gòu)體的類型。然后通過 runtime·convT2E 函數(shù)將類型轉(zhuǎn)為接口。因?yàn)槲覀兪褂玫牟皇?g 結(jié)構(gòu)體指針類型,因此返回的接口表示的 g 結(jié)構(gòu)體值類型。理論上我們也可以構(gòu)造 g 指針類型的接口,但是因?yàn)?Go 匯編語言的限制,我們無法使用 type·*runtime·g 標(biāo)識符。

基于 g 返回的接口,就可以容易獲取 goid 了:

func GetGoid() int64 {
    g := getg()
    gid := reflect.ValueOf(g).FieldByName("goid").Int()
    return goid
}

上述代碼通過反射直接獲取 goid,理論上只要反射的接口和 goid 成員的名字不發(fā)生變化,代碼都可以正常運(yùn)行。經(jīng)過實(shí)際測試,以上的代碼可以在 Go1.8、Go1.9 和 Go1.10 版本中正確運(yùn)行。樂觀推測,如果 g 結(jié)構(gòu)體類型的名字不發(fā)生變化,Go 語言反射的機(jī)制也不發(fā)生變化,那么未來 Go 語言版本應(yīng)該也是可以運(yùn)行的。

反射雖然具備一定的靈活性,但是反射的性能一直是被大家詬病的地方。一個(gè)改進(jìn)的思路是通過反射獲取 goid 的偏移量,然后通過 g 指針和偏移量獲取 goid,這樣反射只需要在初始化階段執(zhí)行一次。

下面是 g_goid_offset 變量的初始化代碼:

var g_goid_offset uintptr = func() uintptr {
    g := GetGroutine()
    if f, ok := reflect.TypeOf(g).FieldByName("goid"); ok {
        return f.Offset
    }
    panic("can not find g.goid field")
}()

有了正確的 goid 偏移量之后,采用前面講過的方式獲取 goid:

func GetGroutineId() int64 {
    g := getg()
    p := (*int64)(unsafe.Pointer(uintptr(g) + g_goid_offset))
    return *p
}

至此我們獲取 goid 的實(shí)現(xiàn)思路已經(jīng)足夠完善了,不過匯編的代碼依然有嚴(yán)重的安全隱患。

雖然 getg 函數(shù)是用 NOSPLIT 標(biāo)志聲明的禁止棧分裂的函數(shù)類型,但是 getg 內(nèi)部又調(diào)用了更為復(fù)雜的 runtime·convT2E 函數(shù)。runtime·convT2E 函數(shù)如果遇到??臻g不足,可能觸發(fā)棧分裂的操作。而棧分裂時(shí),GC 將要挪動棧上所有函數(shù)的參數(shù)和返回值和局部變量中的棧指針。但是我們的 getg 函數(shù)并沒有提供局部變量的指針信息。

下面是改進(jìn)后的 getg 函數(shù)的完整實(shí)現(xiàn):

// func getg() interface{}
TEXT ·getg(SB), NOSPLIT, $32-16
    NO_LOCAL_POINTERS

    MOVQ $0, ret_type+0(FP)
    MOVQ $0, ret_data+8(FP)
    GO_RESULTS_INITIALIZED

    // get runtime.g
    MOVQ (TLS), AX

    // get runtime.g type
    MOVQ $type·runtime·g(SB), BX

    // convert (*g) to interface{}
    MOVQ AX, 8(SP)
    MOVQ BX, 0(SP)
    CALL runtime·convT2E(SB)
    MOVQ 16(SP), AX
    MOVQ 24(SP), BX

    // return interface{}
    MOVQ AX, ret_type+0(FP)
    MOVQ BX, ret_data+8(FP)
    RET

其中 NO_LOCAL_POINTERS 表示函數(shù)沒有局部指針變量。同時(shí)對返回的接口進(jìn)行零值初始化,初始化完成后通過 GO_RESULTS_INITIALIZED 告知 GC。這樣可以在保證棧分裂時(shí),GC 能夠正確處理返回值和局部變量中的指針。

3.8.5 goid 的應(yīng)用: 局部存儲

有了 goid 之后,構(gòu)造 Goroutine 局部存儲就非常容易了。我們可以定義一個(gè) gls 包提供 goid 的特性:

package gls

var gls struct {
    m map[int64]map[interface{}]interface{}
    sync.Mutex
}

func init() {
    gls.m = make(map[int64]map[interface{}]interface{})
}

gls 包變量簡單包裝了 map,同時(shí)通過 sync.Mutex 互斥量支持并發(fā)訪問。

然后定義一個(gè) getMap 內(nèi)部函數(shù),用于獲取每個(gè) Goroutine 字節(jié)的 map:

func getMap() map[interface{}]interface{} {
    gls.Lock()
    defer gls.Unlock()

    goid := GetGoid()
    if m, _ := gls.m[goid]; m != nil {
        return m
    }

    m := make(map[interface{}]interface{})
    gls.m[goid] = m
    return m
}

獲取到 Goroutine 私有的 map 之后,就是正常的增、刪、改操作接口了:

func Get(key interface{}) interface{} {
    return getMap()[key]
}
func Put(key interface{}, v interface{}) {
    getMap()[key] = v
}
func Delete(key interface{}) {
    delete(getMap(), key)
}

最后我們再提供一個(gè) Clean 函數(shù),用于釋放 Goroutine 對應(yīng)的 map 資源:

func Clean() {
    gls.Lock()
    defer gls.Unlock()

    delete(gls.m, GetGoid())
}

這樣一個(gè)極簡的 Goroutine 局部存儲 gls 對象就完成了。

下面是使用局部存儲簡單的例子:

import (
    gls "path/to/gls"
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(idx int) {
            defer wg.Done()
            defer gls.Clean()

            defer func() {
                fmt.Printf("%d: number = %d\n", idx, gls.Get("number"))
            }()
            gls.Put("number", idx+100)
        }(i)
    }
    wg.Wait()
}

通過 Goroutine 局部存儲,不同層次函數(shù)之間可以共享存儲資源。同時(shí)為了避免資源泄漏,需要在 Goroutine 的根函數(shù)中,通過 defer 語句調(diào)用 gls.Clean() 函數(shù)釋放資源。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號