如何获取 Goroutine ID?

Image description

在操作系统中,每个进程都有自己唯一的进程ID,每个线程也有自己唯一的线程ID。同样,在Go语言中,每个Goroutine也有自己唯一的Go协程ID,这个ID经常会在panic等场景遇到。虽然Goroutine有固有的ID,但是Go语言刻意没有提供获取这个ID的接口。这次,我们将尝试通过Go汇编语言来获取Goroutine ID。

1. 官方没有goid的设计(https://github.com/golang/go/issues/22770)

根据官方相关资料,Go 语言之所以刻意不提供 goid 是为了避免被滥用。因为大多数使用者在轻松得到 goid 之后,会在后续的编程中不自觉地写出强依赖 goid 的代码。强依赖 goid 会让这段代码难以移植,也会让并发模型复杂化。同时,Go 语言中可能存在大量的 Goroutine,但是不容易实时监控每个 Goroutine 何时被销毁,也会导致依赖 goid 的资源不能自动回收(需要手动回收)。不过,如果你是 Go 汇编语言用户,那么完全可以无视这些顾虑。

**注意**:如果强行获取`goid`,你可能会被“羞辱”😂:

https://github.com/golang/go/blob/master/src/runtime/proc.go#L7120

2. 在 Pure Go 中获取 goid

为了方便理解,我们先尝试在纯 Go 中获取 goid。虽然纯 Go 获取 goid 的性能比较低,但是代码的可移植性很好,也可以用来测试验证其他方法获取的 goid 是否正确。

每个 Go 语言使用者都应该知道 `panic` 函数。调用 `panic` 函数会导致 Goroutine 异常。如果在到达 Goroutine 的根函数之前 `panic` 没有被 `recover` 函数处理,则运行时会打印相关异常和堆栈信息并退出 Goroutine。

我们来构造一个简单的例子,通过‘panic’输出‘goid’:

package main

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

运行后会输出如下信息:

panic: leapcell

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

我们可以猜测,Panic 输出信息 goroutine 1 [running] 中的 1 就是 goid。但是在程序中,我们如何获取 panic 输出的信息呢?其实上面的信息只是对当前函数调用栈框架的文字描述,runtime.Stack 函数提供了获取这些信息的功能。

我们基于 `runtime.Stack` 函数重新构造一个通过输出当前堆栈帧的信息来输出 `goid` 的例子:

package main

import "runtime"

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

运行后会输出如下信息:

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

所以,从`runtime.Stack`获取的字符串中,很容易解析出`goid`信息:

import (
    "fmt"
    "strconv"
    "strings"
    "runtime"
)

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 函数的细节我们就不细说了,需要注意的是,runtime.Stack 函数不仅可以获取当前 Goroutine 的堆栈信息,还可以获取所有 Goroutine 的堆栈信息(由第二个参数控制)。同时,Go 语言中的 net/http2.curGoroutineID 函数也是以类似的方式获取 goid 的。

3.从g结构中获取goid

根据 Go 汇编语言官方文档,每个正在运行的 Goroutine 结构的 `g` 指针都保存在当前正在运行的 Goroutine 所在的系统线程的本地存储 TLS 中。我们可以先获取 TLS 线程本地存储,再从 TLS 中获取 `g` 结构的指针,最后从 `g` 结构中提取 `goid`。

下面通过引用`runtime`包中定义的`get_tls`宏来获取`g`指针:

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

`get_tls` 是定义在 `runtime/go_tls.h` 头文件中的宏函数。

对于AMD64平台,`get_tls`宏函数定义如下:

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

展开get_tls宏函数后,获取g指针的代码如下:

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

其实TLS就类似于线程本地存储的地址,而该地址对应的内存中的数据就是`g`指针。我们可以更直接的理解:

MOVQ (TLS), AX

基于上面的方法,我们可以包装一个 getg 函数来获取 g 指针:

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

然后在Go代码中通过`g`结构体中`goid`成员的偏移量来获取`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` 结构指的是 `runtime/runtime2.go`。

在Go1.10版本中,goid的偏移量为152字节。所以,上述代码只有在goid偏移量也是152字节的Go版本中才能正确运行。根据伟大的汤普森的预言,枚举和暴力破解是解决所有难题的灵丹妙药。我们也可以将goid的偏移量保存到一张表中,然后根据Go版本号查询goid的偏移量。

以下是改进后的代码:

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)
}()

现在,`goid`偏移终于可以自动适配已发布的Go语言版本了。

4.获取g结构对应的接口对象

枚举和暴力破解虽然简单直接,但是对尚未发布的开发中Go版本支持得不是很好,我们无法提前知道某个开发版本中 goid 成员的偏移量。

如果是在 `runtime` 包内部,我们可以通过 `unsafe.OffsetOf(g.goid)` 直接获取成员的偏移量。也可以通过反射获取 `g` 结构体的类型,然后通过类型查询某个成员的偏移量。由于 `g` 结构体是内部类型,因此 Go 代码无法从外部包中获取 `g` 结构的类型信息。但是在 Go 汇编语言中,我们可以看到所有的符号,所以理论上我们也可以获取 `g` 结构的类型信息。

任何类型定义之后,Go 语言都会为该类型生成相应的类型信息。例如 `g` 结构体会生成一个 `type·runtime·g` 标识符来表示 `g` 结构体的值类型信息,还会生成一个 `type·*runtime·g` 标识符来表示指针类型信息。如果 `g` 结构体有方法,还会生成 `go.itab.runtime.g` 和 `go.itab.*runtime.g` 类型信息来表示有方法的类型信息。

如果我们可以得到代表 `g` 结构体的类型的 `type·runtime·g` 和 `g` 指针,那么我们就可以构造 `g` 对象的接口了。以下是改进后的 `getg` 函数,它返回的是 `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寄存器对应着`g`指针,BX寄存器对应着`g`结构体的类型。然后使用`runtime·convT2E`函数将类型转换为接口。由于我们用的不是`g`结构体的指针类型,所以返回的接口表示的是`g`结构体的值类型。理论上我们也可以构造一个`g`指针类型的接口,但是受限于Go汇编语言的限制,我们不能使用`type·*runtime·g`这样的标识符。

根据`g`返回的接口,很容易得到`goid`:

import (
    "reflect"
)

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

上述代码直接通过反射获取了 goid ,理论上只要反射接口和 goid 成员名称不变,代码就能正常运行。经过实际测试,上述代码在 Go1.8、Go1.9、Go1.10 版本中均能正确运行。乐观地讲,如果 g 结构体类型名称不变,Go 语言的反射机制不变,那么在未来的 Go 语言版本中应该也能运行。

反射虽然具有一定的灵活性,但是反射的性能一直被人诟病。一个改进的想法是通过反射获取 goid 的偏移量,再通过 g 指针和偏移量获取 goid,这样在初始化阶段只需要执行一次反射即可。

以下是“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”的实现思路已经足够完整,但是汇编代码仍然存在严重的安全隐患。

虽然 `getg` 函数被声明为使用 `NOSPLIT` 标志禁止栈分裂的函数类型,但是 `getg` 函数内部调用了更复杂的 `runtime·convT2E` 函数。如果 `runtime·convT2E` 函数遇到栈空间不足的情况,则可能触发栈分裂操作。当栈分裂时,GC 会移动函数参数、返回值和局部变量中的堆栈指针。但是我们的 `getg` 函数并没有提供局部变量的指针信息。

以下是改进后的 getg 函数的完整实现:

// 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 表示函数没有局部指针变量。同时,返回的接口用零值初始化,初始化完成后通过 GO_RESULTS_INITIALIZED 通知 GC。这样可以保证在堆栈分裂时,GC 可以正确处理返回值和局部变量中的指针。

5. goid的应用:本地存储

有了 goid 之后,构造 Goroutine 本地存储就变得非常容易了。我们可以定义一个 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`,并支持通过 `sync.Mutex` 互斥锁进行并发访问。

然后定义一个内部的`getMap`函数来获取每个Goroutine字节的`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)
}

最后我们提供一个`Clean`函数来释放该Goroutine对应的`map`资源:

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

    delete(gls.m, GetGoid())
}

这样,一个极简的Goroutine本地存储`gls`对象就完成了。

以下是使用本地存储的简单示例:

import (
    "fmt"
    "sync"
    "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 本地存储,不同层级的函数可以共享存储资源。同时,为了避免资源泄漏,在 Goroutine 的根函数中,需要通过 `defer` 语句调用 `gls.Clean()` 函数来释放资源。

Leapcell:用于托管 Golang 应用程序的高级无服务器平台

Image description

最后给大家推荐一个最适合部署Go服务的平台:leapcell

1. 多语言支持

  • 使用 JavaScript、Python、Go 或 Rust 进行开发。
  • 2. 免费部署无限项目

  • 仅按使用量付费 — 无请求,无费用。
  • 3.无与伦比的成本效率

  • 按需付费,无闲置费用。
  • 例如:25 美元支持 694 万个请求,平均响应时间为 60 毫秒。
  • 4. 简化的开发人员体验

  • 直观的用户界面,轻松设置。
  • 完全自动化的 CI/CD 管道和 GitOps 集成。
  • 实时指标和日志记录可提供可操作的见解。
  • 5.轻松的可扩展性和高性能

  • 自动扩展以轻松处理高并发。
  • 零运营开销——只需专注于建设。
  • 在文档中探索更多!

    Leapcell Twitter:https://x.com/LeapcellHQ