共计 7558 个字符,预计需要花费 19 分钟才能阅读完成。
Go 程序出异样怎么办?pprof 工具剖析啊,可是如果是代码方面 bug 等呢?剖析代码 bug 有时须要联合执行过程,加日志呗,可是某些异样问题服务重启之后,可能会很难复现。这时候咱们能够断点调试,这样就能剖析每一行代码的执行,每一个变量的后果,C 语言通常应用 GDB 调试,Go 语言有专门的调试工具 dlv,本篇文章次要介绍 dlv 的根本应用。
dlv 概述
dlv 全称 delve,装置也比较简单,go install 就能装置:
// 下载 & 装置
$ git clone https://github.com/go-delve/delve
$ cd delve
$ go install github.com/go-delve/delve/cmd/dlv
//go 1.16 版本以上
# Install at a specific version or pseudo-version:
$ go install github.com/go-delve/delve/cmd/[email protected]
#On macOS make sure you also install the command line developer tools:
xcode-select --install
dlv 反对多种形式跟踪你的 Go 程序,help 命令查看:
dlv help
// 参数传递
Pass flags to the program you are debugging using `--`, for example:
`dlv exec ./hello -- server --config conf/config.toml`
Usage:
dlv [command]
Available Commands:
// 罕用来调试异样过程
attach Attach to running process and begin debugging.
// 启动并调试二进制程序
exec Execute a precompiled binary, and begin a debug session.
debug Compile and begin debugging main package in current directory, or the package specified.
......
dlv 与 GDB 还是比拟相似的,可打印变量的值,可设置断点,可单步执行,可查看调用栈,另外还能够查看以后 Go 过程的所有协程、线程等;罕用的性能(命令)如下:
Running the program:
// 运行到断点处,或者直到程序终止
continue (alias: c) --------- Run until breakpoint or program termination.
// 单步执行
next (alias: n) ------------- Step over to next source line.
// 重新启动过程
restart (alias: r) ---------- Restart process.
// 进入函数,一般的 n 函数调用是一行代码,会间接跳过
step (alias: s) ------------- Single step through program.
// 退出函数执行
stepout (alias: so) --------- Step out of the current function.
Manipulating breakpoints:
// 设置断点
break (alias: b) ------- Sets a breakpoint.
// 查看所有断点
breakpoints (alias: bp) Print out info for active breakpoints.
// 删除断点
clear ------------------ Deletes breakpoint.
// 删除所有断点
clearall --------------- Deletes multiple breakpoints.
Viewing program variables and memory:
// 输入函数参数
args ----------------- Print function arguments.
// 输入局部变量
locals --------------- Print local variables.
// 输入某一个变量
print (alias: p) ----- Evaluate an expression.
// 输入寄存器内存
regs ----------------- Print contents of CPU registers.
// 批改变量的值
set ------------------ Changes the value of a variable.
Listing and switching between threads and goroutines:
// 输入协程调用栈或者切换到指定协程
goroutine (alias: gr) -- Shows or changes current goroutine
// 输入所有协程
goroutines (alias: grs) List program goroutines.
// 切换到指定线程
thread (alias: tr) ----- Switch to the specified thread.
// 输入所有线程
threads ---------------- Print out info for every traced thread.
Viewing the call stack and selecting frames:
// 输入调用栈
stack (alias: bt) Print stack trace.
Other commands:
// 输入程序汇编指令
disassemble (alias: disass) Disassembler.
// 显示源代码
list (alias: ls | l) ------- Show source code.
dlv 的命令尽管比拟多,然而罕用的也就几个,个别只有会设置断点,单步执行,输入变量、调用栈等就能满足根本的调试需要。
dlv 实战
咱们写一个小程序,通过 dlv 调试,温习下之前介绍的管道读写,以及调度器流程。留神,Go 是多线程 / 多协程程序,理论执行过程可能比较复杂,而且笔者也省略了局部调试过程,所以即便你齐全跟着步骤调试,后果可能也不一样。程序如下:
package main
import (
"fmt"
"time"
)
func main() {queue := make(chan int, 1)
go func() {
for {
data := <- queue
fmt.Print(data, " ")
}
}()
for i := 0; i < 10; i ++ {queue <- i}
time.Sleep(time.Second * 1000)
}
编译 Go 程序并通过 dlv 启动执行:
// 编译标识留神 -N -l,禁止编译优化
go build -gcflags '-N -l' test.go
dlv exec test
Type 'help' for list of commands.
(dlv)
接下来就能够输出下面介绍的诸多调试命令,开启 dlv 调试之旅了。咱们之前曾经介绍过管道的实现原理以及 Go 调度器相干,管道的读写操作实现函数为 runtime.chanrecv/runtime.chansend,调度器主逻辑是 runtime.schedule;另外,读者须要晓得,咱们的主协程也就是 main 函数,编译后对应的函数是 main.main。在这几个函数都增加断点。
// 有些时候只依据函数名无奈辨别,设置断点可能须要携带包名,如 runtime.chansend
(dlv) b chansend
Breakpoint 1 set at 0x1003f0a for runtime.chansend() /go1.18/src/runtime/chan.go:159
(dlv) b chanrecv
Breakpoint 2 set at 0x1004c2f for runtime.chanrecv() /go1.18/src/runtime/chan.go:455
(dlv) b schedule
Breakpoint 3 set at 0x1037aea for runtime.schedule() /go1.18/src/runtime/proc.go:3111
(dlv) b main.main
Breakpoint 4 set at 0x1089a0a for main.main() ./test.go:8
continue(简写 c)命令执行到断点处:
(dlv) c
> runtime.schedule() /go1.18/src/runtime/proc.go:3111 (hits total:1) (PC: 0x1037aea)
=>3111: func schedule() {3112: _g_ := getg()
3113:
3114: if _g_.m.locks != 0 {3115: throw("schedule: holding locks")
3116: }
=> 指向以后执行的代码,第一次居然执行到了 runtime.schedule,没有到 main 函数?要晓得 main 函数最终也是作为主协程调度执行的,所以 main 函数必定不是第一个执行的,调度主协程之前必定须要线程,创立主协程,执行调度逻辑等等。那 Go 程序第一行代码应该是什么?咱们看一下调用栈:
(dlv) bt
0 0x0000000001037aea in runtime.schedule
at /go1.18/src/runtime/proc.go:3111
1 0x000000000103444d in runtime.mstart1
at /go1.18/src/runtime/proc.go:1425
2 0x000000000103434c in runtime.mstart0
at /go1.18/src/runtime/proc.go:1376
3 0x00000000010585e5 in runtime.mstart
at /go1.18/src/runtime/asm_amd64.s:368
4 0x0000000001058571 in runtime.rt0_go
at /go1.18/src/runtime/asm_amd64.s:331
Go 程序第一行代码在 runtime/asm_amd64.s,入口函数是 runtime.rt0_go,有趣味的能够看看,都是汇编代码。接下来,持续 c 执行到断点,你会发现还是程序还是会执行的暂停到 runtime.schedule,甚至是 runtime.chanrecv,这是因为在调度主协程之前,还须要做很多初始化工作(有用到这几个函数)。所以咱们通常是先设置断点 main.main,c 执行到这里,再设置其余断点,restart 从新执行程序,删除其余断点,从新在 main.main 设置断点,并 continue 执行到断点处:
(dlv) r
Process restarted with PID 57676
(dlv) clearall
(dlv) b main.main
Breakpoint 5 set at 0x1089a0a for main.main() ./test.go:8
(dlv) c
> main.main() ./test.go:8 (hits goroutine(1):1 total:1) (PC: 0x1089a0a)
=> 8: func main() {9: queue := make(chan int, 1)
10: go func() {
这下程序终于执行到 main.main 函数处了,接下来在管道读写函数设置断点,并 continue 执行到断点处:
(dlv) b chansend
Breakpoint 1 set at 0x1003f0a for runtime.chansend() /go1.18/src/runtime/chan.go:159
(dlv) b chanrecv
Breakpoint 2 set at 0x1004c2f for runtime.chanrecv() /go1.18/src/runtime/chan.go:455
(dlv) c
> runtime.chansend() /go1.18/src/runtime/chan.go:159 (hits goroutine(1):1 total:1) (PC: 0x1003f0a)
=> 159: func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
160: if c == nil {
161: if !block {
162: return false
163: }
程序执行到了 runtime.chansend 函数,对应的应该是 ”queue <- i” 这一行代码。bt 看看函数栈桢确认下是不是:
(dlv) bt
0 0x0000000001003f0a in runtime.chansend
at /go1.18/src/runtime/chan.go:159
1 0x0000000001003edd in runtime.chansend1
at /go1.18/src/runtime/chan.go:144
2 0x0000000001089aa9 in main.main
at ./test.go:18
// 查看参数
(dlv) args
c = (*runtime.hchan)(0xc00005a070)
ep = unsafe.Pointer(0xc000070f58)
block = true // 会阻塞协程
callerpc = 17341097
~r0 = (unreadable empty OP stack)
// 循环第一次写入管道的数值应该是 0,x 命令可查看内存
(dlv) x 0xc000070f58
0xc000070f58: 0x00
这里咱们通过 args 命令看一下输出参数,block 为 true 说明会阻塞以后协程(如果管道不可写),ep 是一个地址,存储待写入数据,x 命令能够查看内存,咱们看到就是数值 0。
还记得咱们之前介绍的管道 chan 的实现原理吗?底层保护着一个循环队列(有缓冲管道),写数据次要蕴含这几步逻辑:1)如果管道为 nil,阻塞以后协程(block=true);2)如果已敞开,抛出 panic 异样;3)如果有协程在期待读,间接将数据交给指标协程,并唤醒该协程;4)如果管道还有残余容量,写数据;4)管道容量曾经满了,阻塞以后协程(block=true)。
接下来能够单步执行,看看管道写操作的执行流程。这一过程比较简单,反复较多,就不再赘述了,咱们只列出来单步执行的一个两头过程:
(dlv) n
1 > runtime.chansend() /go1.18/src/runtime/chan.go:208 (PC: 0x10040e0)
Warning: debugging optimized function
203: if c.closed != 0 {204: unlock(&c.lock)
205: panic(plainError("send on closed channel"))
206: }
207:
=> 208: if sg := c.recvq.dequeue(); sg != nil {
209: // Found a waiting receiver. We pass the value we want to send
210: // directly to the receiver, bypassing the channel buffer (if any).
211: send(c, sg, ep, func() {unlock(&c.lock) }, 3)
212: return true
213: }
单步执行过程中,你可能会发现阻塞协程是通过 gopark 函数将协程换出,切换到调度器循环的。咱们在 runtime.schedule 以及 runtime.gopark 函数再设置断点,察看协程切换状况:
(dlv) b schedule
Breakpoint 8 set at 0x1037aea for runtime.schedule() /go1.18/src/runtime/proc.go:3111
(dlv) b gopark
Breakpoint 9 set at 0x1031aca for runtime.gopark() /go1.18/src/runtime/proc.go:344
(dlv) c
> runtime.gopark() /go1.18/src/runtime/proc.go:344 (hits goroutine(1):2 total:2) (PC: 0x1031aca)
=> 344: func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
345: if reason != waitReasonSleep {346: checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
347: }
348: mp := acquirem()
349: gp := mp.curg
runtime.gopark 函数次要是切换到调度栈,并执行 runtime.schedule 调度器(查找可执行协程并调度),所以再次 continue 会执行到 runtime.schedule 断点处:
(dlv) c
> [b] runtime.schedule() /go1.18/src/runtime/proc.go:3111 (hits total:19) (PC: 0x1037aea)
=>3111: func schedule() {3112: _g_ := getg()
(dlv) bt
0 0x0000000001037aea in runtime.schedule
at /Users/lile/Documents/go1.18/src/runtime/proc.go:3111
1 0x000000000103826d in runtime.park_m
at /Users/lile/Documents/go1.18/src/runtime/proc.go:3336
2 0x0000000001058663 in runtime.mcall
at /Users/lile/Documents/go1.18/src/runtime/asm_amd64.s:425
bt 查看调用栈,发现栈底函数是 runtime.mcall,调用栈这么短吗?怎么看不到 runtime.gopark 函数呢?因为这里切换了栈桢,从用户协程栈切换到调度栈,所以调用链路必定不一样了,是看不到之前用户栈的调用链路的。runtime.mcall 函数就是用来切换栈桢的。
总结
dlv 是 Go 程序调试十分好的工具,不仅能够帮忙咱们学习了解 Go 语言,也能够帮忙咱们疾速排查定位程序 bug 等,肯定要熟练掌握。