delve

日常常用的是用 goland 打断点以 debug 模式运行调试,goland 实际上也是调用 delve 来做的调试功能。

delve 可以 attach 到一个正在运行中的 golang 程序,可以用来调试线上执行中的程序,非常强大。

Goland基本调试功能

调试程序的执行

先对你感兴趣的代码行的左边侧边栏鼠标左击打断点,然后在main函数左边侧边栏点击启动程序的按钮,选择绿色小虫子按钮,就是以debug模式启动了程序,当运行到断点代码行时,程序就会阻塞在此处,你就可以对程序做调试了。

如果你的程序需要设置启动参数才能启动,Goland右上角有Add Configuration按钮,可以对运行的Golang程序做配置,Program arguments一栏就是设置的启动参数。

  • Show Execution Point:把输入光标跳转到打断点的那行代码上;

  • Step Over:逐行执行,如果有函数调用,不会进入,只在当前代码处逐行执行;

  • Step Into:逐行执行,如果有函数调用,会进入到函数里;

  • Step Out:跳出进入的函数;

  • Run To Cursor:跳转到下个断点。

调试指定的goroutine

点击向下的箭头按钮,可以选择切换到你感兴趣的goroutine来进行调试。

给断点设置触发条件

在红色断点上右击,Condition:即为用户为该断点设置的触发条件,满足指定的表达式时才触发断点。 点击More有更丰富的功能,可以选择触发该断点时打印日志,还支持Evaluate and log,计算指定表达式输出日志; disable until hitting the following breakpoint,指定的断点触发时就禁用该断点。

监听自定义表达式

在右下角的调试界面会显示程序执行运行时变量的值,上方有一个输入框,可以输入一个表达式,按回车键查看表达式的值,或者按Shift+Command+回车键监听表达式的值,这对观察复杂的表达式或查看 slice/map/struct 中的特定值是非常有用的。参考

一个经典bug的调试

package main

import "fmt"

func main() {
	go func() {
		var i int
		for {
			i++
			fmt.Println(i)
		}
	}()
	for {
	}
}

这段代码在 Go1.13 以下会出现一个经典 bug:递增数字输出到一半阻塞在那不动了。

$ $GOPATH/go1.13.15/bin/go version
go version go1.13.15 darwin/amd64
$ $GOPATH/go1.13.15/bin/go build -o main
$ ./main

可以看到,虽然程序卡在那不输出了,但是仍然是吃满了一核CPU,PID=4370。

dlv attach 4370进入 delve 交互式命令行,执行grs查看当前所有 goroutine,第13行是空的for循环,剩下的 goroutine 中只有第 18 号是我创建的,当前停在了第 10 行是fmt.Println(i)

1 号协程前面有一个小星星,代表当前 delve 调试工具绑定到了 1 号协程,执行gr 18切换到 18 号协程,执行bt查看当前协程的函数调用栈回溯:

可以看到程序是阻塞在了runtime.gcStart,查看runtime/mgc.go的1287行:

systemstack(stopTheWorldWithSema)

可以看到程序是在执行 GC 的 STW 时发生了阻塞。

分析原因:GC 在开始前需要 STW 抢占所有的 P 来开启写屏障,执行输出的 goroutine18 已经被 GC 抢占所以程序不输出了,而 goroutine1 的 for 循环没能被抢占,一直在执行,所以仍然是吃满了一核CPU,而 STW 一直在等待它让出 P,这样就陷入了僵局。

在 Go1.13 中不是真正的抢占,编译器会给每个函数注入一个栈增长检测函数runtime.morestack,会在这个函数里检查抢占标识,让出P,但是这个设计在遇到没有函数调用的时候会有问题,比如本例的空转 for 循环,没有机会执行栈增长检测函数,也就不会让出P了。所以这是 Go1.13 中的一个bug,在 Go1.14 以后实现了基于signal信号的真正的抢占式调度,就没有这个 bug 了。

结合Goland做远程调试

  1. 在服务器上安装Godelvevim .bash_profile,在文件最后写入如下内容,然后执行source .bash_profile使之生效;

export GOPROXY=https://mirrors.aliyun.com/goproxy/,direct
export GOPATH="/workspace/go"
export PATH=$PATH:$GOPATH/bin
  1. 在本地交叉编译好用于在服务器上运行的Go可执行文件,然后把文件传输到服务器上,在服务器上运行Go程序,并查到该进程的PID

  2. 在服务器上执行dlv attach $PID --headless --api-version=2 --log --listen=:2345

  3. 打开GolandRun/Debug Configurations标签,点击+,选择Go Remote选项卡,HostPort填上一步启动的delve服务端,接下来就可以跟本地调试一样的调试远程程序了。

注:由于本地和服务器上的操作系统不同,不同的操作系统之间可能存在一些细微的差异,这有可能导致无法按照预期进行调试,所以需要在本地做交叉编译生成可执行文件。

利用shell脚本一键替换新的可执行文件

编写用于在远程服务器上执行的脚本文件,命名为debug.sh

# 查到老进程,go程序和dlv,kill
ps -ef | grep debug-server | grep -v grep | awk '{print $2}' | xargs kill -9
ps -ef | grep dlv | grep -v grep | awk '{print $2}' | xargs kill -9

# 用delve启动新进程
cd /your/path/
nohup dlv --listen=:2345 --headless=true --api-version=2 --accept-multiclient exec ./debug-server -- --custom_param xxx >dlv.out 2>&1 &
exit

若只需attach进已启动的Go程序,则debug.sh脚本如下:

cd /your/path/
PID=$(ps -ef | grep {process-name} | grep -v grep | awk '{print $2}')
nohup dlv attach $PID --headless --api-version=2 --log --listen=:2345 >dlv.out 2>&1 &

注:

  1. grep -v grep是列出除开grep命令本身的进程;

  2. $2表示第2列,即进程号PID;

  3. xargs使用上一个操作的结果作为下一个命令的参数使用;

  4. 将一条命令的执行结果重定向到其他设备,>dlv.out>>dlv.out的区别:>>是追加内容,>是覆盖原有内容

先配好免密登录

在本地编译好,传输到远程服务器上,启动新的Go程序,这些操作只需要每次在本地执行如下脚本即可:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -gcflags="all=-N -l" -o debug-server main.go
# 传输新的可执行文件到服务器
scp debug-server root@{ip}:/your/path/
# 在服务器上执行debug.sh
ssh root@{ip} < debug.sh

注:如果没有-gcflags="all=-N -l",编译优化会使实际运行的代码与源代码不同,进而导致打的断点无效。

随后即可在本地Goland配置Go Remote远程调试新启动的程序了。

Last updated

Was this helpful?