Golang 獲取 goroutine id 完全指南

AlexaMa發表於2018-03-06

在Golang中,每個goroutine協程都有一個goroutine id (goid),該goid沒有嚮應用層暴露。但是,在很多場景下,開發者又希望使用goid作為唯一標識,將一個goroutine中的函式層級呼叫串聯起來。比如,希望在一個http handler中將這個請求的每行日誌都加上對應的goid以便於對這個請求處理過程進行跟蹤和分析。

關於是否應該將goid暴露給應用層已經爭論多年。基本上,Golang的開發者都一致認為不應該暴露goid(faq: document why there is no way to get a goroutine ID),主要有以下幾點理由:

  1. goroutine設計理念是輕量,鼓勵開發者使用多goroutine進行開發,不希望開發者通過goid做goroutine local storage或thread local storage(TLS)的事情;
  2. Golang開發者Brad認為TLS在C/C++實踐中也問題多多,比如一些使用TLS的庫,thread狀態非常容易被非期望執行緒修改,導致crash.
  3. goroutine並不等價於thread, 開發者可以通過syscall獲取thread id,因此根本不需要暴露goid.

官方也一直推薦使用context作為上下文關聯的最佳實踐。如果你還是想獲取goid,下面是我整理的目前已知的所有獲取它的方式,希望你想清楚了再使用。

  1. 通過stack資訊獲取goroutine id.
  2. 通過修改原始碼獲取goroutine id.
  3. 通過CGo獲取goroutine id.
  4. 通過彙編獲取goroutine id.
  5. 通過彙編獲取goroutine id.

在開始介紹各種方法前,先看一下定義在src/runtime/runtime2.go中儲存goroutine狀態的g結構:

type g struct {
    // Stack parameters.
    // stack describes the actual stack memory: [stack.lo, stack.hi).
    // stackguard0 is the stack pointer compared in the Go stack growth prologue.
    // It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
    // stackguard1 is the stack pointer compared in the C stack growth prologue.
    // It is stack.lo+StackGuard on g0 and gsignal stacks.
    // It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
    stack       stack   // offset known to runtime/cgo
    stackguard0 uintptr // offset known to liblink
    stackguard1 uintptr // offset known to liblink

    _panic         *_panic // innermost panic - offset known to liblink
    _defer         *_defer // innermost defer
    m              *m      // current m; offset known to arm liblink
    sched          gobuf
    syscallsp      uintptr        // if status==Gsyscall, syscallsp = sched.sp to use during gc
    syscallpc      uintptr        // if status==Gsyscall, syscallpc = sched.pc to use during gc
    stktopsp       uintptr        // expected sp at top of stack, to check in traceback
    param          unsafe.Pointer // passed parameter on wakeup
    atomicstatus   uint32
    stackLock      uint32 // sigprof/scang lock; TODO: fold in to atomicstatus
    goid           int64 // goroutine id
    ...

其中goid int64欄位即為當前goroutine的id。

1. 通過stack資訊獲取goroutine id

package main

import (
    "bytes"
    "fmt"
    "runtime"
    "strconv"
)

func main() {
    fmt.Println(GetGID())
}

func GetGID() uint64 {
    b := make([]byte, 64)
    b = b[:runtime.Stack(b, false)]
    b = bytes.TrimPrefix(b, []byte("goroutine "))
    b = b[:bytes.IndexByte(b, ' ')]
    n, _ := strconv.ParseUint(string(b), 10, 64)
    return n
}

原理非常簡單,將stack中的文字資訊"goroutine 1234"匹配出來。但是這種方式有兩個問題:

  1. stack資訊的格式隨版本更新可能變化,甚至不再提供goroutine id,可靠性差。
  2. 效能較差,呼叫10000次消耗>50ms。

如果你只是想在個人專案中使用goid,這個方法是可以勝任的。維護和修改成本相對較低,且不需要引入任何第三方依賴。同時建議你就此打住,不要繼續往下看了。

2. 通過修改原始碼獲取goroutine id

既然方法1效率較低,且不可靠,那麼我們可以嘗試直接修改原始碼src/runtime/runtime2.go中新增Goid函式,將goid暴露給應用層:

func Goid() int64 {
    _g_ := getg()
    return _g_.goid
}

這個方式能解決法1的兩個問題,但是會導致你的程式只能在修改了原始碼的機器上才能編譯,沒有移植性,並且每次go版本升級以後,都需要重新修改原始碼,維護成本較高。

3. 通過CGo獲取goroutine id

那麼有沒有效能好,同時不影響移植性,且維護成本低的方法呢?那就是來自Dave Cheney的CGo方式:

檔案id.c:

#include "runtime.h"

int64 ·Id(void) {
    return g->goid;
}

檔案id.go:

package id

func Id() int64

完整程式碼參見junk/id.

這種方法的問題在於你需要開啟CGo, CGo存在一些缺點,具體可以參見這個大牛的cgo is not Go. 我相信在你絕大部分的工程專案中,你是不希望開啟CGo的。

4. 通過彙編獲取goroutine id

如果前面三種方法我們都不能接受,有沒有第四種方法呢?那就是通過彙編獲取goroutine id的方法。原理是:通過getg方法(彙編實現)獲取到當前goroutine的g結構地址,根據偏移量計算出成員goid int的地址,然後取出該值即可。

專案goroutine實現了這種方法。需要說明的是,這種方法看似簡單,實際上因為每個go版本幾乎都會有針對g結構的調整,因此goid int64的偏移並不是固定的,更加複雜的是,go在編譯的時候,傳遞的編譯引數也會影響goid int64的偏移值,因此,這個專案的作者花了非常多精力來維護每個go版本g結構偏移的計算,詳見hack目錄。

這個方法效能好,原理清晰,實際使用上穩定性也不錯(我們在部分不太重要的線上業務使用了這種方法)。但是,維護這個庫也許真的太累了,最近發現作者將這個庫標記為“DEPRECATED”,看來獲取goroutine id是條越走越遠的不歸路

5. 通過彙編獲取goroutine id

雖然方法4從原理和實際應用上表現都不錯,但是畢竟作者棄坑了。回到我們要解決的問題上:我們並不是真的一定要獲取到goroutine id,我們只是想獲取到goroutine的唯一標識。那麼,從這個角度看的話,我們只需要解決goroutine標識唯一性的問題即可。

顯然,上面作者也想清楚了這個問題。他新開了一個庫go-tls, 這個庫實現了goroutine local storage,其中獲取goroutine id的方式是:用方法4的彙編獲取goroutine的地址,然後自己管理和分配goroutine id。由於它獲取到的並不是真正的goroutine id,因此我將之稱為goroutine id。其實現的核心程式碼如下:

var (
    tlsDataMap  = map[unsafe.Pointer]*tlsData{}
    tlsMu       sync.Mutex
    tlsUniqueID int64
)

...

func fetchDataMap(readonly bool) *tlsData {
    gp := g.G() // 1. 獲取g結構地址

    if gp == nil {
        return nil
    }

    // Try to find saved data.
    needHack := false
    tlsMu.Lock()
    dm := tlsDataMap[gp]

    if dm == nil && !readonly {
        needHack = true
        dm = &tlsData{
            id:   atomic.AddInt64(&tlsUniqueID, 1), // 2. 分配偽goroutine id
            data: dataMap{},
        }
        tlsDataMap[gp] = dm
    }

    tlsMu.Unlock()

    // Current goroutine is not hacked. Hack it.
    if needHack {
        if !hack(gp) {
            tlsMu.Lock()
            delete(tlsDataMap, gp)
            tlsMu.Unlock()
        }
    }

    return dm
}
  1. 獲取g結構地址。
  2. 分配偽goroutine id.

這種方式基本沒有什麼不能接受的hack實現,從原理上來說也更加安全。但是獲取到不是你最開始想要的goroutine id,不知你能否接受

小結

獲取goroutine id是一條不歸路,目前也沒有完美的獲取它的方式。如果你一定要使用goroutine id,先想清楚你要解決的問題是什麼,如果沒有必要,建議你不要走上這條不歸路。儘早在團隊中推廣使用context, 越早使用越早脫離對goroutine id的留戀和掙扎。

Credit

Originally published at liudanking.com

相關文章