Go語言的函數調用信息
注: 本文初稿發在 Golang 中國博客, 這里的內容有部分修改.
函數的調用信息是程序中比較重要運行期信息, 在很多場合都會用到(比如調試或日志).
Go語言 runtime 包的 runtime.Caller / runtime.Callers / runtime.FuncForPC 等幾個函數提供了獲取函數調用者信息的方法.
這幾個函數的文檔鏈接:
- http://golang.org/pkg/runtime/#Caller
- http://golang.org/pkg/runtime/#Callers
- http://golang.org/pkg/runtime/#FuncForPC
本文主要講述這幾個函數的用法.
runtime.Caller 的用法
函數的簽名如下:
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 含義并不相同, 后面會講到.
下面是一個簡單的例子, 打印函數調用的棧幀信息:
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 = 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
} 其中 skip = 0 為當前文件(“caller.go”)的 main.main 函數, 以及對應的行號. 這里省略的無關代碼, 因此輸出的行號和網頁展示的位置有些差異.
另外的 skip = 1 和 skip = 2 也分別對應2個函數調用. 通過查閱 runtime/proc.c 文件的代碼, 我們可以知道對應的函數分別為 runtime.main 和 runtime.goexit.
整理之后可以知道, Go的普通程序的啟動順序如下:
runtime.goexit為真正的函數入口(并不是main.main)- 然后
runtime.goexit調用runtime.main函數 - 最終
runtime.main調用用戶編寫的main.main函數
runtime.Callers 的用法
函數的簽名如下:
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 信息:
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 有哪些關聯和差異?
runtime.Callers 和 runtime.Caller 的異同
因為前面2個例子為不同的程序, 輸出的 pc 值并不具備參考性. 現在我們看看在同一個例子的輸出結果如何:
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]
} 比如輸出結果可以發現, 4280962 和 4290608 兩個 pc 值是相同的. 它們分別對應 runtime.main 和 runtime.goexit 函數.
runtime.Caller 輸出的 4198456 和 runtime.Callers 輸出的 4198635 并不相同. 這是因為, 這兩個函數的調用位置并不相同, 因此導致了 pc 值也不完全相同.
最后就是 runtime.Callers 多輸出一個 4305334 值, 對應runtime.Callers內部的調用位置.
由于Go語言(Go1.2)采用分段堆棧, 因此不同的 pc 之間的大小關系并不明顯.
runtime.FuncForPC 的用途
函數的簽名如下:
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 返回該函數的名稱.
下面是 runtime.FuncForPC 的例子:
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 很少用到(如何獨立獲取pc參數?).
定制的 CallerName 函數
基于前面的幾個函數, 我們可以方便的定制一個 CallerName 函數. 函數 CallerName 返回調用者的函數名/文件名/行號等用戶友好的信息.
函數實現如下:
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 函數自身的調用.
下面是基于 CallerName 的輸出例子:
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 = $(GOROOT)/src/pkg/runtime/proc.c, line = 220
// name = runtime.main
// skip = 2
// file = $(GOROOT)/src/pkg/runtime/proc.c, line = 1394
// name = runtime.goexit
} 這樣就可以方便的輸出函數調用者的信息了.
Go語言中函數的類型
在Go語言中, 除了語言定義的普通函數調用外, 還有閉包函數/init函數/全局變量初始化等不同的函數調用類型.
為了便于測試不同類型的函數調用, 我們包裝一個 PrintCallerName 函數. 該函數用于輸出調用者的信息.
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函數等):
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]")
}()
} 輸出結果如下:
// 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·1和main.init·2等. - 閉包函數采用
main.func·001格式命名, 安裝閉包定義結束的位置順序進編號.
比如以下全局變量的初始化調用者為 main.init 函數:
var a = PrintCallerName(0, "main.a") var b = PrintCallerName(0, "main.b")
以下兩個 init 函數根據出現順序分別對應 main.init·1 和 main.init·2 :
func init() { // main.init·1
//
}
func init() { // main.init·2
//
} 以下三個閉包根據定義結束順序分別為 001 / 002 / 003 :
func init() {
func(){
//
}() // main.func·001
}
func main() {
func() {
func(){
//
}() // main.func·002
}() // main.func·003
} 因為, 這些特殊函數調用方式的存在, 我們需要進一步完善 CallerName 函數.
改進的 CallerName 函數
兩類特殊的調用是 init 類函數調用 和 閉包函數調用.
改進后的 CallerName 函數對 init 類函數調用者統一處理為 init 函數. 將閉包函數調用這處理為調用者的函數名.
// caller types:
// runtime.goexit
// runtime.main
// main.init
// main.init·1
// main.main
// main.func·001
// code.google.com/p/gettext-go/gettext.TestCallerName
// ...
func CallerName(skip int) (name, file string, line int, ok bool) {
var (
reInit = regexp.MustCompile(`init·\d+$`) // main.init·1
reClosure = regexp.MustCompile(`func·\d+$`) // main.func·001
)
for {
var pc uintptr
if pc, file, line, ok = runtime.Caller(skip + 1); !ok {
return
}
name = runtime.FuncForPC(pc).Name()
if reInit.MatchString(name) {
name = reInit.ReplaceAllString(name, "init")
return
}
if reClosure.MatchString(name) {
skip++
continue
}
return
}
return
} 處理的思路:
- 如果是
init類型的函數調用(匹配正則表達式"init·\d+$"), 直接作為init函數范返回 - 如果是
func閉包類型(匹配正則表達式"func·\d+$"), 跳過當前棧幀, 繼續遞歸處理 - 返回普通的函數調用類型
CallerName 函數的不足之處
有以下的代碼:
func init() {
var _ = myInit("1")
}
func main() {
var _ = myInit("2")
}
var myInit = func(name string) {
b = PrintCallerName(0, name + ":main.myInit.b")
} myInit 為一個全局變量, 被賦值為一個閉包函數. 然后在 init 和 main 函數分別調用 myInit 這個閉包函數輸出的結果
會因為調用環境的不同而有差異.
從直觀上看, myInit閉包函數在執行時, 最好輸出 main.myInit 函數名. 但是 main.myInit 只是一個綁定到閉包函數的變量, 而閉包的真正名字是 main.func·???. 在運行時是無法得到 main.myInit 這個名字的.
因此在 gettext-go 中內部用的 callerName 函數采用將 main.func·??? 統一處理為 main.func 的, 然后作為 gettext.Gettext 翻譯函數的上下文.
gettext-go 的 callerName 函數實現在這里: caller.go. 測試文件在這里: caller_test.go.
不同Go程序啟動流程
基于函數調用者信息可以很容易的驗證各種環境的程序啟動流程.
我們需要建立一個獨立的 caller 目錄, 里面有三個測試代碼.
caller/main.go 主程序:
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包):
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包):
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 可以得到以下的輸出結果:
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
分析輸出數據我們可以發現, 測試代碼和例子代碼的啟動流程和普通的程序流程都不太一樣.
測試代碼的啟動流程:
runtime.goexit還是入口- 但是
runtime.goexit不在調用runtime.main函數, 而是調用testing.tRunner函數 testing.tRunner函數由go test命令生成, 用于執行各個測試函數
例子代碼的啟動流程:
runtime.goexit還是入口- 然后
runtime.goexit調用runtime.main函數 - 最終
runtime.main調用go test命令生成的main.main函數, 在_test/_testmain.go文件 - 然后調用
testing.Main, 改函數執行各個例子函數
另外, 從這個例子我們可以發現, 我們自己寫的 main.main 函數所在的 main 包也可以被其他包導入. 但是其他包導入之后的 main 包里的 main 函數就不再是main.main 函數了. 因此, 程序的入口也就不是自己寫的 main.main 函數了.
總結
Go語言 runtime 包的 runtime.Caller / runtime.Callers / runtime.FuncForPC 等函數雖然看起來比較簡單, 但是功能卻非常強大.
這幾個函數不僅可以解決一些實際的工程問題(比如 gettext-go 中用于獲取翻譯的上下文信息), 而且非常適合用于調試和分析各種Go程序的運行時信息.