這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
By chaishushan{AT}gmail.com
注: 本文初稿發在 Golang 中國部落格, 這裡的內容有部分修改.
函數的調用資訊是程式中比較重要運行期資訊, 在很多場合都會用到(比如調試或日誌).
Go語言 runtime 包的 runtime.Caller / runtime.Callers / runtime.FuncForPC 等幾個函數提供了擷取函數調用者資訊的方法.
這幾個函數的文檔連結:
- http://docs.studygolang.com/pkg/runtime/#Caller
- http://docs.studygolang.com/pkg/runtime/#Callers
- http://docs.studygolang.com/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.Funcfunc (f *runtime.Func) FileLine(pc uintptr) (file string, line int)func (f *runtime.Func) Entry() uintptrfunc (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 類型的函數調用(匹配Regex"init·\d+$"), 直接作為 init 函數範返回
- 如果是
func 閉包類型(匹配Regex"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 mainimport ( "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 mainimport ( "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_testimport ( 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 = 10skip = 1, name = testing.tRunner, file = $(GOROOT)/src/pkg/testing/testing.go, line = 391skip = 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 = 10skip = 1, name = testing.runExample, file = $(GOROOT)/src/pkg/testing/example.go, line = 98skip = 2, name = testing.RunExamples, file = $(GOROOT)/src/pkg/testing/example.go, line = 36skip = 3, name = testing.Main, file = $(GOROOT)/src/pkg/testing/testing.go, line = 404skip = 4, name = main.main, file = $(TEMP)/go-build365033523/caller/_test/_testmain.go, line = 51skip = 5, name = runtime.main, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 220skip = 6, name = runtime.goexit, file = $(GOROOT)/src/pkg/runtime/proc.c, line = 1394want:?FAILexit status 1FAIL 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程式的運行時資訊.