0%

golang_runtime函数调用信息

函数的调用信息是程序中比较重要运行期信息, 在很多场合都会用到(比如调试或日志)。

Go 语言 runtime 包的 runtime.Caller / runtime.Callers / runtime.FuncForPC 等几个函数提供了获取函数调用者信息的方法.

这几个函数的文档链接:

runtime.Caller的用法(常用)

函数的签名如下:

1
func runtime.Caller(skip int) (pc uintptr, file string, line int, ok bool)

runtime.Caller 返回当前 goroutine 的栈上的函数调用信息. 主要有当前的pc 值和调用的文件和行号等信息. 若无法获得信息, 返回的 ok 值为 false.

其输入参数 skip 为要跳过的栈帧数, 若为 0 则表示 runtime.Caller 的调用者.

注意:由于历史原因, runtime.Caller 和 runtime.Callers 中的 skip 含义并不相同, 后面会讲到.

下面是一个简单的例子, 打印函数调用的栈帧信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
for skip := 0; skip < 3; skip++ {
pc, file, line, ok := runtime.Caller(skip)
if !ok {
break
}
fmt.Printf("skip = %v, pc = %v, file = %v, line = %v\n", skip, pc, file, line)
}
// Output:
// skip = 0, pc = 4198453, file = caller.go, line = 10
// skip = 1, pc = 4280066, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 220
// skip = 2, pc = 4289712, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 1394
}

runtime.Callers的用法

函数的签名如下:

1
func runtime.Callers(skip int, pc []uintptr) int

runtime.Callers 函数和 runtime.Caller 函数虽然名字相似(多一个后缀s), 但是函数的参数/返回值和参数的意义都有很大的差异.

runtime.Callers 把调用它的函数Go程栈上的程序计数器填入切片 pc 中. 参数 skip 为开始在 pc 中记录之前所要跳过的栈帧数, 若为 0 则表示 runtime.Callers 自身的栈帧, 若为 1 则表示调用者的栈帧. 该函数返回写入到 pc 切片中的项数(受切片的容量限制).

下面是 runtime.Callers 的例子, 用于输出每个栈帧的 pc 信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
pc := make([]uintptr, 1024)
for skip := 0; ; skip++ {
n := runtime.Callers(skip, pc)
if n <= 0 {
break
}
fmt.Printf("skip = %v, pc = %v\n", skip, pc[:n])
}
// Output:
// skip = 0, pc = [4304486 4198562 4280114 4289760]
// skip = 1, pc = [4198562 4280114 4289760]
// skip = 2, pc = [4280114 4289760]
// skip = 3, pc = [4289760]
}

输出新的 pc 长度和 skip 大小有逆相关性. skip = 0 为 runtime.Callers 自身的信息.

这个例子比前一个例子多输出了一个栈帧, 就是因为多了一个 runtime.Callers 栈帧的信息 (前一个例子是没有 runtime.Caller 信息的(注意:没有 s 后缀)).

runtime.Callers 和 runtime.Caller 的异同

因为前面2个例子为不同的程序, 输出的 pc 值并不具备参考性. 现在我们看看在同一个例子的输出结果如何:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func main() {
for skip := 0; ; skip++ {
pc, file, line, ok := runtime.Caller(skip)
if !ok {
break
}
fmt.Printf("skip = %v, pc = %v, file = %v, line = %v\n", skip, pc, file, line)
}
// Output:
// skip = 0, pc = 4198456, file = caller.go, line = 10
// skip = 1, pc = 4280962, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 220
// skip = 2, pc = 4290608, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 1394
pc := make([]uintptr, 1024)
for skip := 0; ; skip++ {
n := runtime.Callers(skip, pc)
if n <= 0 {
break
}
fmt.Printf("skip = %v, pc = %v\n", skip, pc[:n])
}
// Output:
// skip = 0, pc = [4305334 4198635 4280962 4290608]
// skip = 1, pc = [4198635 4280962 4290608]
// skip = 2, pc = [4280962 4290608]
// skip = 3, pc = [4290608]
}

比如输出结果可以发现, 42809624290608 两个 pc 值是相同的. 它们分别对应 runtime.mainruntime.goexit 函数.

runtime.Caller 输出的 4198456runtime.Callers 输出的 4198635 并不相同. 这是因为, 这两个函数的调用位置并不相同, 因此导致了 pc 值也不完全相同.

最后就是 runtime.Callers 多输出一个 4305334 值, 对应runtime.Callers内部的调用位置.

由于Go语言(Go1.2)采用分段堆栈, 因此不同的 pc 之间的大小关系并不明显.

runtime.FuncForPC 的用途

函数的签名如下:

1
2
3
4
func runtime.FuncForPC(pc uintptr) *runtime.Func
func (f *runtime.Func) FileLine(pc uintptr) (file string, line int)
func (f *runtime.Func) Entry() uintptr
func (f *runtime.Func) Name() string

其中 runtime.FuncForPC 返回包含给定 pc 地址的函数, 如果是无效 pc 则返回 nil .

runtime.Func.FileLine 返回与 pc 对应的源码文件名和行号. 安装文档的说明, 如果pc不在函数帧范围内, 则结果是不确定的.

runtime.Func.Entry 对应函数的地址.

runtime.Func.Name 返回该函数的名称.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
func main() {
for skip := 0; ; skip++ {
pc, _, _, ok := runtime.Caller(skip)
if !ok {
break
}
p := runtime.FuncForPC(pc)
file, line := p.FileLine(0)

fmt.Printf("skip = %v, pc = %v\n", skip, pc)
fmt.Printf(" file = %v, line = %d\n", file, line)
fmt.Printf(" entry = %v\n", p.Entry())
fmt.Printf(" name = %v\n", p.Name())
}
// Output:
// skip = 0, pc = 4198456
// file = caller.go, line = 8
// entry = 4198400
// name = main.main
// skip = 1, pc = 4282882
// file = $(GOROOT)/src/pkg/runtime/proc.c, line = 179
// entry = 4282576
// name = runtime.main
// skip = 2, pc = 4292528
// file = $(GOROOT)/src/pkg/runtime/proc.c, line = 1394
// entry = 4292528
// name = runtime.goexit


pc := make([]uintptr, 1024)
for skip := 0; ; skip++ {
n := runtime.Callers(skip, pc)
if n <= 0 {
break
}
fmt.Printf("skip = %v, pc = %v\n", skip, pc[:n])
for j := 0; j < n; j++ {
p := runtime.FuncForPC(pc[j])
file, line := p.FileLine(0)

fmt.Printf(" skip = %v, pc = %v\n", skip, pc[j])
fmt.Printf(" file = %v, line = %d\n", file, line)
fmt.Printf(" entry = %v\n", p.Entry())
fmt.Printf(" name = %v\n", p.Name())
}
break
}
// Output:
// skip = 0, pc = [4307254 4198586 4282882 4292528]
// skip = 0, pc = 4307254
// file = $(GOROOT)/src/pkg/runtime/runtime.c, line = 315
// entry = 4307168
// name = runtime.Callers
// skip = 0, pc = 4198586
// file = caller.go, line = 8
// entry = 4198400
// name = main.main
// skip = 0, pc = 4282882
// file = $(GOROOT)/src/pkg/runtime/proc.c, line = 179
// entry = 4282576
// name = runtime.main
// skip = 0, pc = 4292528
// file = $(GOROOT)/src/pkg/runtime/proc.c, line = 1394
// entry = 4292528
// name = runtime.goexit
}

根据测试, 如果是无效 pc (比如0), runtime.Func.FileLine 一般会输出当前函数的开始行号. 不过在实践中, 一般会用 runtime.Caller 获取文件名和行号信息, runtime.Func.FileLine 很少用到.

定制的 CallerName 函数

基于前面的几个函数, 我们可以方便的定制一个 CallerName 函数. 函数 CallerName 返回调用者的函数名/文件名/行号等用户友好的信息.

1
2
3
4
5
6
7
8
func CallerName(skip int) (name, file string, line int, ok bool) {
var pc uintptr
if pc, file, line, ok = runtime.Caller(skip + 1); !ok {
return
}
name = runtime.FuncForPC(pc).Name()
return
}

其中在执行 runtime.Caller 调用时, 参数 skip + 1 用于抵消 CallerName 函数自身的调用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
for skip := 0; ; skip++ {
name, file, line, ok := CallerName(skip)
if !ok {
break
}
fmt.Printf("skip = %v\n", skip)
fmt.Printf(" file = %v, line = %d\n", file, line)
fmt.Printf(" name = %v\n", name)
}
// Output:
// skip = 0
// file = caller.go, line = 19
// name = main.main
// skip = 1
// file = C:/go/go-tip/src/pkg/runtime/proc.c, line = 220
// name = runtime.main
// skip = 2
// file = C:/go/go-tip/src/pkg/runtime/proc.c, line = 1394
// name = runtime.goexit
}

Go 语言中函数的类型

在 Go 语言中, 除了语言定义的普通函数调用外, 还有闭包函数/init函数/全局变量初始化等不同的函数调用类型.

为了便于测试不同类型的函数调用, 我们包装一个 PrintCallerName 函数. 该函数用于输出调用者的信息.

1
2
3
4
5
6
7
8
9
10
func PrintCallerName(skip int, comment string) bool {
name, file, line, ok := CallerName(skip + 1)
if !ok {
return false
}
fmt.Printf("skip = %v, comment = %s\n", skip, comment)
fmt.Printf(" file = %v, line = %d\n", file, line)
fmt.Printf(" name = %v\n", name)
return true
}

然后编写以下的测试代码(函数闭包调用/全局变量初始化/init函数等):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var a = PrintCallerName(0, "main.a")
var b = PrintCallerName(0, "main.b")

func init() {
a = PrintCallerName(0, "main.init.a")
}

func init() {
b = PrintCallerName(0, "main.init.b")
func() {
b = PrintCallerName(0, "main.init.b[1]")
}()
}

func main() {
a = PrintCallerName(0, "main.main.a")
b = PrintCallerName(0, "main.main.b")
func() {
b = PrintCallerName(0, "main.main.b[1]")
func() {
b = PrintCallerName(0, "main.main.b[1][1]")
}()
b = PrintCallerName(0, "main.main.b[2]")
}()
}

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Output:
// skip = 0, comment = main.a
// file = caller.go, line = 8
// name = main.init
// skip = 0, comment = main.b
// file = caller.go, line = 9
// name = main.init
// skip = 0, comment = main.init.a
// file = caller.go, line = 12
// name = main.init·1
// skip = 0, comment = main.init.b
// file = caller.go, line = 16
// name = main.init·2
// skip = 0, comment = main.init.b[1]
// file = caller.go, line = 18
// name = main.func·001
// skip = 0, comment = main.main.a
// file = caller.go, line = 23
// name = main.main
// skip = 0, comment = main.main.b
// file = caller.go, line = 24
// name = main.main
// skip = 0, comment = main.main.b[1]
// file = caller.go, line = 26
// name = main.func·003
// skip = 0, comment = main.main.b[1][1]
// file = caller.go, line = 28
// name = main.func·002
// skip = 0, comment = main.main.b[2]
// file = caller.go, line = 30
// name = main.func·003

观察输出结果, 可以发现以下几个规律:

  • 全局变量的初始化调用者为 main.init 函数
  • 自定义的 init 函数有一个数字后缀, 根据出现的顺序进编号. 比如 main.init·1main.init·2 等.
  • 闭包函数采用 main.func·001 格式命名, 安装闭包定义结束的位置顺序进编号.

不同 Go 程序启动流程

基于函数调用者信息可以很容易的验证各种环境的程序启动流程.

我们需要建立一个独立的 caller 目录, 里面有三个测试代码.

caller/main.go 主程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"fmt"
"regexp"
"runtime"
)

func main() {
_ = PrintCallerName(0, "main.main._")
}

func PrintCallerName(skip int, comment string) bool {
// 实现和前面的例子相同
}

func CallerName(skip int) (name, file string, line int, ok bool) {
// 实现和前面的例子相同
}

caller/main_test.go 主程序的测试文件(同在一个main包):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
"testing"
)

func TestPrintCallerName(t *testing.T) {
for skip := 0; ; skip++ {
name, file, line, ok := CallerName(skip)
if !ok {
break
}
fmt.Printf("skip = %v, name = %v, file = %v, line = %v\n", skip, name, file, line)
}
t.Fail()
}

caller/example_test.go 主程序的包的调用者(在新的main_test包):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main_test

import (
myMain "."
"fmt"
)

func Example() {
for skip := 0; ; skip++ {
name, file, line, ok := myMain.CallerName(skip)
if !ok {
break
}
fmt.Printf("skip = %v, name = %v, file = %v, line = %v\n", skip, name, file, line)
}
// Output: ?
}

然后进入 caller 目录, 运行 go run test 可以得到以下的输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
skip = 0, name = caller.TestPrintCallerName, file = caller/main_test.go, line = 10
skip = 1, name = testing.tRunner, file = $(GOROOT)/src/pkg/testing/testing.go, line = 391
skip = 2, name = runtime.goexit, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 1394
--- FAIL: TestPrintCallerName (0.00 seconds)
--- FAIL: Example (2.0001ms)
got:
skip = 0, name = caller_test.Example, file = caller/example_test.go, line = 10

skip = 1, name = testing.runExample, file = $(GOROOT)/src/pkg/testing/example.go, line = 98
skip = 2, name = testing.RunExamples, file = $(GOROOT)/src/pkg/testing/example.go, line = 36
skip = 3, name = testing.Main, file = $(GOROOT)/src/pkg/testing/testing.go, line = 404
skip = 4, name = main.main, file = $(TEMP)/go-build365033523/caller/_test/_testmain.go, line = 51
skip = 5, name = runtime.main, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 220
skip = 6, name = runtime.goexit, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 1394
want:
?
FAIL
exit status 1
FAIL caller 0.254s

分析输出数据我们可以发现, 测试代码和例子代码的启动流程和普通的程序流程都不太一样.

测试代码的启动流程:

  1. runtime.goexit 还是入口
  2. 但是 runtime.goexit 不在调用 runtime.main 函数, 而是调用 testing.tRunner 函数
  3. testing.tRunner 函数由 go test 命令生成, 用于执行各个测试函数

例子代码的启动流程:

  1. runtime.goexit 还是入口
  2. 然后 runtime.goexit 调用 runtime.main 函数
  3. 最终 runtime.main 调用go test 命令生成的 main.main 函数, 在 _test/_testmain.go 文件
  4. 然后调用 testing.Main, 改函数执行各个例子函数

另外, 从这个例子我们可以发现, 我们自己写的 main.main 函数所在的 main 包也 可以被其他包导入. 但是其他包导入之后的 main 包里的 main 函数就不再是 main.main 函数了. 因此, 程序的入口也就不是自己写的 main.main 函数了.

总结

Go 语言 runtime 包的 runtime.Caller / runtime.Callers / runtime.FuncForPC 等函数虽然看起来比较简单, 但是功能却非常强大.

这几个函数不仅可以解决一些实际的工程问题 , 而且非常适合用于调试和分析各种Go程序的运行时信息.

可以加首页作者微信,咨询相关问题!