Go語言的函數調用信息

jopen 10年前發布 | 24K 次閱讀 Go語言 Google Go/Golang開發

注: 本文初稿發在 Golang 中國博客, 這里的內容有部分修改.

函數的調用信息是程序中比較重要運行期信息, 在很多場合都會用到(比如調試或日志).

Go語言 runtime 包的 runtime.Caller / runtime.Callers / 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.Callerruntime.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 = 1skip = 2 也分別對應2個函數調用. 通過查閱 runtime/proc.c 文件的代碼, 我們可以知道對應的函數分別為 runtime.mainruntime.goexit.

整理之后可以知道, Go的普通程序的啟動順序如下:

  1. runtime.goexit 為真正的函數入口(并不是main.main)
  2. 然后 runtime.goexit 調用 runtime.main 函數
  3. 最終 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 = 0runtime.Callers 自身的信息.

這個例子比前一個例子多輸出了一個棧幀, 就是因為多了一個runtime.Callers棧幀的信息(前一個例子是沒有runtime.Caller信息的(注意:沒有s后綴)).

那么 runtime.Callersruntime.Caller 有哪些關聯和差異?

runtime.Callersruntime.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]
}

比如輸出結果可以發現, 42809624290608 兩個 pc 值是相同的. 它們分別對應 runtime.mainruntime.goexit 函數.

runtime.Caller 輸出的 4198456runtime.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·1main.init·2 等.
  • 閉包函數采用 main.func·001 格式命名, 安裝閉包定義結束的位置順序進編號.

比如以下全局變量的初始化調用者為 main.init 函數:

var a = PrintCallerName(0, "main.a")
var b = PrintCallerName(0, "main.b")

以下兩個 init 函數根據出現順序分別對應 main.init·1main.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
}

處理的思路:

  1. 如果是 init 類型的函數調用(匹配正則表達式"init·\d+$"), 直接作為 init 函數范返回
  2. 如果是 func 閉包類型(匹配正則表達式"func·\d+$"), 跳過當前棧幀, 繼續遞歸處理
  3. 返回普通的函數調用類型

CallerName 函數的不足之處

有以下的代碼:

func init() {
    var _ = myInit("1")
}
func main() {
    var _ = myInit("2")
}

var myInit = func(name string) {
    b = PrintCallerName(0, name + ":main.myInit.b")
}

myInit 為一個全局變量, 被賦值為一個閉包函數. 然后在 initmain 函數分別調用 myInit 這個閉包函數輸出的結果
會因為調用環境的不同而有差異.

從直觀上看, myInit閉包函數在執行時, 最好輸出 main.myInit 函數名. 但是 main.myInit 只是一個綁定到閉包函數的變量, 而閉包的真正名字是 main.func·???. 在運行時是無法得到 main.myInit 這個名字的.

因此在 gettext-go 中內部用的 callerName 函數采用將 main.func·??? 統一處理為 main.func 的, 然后作為 gettext.Gettext 翻譯函數的上下文.

gettext-gocallerName 函數實現在這里: 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

分析輸出數據我們可以發現, 測試代碼和例子代碼的啟動流程和普通的程序流程都不太一樣.

測試代碼的啟動流程:

  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 等函數雖然看起來比較簡單, 但是功能卻非常強大.

這幾個函數不僅可以解決一些實際的工程問題(比如 gettext-go 中用于獲取翻譯的上下文信息), 而且非常適合用于調試和分析各種Go程序的運行時信息.

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!