首页 >后端开发 >Golang >如何获取Goroutine ID?

如何获取Goroutine ID?

Mary-Kate Olsen
Mary-Kate Olsen原创
2025-01-04 10:45:35193浏览

How to Get the Goroutine ID?

在操作系统中,每个进程都有一个唯一的进程ID,每个线程也有自己唯一的线程ID。同样,在Go语言中,每个Goroutine都有自己唯一的Go例程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 的根函数之前,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的方式也是类似。

3. 从g结构中获取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 语言版本了。

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指针对象的接口:

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能够正确处理返回值和局部变量中的指针。

5. goid的应用:本地存储

有了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()函数来释放资源。

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

How to Get the Goroutine ID?

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

1. 多语言支持

  • 使用 JavaScript、Python、Go 或 Rust 进行开发。

2.免费部署无限个项目

  • 只需支付使用费用——无请求,不收费。

3. 无与伦比的成本效益

  • 即用即付,无闲置费用。
  • 示例:25 美元支持 694 万个请求,平均响应时间为 60 毫秒。

4.简化的开发者体验

  • 直观的用户界面,轻松设置。
  • 完全自动化的 CI/CD 管道和 GitOps 集成。
  • 实时指标和日志记录以获取可操作的见解。

5. 轻松的可扩展性和高性能

  • 自动扩展以轻松处理高并发。
  • 零运营开销——只需专注于构建。

在文档中探索更多内容!

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

以上是如何获取Goroutine ID?的详细内容。更多信息请关注PHP中文网其他相关文章!

声明:
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn