如何获取 Goroutine ID?

在操作系统中,每个进程都有自己唯一的进程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 应用程序的高级无服务器平台

最后给大家推荐一个最适合部署Go服务的平台:leapcell
1. 多语言支持
2. 免费部署无限项目
3.无与伦比的成本效率
4. 简化的开发人员体验
5.轻松的可扩展性和高性能
在文档中探索更多!
Leapcell Twitter:https://x.com/LeapcellHQ