在操作系统中,每个进程都有一个唯一的进程ID,每个线程也有自己唯一的线程ID。同样,在Go语言中,每个Goroutine都有自己唯一的Go例程ID,这在panic等场景中经常遇到。虽然Goroutine有固有的ID,但是Go语言故意不提供获取这个ID的接口。这次我们将尝试通过Go汇编语言获取Goroutine ID。
根据官方相关资料,Go语言故意不提供goid的原因是为了避免滥用。因为大多数用户在轻松获得goid之后,在后续的编程中会不自觉地编写出强烈依赖goid的代码。对 goid 的强烈依赖会导致该代码难以移植,同时也会使并发模型变得复杂。同时,Go语言中可能存在海量的Goroutine,但每个Goroutine何时被销毁并不容易实时监控,这也会导致依赖goid的资源无法自动回收(需要人工回收)。不过,如果你是 Go 汇编语言用户,你完全可以忽略这些担忧。
注意:如果强行获得goid,可能会被“羞辱”?:
https://github.com/golang/go/blob/master/src/runtime/proc.go#L7120
为了方便理解,我们先尝试获取纯Go中的goid。虽然纯Go中获取goid的性能比较低,但代码具有良好的可移植性,也可以用来测试验证其他方法获取的goid是否正确。
每个Go语言用户都应该知道panic函数。调用panic函数会导致Goroutine异常。如果在到达 Goroutine 的根函数之前,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 func main() { panic("leapcell") }
运行后会输出以下信息:
panic: leapcell goroutine 1 [running]: main.main() /path/to/main.go:4 +0x40
所以,从runtime.Stack得到的字符串中解析出goid信息就很容易了:
package main import "runtime" func main() { var buf = make([]byte, 64) var stk = buf[:runtime.Stack(buf, false)] print(string(stk)) }
GetGoid 函数的细节我们不再赘述。需要注意的是,runtime.Stack函数不仅可以获取当前Goroutine的堆栈信息,还可以获取所有Goroutine的堆栈信息(由第二个参数控制)。同时Go语言中的net/http2.curGoroutineID函数获取goid的方式也是类似。
根据Go官方汇编语言文档,每个正在运行的Goroutine结构体的g指针都存储在当前运行的Goroutine所在系统线程的本地存储TLS中。我们可以先获取TLS线程本地存储,然后从TLS中获取g结构体的指针,最后从g结构体中提取goid。
下面是通过引用runtime包中定义的get_tls宏来获取g指针:
goroutine 1 [running]: main.main() /path/to/main.g
get_tls是runtime/go_tls.h头文件中定义的宏函数。
对于AMD64平台,get_tls宏函数定义如下:
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) }
扩展get_tls宏函数后,获取g指针的代码如下:
get_tls(CX) MOVQ g(CX), AX // Move g into AX.
其实TLS类似于线程本地存储的地址,该地址对应的内存中的数据就是g指针。我们可以更直接一点:
#ifdef GOARCH_amd64 #define get_tls(r) MOVQ TLS, r #define g(r) 0(r)(TLS*1) #endif
基于上面的方法,我们可以封装一个getg函数来获取g指针:
MOVQ TLS, CX MOVQ 0(CX)(TLS*1), AX
然后,在Go代码中,通过goid成员在g结构体中的偏移量来获取goid的值:
MOVQ (TLS), AX
这里,g_goid_offset是goid成员的偏移量。 g结构指的是runtime/runtime2.go。
Go1.10版本中,goid的偏移量为152字节。所以,上面的代码只能在 goid 偏移量也是 152 字节的 Go 版本中正确运行。根据伟大汤普森的神谕,枚举和蛮力是解决所有难题的灵丹妙药。我们也可以将 goid 偏移量保存在一个表中,然后根据 Go 版本号查询 goid 偏移量。
以下是改进后的代码:
// func getg() unsafe.Pointer TEXT ·getg(SB), NOSPLIT, <pre class="brush:php;toolbar:false">const g_goid_offset = 152 // Go1.10 func GetGroutineId() int64 { g := getg() p := (*int64)(unsafe.Pointer(uintptr(g) + g_goid_offset)) return *p }-8 MOVQ (TLS), AX MOVQ AX, ret+0(FP) RET
现在,goid 偏移量终于可以自动适配已发布的 Go 语言版本了。
虽然枚举和暴力破解很简单,但它们不能很好地支持正在开发的未发布的 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指针对象的接口:
package main func main() { panic("leapcell") }
这里,AX寄存器对应g指针,BX寄存器对应g结构体的类型。然后,使用runtime·convT2E函数将类型转换为接口。因为我们没有使用g结构的指针类型,所以返回的接口表示g结构的值类型。理论上我们也可以构造一个g指针类型的接口,但是由于Go汇编语言的限制,我们无法使用type·*runtime·g标识符。
根据g返回的接口,很容易获取goid:
panic: leapcell goroutine 1 [running]: main.main() /path/to/main.go:4 +0x40
以上代码直接通过反射获取goid。理论上只要反射接口的名称和goid成员不发生变化,代码就可以正常运行。经过实际测试,上述代码在Go1.8、Go1.9、Go1.10版本中均能正确运行。乐观地讲,如果g结构类型的名称不改变,Go语言的反射机制不改变,应该也能在未来的Go语言版本中运行。
虽然反射具有一定的灵活性,但是反射的性能却一直被诟病。一个改进的思路是通过反射获取goid的偏移量,然后通过g指针和偏移量获取goid,这样反射只需要在初始化阶段执行一次。
以下是g_goid_offset变量的初始化代码:
package main import "runtime" func main() { var buf = make([]byte, 64) var stk = buf[:runtime.Stack(buf, false)] print(string(stk)) }
获得正确的goid偏移量后,按照前面提到的方式获取goid:
package main func main() { panic("leapcell") }
至此,我们获取goid的实现思路已经足够完整,但是汇编代码仍然存在严重的安全隐患。
虽然 getg 函数被声明为禁止使用 NOSPLIT 标志进行堆栈拆分的函数类型,但 getg 函数内部调用了更复杂的runtime·convT2E 函数。如果runtime·convT2E函数遇到堆栈空间不足,可能会触发堆栈分裂操作。当堆栈分裂时,GC会移动函数参数、返回值和局部变量中的堆栈指针。然而,我们的 getg 函数不提供局部变量的指针信息。
以下是改进后的getg函数的完整实现:
panic: leapcell goroutine 1 [running]: main.main() /path/to/main.go:4 +0x40
这里,NO_LOCAL_POINTERS 表示该函数没有局部指针变量。同时对返回的接口进行零值初始化,初始化完成后用GO_RESULTS_INITIALIZED通知GC。这样可以保证当栈分裂时,GC能够正确处理返回值和局部变量中的指针。
有了goid,构建Goroutine本地存储就变得非常简单。我们可以定义一个 gls 包来提供 goid 功能:
package main import "runtime" func main() { var buf = make([]byte, 64) var stk = buf[:runtime.Stack(buf, false)] print(string(stk)) }
gls 包变量只是包装了一个映射,并支持通过sync.Mutex 互斥体进行并发访问。
然后定义一个内部 getMap 函数来获取每个 Goroutine 字节的映射:
goroutine 1 [running]: main.main() /path/to/main.g
获取Goroutine的私有映射后,就是增删改操作的正常接口:
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) }
最后,我们提供了一个Clean函数来释放Goroutine对应的map资源:
get_tls(CX) MOVQ g(CX), AX // Move g into AX.
这样,一个极简的Goroutine本地存储gls对象就完成了。
以下是使用本地存储的简单示例:
#ifdef GOARCH_amd64 #define get_tls(r) MOVQ TLS, r #define g(r) 0(r)(TLS*1) #endif
通过Goroutine本地存储,不同级别的函数可以共享存储资源。同时,为了避免资源泄漏,在Goroutine的根函数中,需要通过defer语句调用gls.Clean()函数来释放资源。
最后给大家推荐一个最适合部署Go服务的平台:leapcell
在文档中探索更多内容!
Leapcell Twitter:https://x.com/LeapcellHQ
以上是如何获取Goroutine ID?的详细内容。更多信息请关注PHP中文网其他相关文章!