用 debugger 學習 golang
常見的工程語言可分為解釋型和編譯型兩種,比如寫 php 的,一般就不怎麼在乎 debugger 之類的東西。為什麼?如果真出了問題,我可以臨時把出問題的服務機器從線上服務中摘除出來,甚至申請一個較高的許可權去修改程式碼,然後到處去 die/echo。雖然有人說這麼做不太好,或者一般公司也不給開許可權。不過著急的時候,這個肯定是可行的。然而像 java/go 這種編譯型的就比較麻煩了。線上一般只有程式的執行環境而沒有編譯環境。就算是線上下,每次去加一行 fmt.Println 或者 System.out.println 都去編譯一遍程式碼也是會明顯降低幸福感的事情 (當然這裡有人說現在 java 支援 hotswap 之類的功能,不過你總還是會遇到需要重新編譯的場景。go 也是一樣的,專案大了,編譯時間還是可能會有個五六七八秒的。想要迅速地還原 bug 的現場,那還是能用 debugger 為上。
除了拿 debugger 來 debug。還可以用 debugger 來了解了解程式執行的機制,或者用 disass 來檢視程式執行的彙編碼。這一點也很重要。應用層的語言很多時候因為 runtime 事無鉅細的封裝,已經不是所見即所得的東西了,特別是像 go 這樣,你寫一個 var a = 1 卻連最終這個變數會被分配到堆上還是棧上都不知道。而像應用層的空 interface 和非空的 interface 實際的資料結構完全不一樣,這些如果你想知道的話一方面可以通過閱讀原始碼,但 go 的原始碼到你的程式碼之間始終還是有一個轉換過程。如果你可以通過彙編直接檢視執行時的結構顯然要更為直觀。
這篇文章也不準備寫得大而全,就簡單地舉一些可以靠 debugger 來幫我們更清楚地認識問題的場景吧。
var a = new(T) 和 var a = &T{} 這兩種語法有區別麼?
寫兩個差不多的程式,然後帶上 gcflags="-N -l" 來 go build
-> 5 func main() {
di`main.main:
-> 0x104f400 <+0>: sub rsp, 0x28
0x104f404 <+4>: mov qword ptr [rsp + 0x20], rbp
0x104f409 <+9>: lea rbp, [rsp + 0x20]
** 6 var a = &T{}
0x104f40e <+14>: mov qword ptr [rsp], 0x0
0x104f416 <+22>: lea rax, [rsp]
0x104f41a <+26>: mov qword ptr [rsp + 0x18], rax
0x104f41f <+31>: test al, byte ptr [rax]
0x104f421 <+33>: mov qword ptr [rsp], 0x0
0x104f429 <+41>: mov rax, qword ptr [rsp + 0x18]
0x104f42e <+46>: mov qword ptr [rsp + 0x10], rax
** 7 a.age += 1
0x104f433 <+51>: test al, byte ptr [rax]
0x104f435 <+53>: mov rax, qword ptr [rax]
0x104f438 <+56>: mov qword ptr [rsp + 0x8], rax
0x104f43d <+61>: mov rcx, qword ptr [rsp + 0x10]
0x104f442 <+66>: test al, byte ptr [rcx]
0x104f444 <+68>: inc rax
0x104f447 <+71>: mov qword ptr [rcx], rax
-> 5 func main() {
di2`main.main:
-> 0x104f400 <+0>: sub rsp, 0x20
0x104f404 <+4>: mov qword ptr [rsp + 0x18], rbp
0x104f409 <+9>: lea rbp, [rsp + 0x18]
** 6 var a = new(T)
0x104f40e <+14>: mov qword ptr [rsp], 0x0
0x104f416 <+22>: lea rax, [rsp]
0x104f41a <+26>: mov qword ptr [rsp + 0x10], rax
** 7 a.age += 1
0x104f41f <+31>: test al, byte ptr [rax]
0x104f421 <+33>: mov rax, qword ptr [rsp]
0x104f425 <+37>: mov qword ptr [rsp + 0x8], rax
0x104f42a <+42>: mov rcx, qword ptr [rsp + 0x10]
0x104f42f <+47>: test al, byte ptr [rcx]
0x104f431 <+49>: inc rax
0x104f434 <+52>: mov qword ptr [rcx], rax
兩種程式碼反編譯出來的彙編不一致,可以看到第一種比第二種多要了 8 個位元組的棧空間。可以猜測實際上第一種寫法是分兩部走:
- T{};2.& 取地址
go build 不帶 gcflags 引數時,兩者出來的彙編程式碼就是完全一致的了。感興趣的同學可以自行驗證。
檢視 go 的 interface 的資料結構
go 的 interface 一直是一個比較讓人糾結的資料結構,官方和信徒們從 14 年就一直在花不少篇幅跟你講,怎麼判斷 interface 和 nil,我們這個設計是這樣的 blabla。不過我始終覺得 go 的 interface 設計是有點問題的,只不過這幫 unix 老古董們不想承認。。。
先來看一些例子吧:
package main
import (
"bytes"
"fmt"
"io"
)
var (
a *bytes.Buffer = nil
b io.Writer
)
func set(v *bytes.Buffer) {
if v == nil {
fmt.Println("v is nil")
}
b = v
}
func get() {
if b == nil {
fmt.Println("b is nil")
} else {
fmt.Println("b is not nil")
}
}
func main() {
set(nil)
get()
}
例子二 (來自公司同事):
package main
import (
"fmt"
"io"
"os"
"unsafe"
)
var (
v interface{}
r io.Reader
f *os.File
fn os.File
)
func main() {
fmt.Println(v == nil)
fmt.Println(r == nil)
fmt.Println(f == nil)
v = r
fmt.Println(v == nil)
v = fn
fmt.Println(v == nil)
v = f
fmt.Println(v == nil)
r = f
fmt.Println(r == nil)
}
可以自己執行一下看看結果。有很多文章會講,interface 包含有 type 和 data 兩個元素,只有兩者均為 nil 的時候才是真的 nil,然後再給你灌輸了很多理由為什麼要這麼設計。甚至還援引了 Rob Pike 的某個 ppt。
對設計的吐槽先打住,我們看看 interface 在執行期到底是一個什麼樣的東西:
(lldb) p v
(interface {}) main.v = {
_type = 0x0000000000000000
data = 0x0000000000000000
}
(lldb) p r
(io.Reader) main.r = {
tab = 0x0000000000000000
data = 0x0000000000000000
}
(lldb) p f
(*os.File) main.f = 0x0000000000000000
這裡可以看到,在 golang 中空 interface 和非空 interface 在資料結構上也是有差別的。空 interface 就只有 runtime._type 和 void* 指標組成。而非空 interface 則是 runtime.itab 和 void* 指標組成。
把 *os.File 分別賦值給空 interface 和 io.Reader 型別的介面變數之後。我們看看這個 runtime._type 和 runtime.itab 都變成什麼樣了:
(lldb) p v
(interface {}) main.v = {
_type = 0x00000000010be0a0
data = 0x0000000000000000
}
(lldb) p *r.tab
(runtime.itab) *tab = {
inter = 0x00000000010ad520
_type = 0x00000000010be0a0
link = 0x0000000000000000
hash = 871609668
bad = false
inhash = true
unused = ([0] = 0, [1] = 0)
fun = ([0] = 0x000000000106d610)
}
非空 interface 的 _type 是儲存在 tab 欄位裡了。除此之外,非空 interface 本身的型別 (這裡是 io.Reader) 儲存在 inter 欄位中:
(runtime.interfacetype) *inter = {
typ = {
size = 0x0000000000000010
ptrdata = 0x0000000000000010
hash = 3769182245
tflag = 7
align = 8
fieldalign = 8
kind = 20
alg = 0x000000000113cd80
gcdata = 0x00000000010d55f6
str = 12137
ptrToThis = 45152
}
pkgpath = {
bytes = 0x0000000001094538
}
mhdr = (len 1, cap 1) {
[0] = (name = 1236, ityp = 90528)
}
}
此外,非空 interface 還會在 itab 的 fun 陣列裡儲存函式列表。
這裡會有一個非常蛋疼的地方,如果你把一個非空 interface 型別的 nil 值的 interface 變數賦值給一個空 interface 型別的變數,那麼就會得到一個非空型別的非空 interface 變數。
這絕對是 go 的設計缺陷。。。
現在為了避免判斷時候的失誤,也有人會用 reflect.ValueOf(v) 來判斷一個 interface 是否為 nil。但也會比較彆扭。
學習 go 的 channel
來一個簡單的 demo:
package main
func main() {
var a = make(chan int, 4)
a <- 1
a <- 1
a <- 1
a <- 1
close(a)
println()
}
打上斷點,檢視 a 的結構:
* thread #1, stop reason = step over
frame #0: 0x000000000104c354 normal_example`main.main at normal_example.go:5
2
3 func main() {
4 var a = make(chan int, 4)
-> 5 a <- 1
6 a <- 1
7 a <- 1
8 a <- 1
Target 0: (normal_example) stopped.
(lldb) p a
(chan int) a = 0x000000c42007a000
(lldb) p *a
(hchan<int>) *a = {
qcount = 0
dataqsiz = 4
buf = 0x000000c42007a060
elemsize = 8
closed = 0
elemtype = 0x0000000001055ee0
sendx = 0
recvx = 0
recvq = {
first = 0x0000000000000000
last = 0x0000000000000000
}
sendq = {
first = 0x0000000000000000
last = 0x0000000000000000
}
lock = (key = 0x0000000000000000)
}
a.buf 是 void* 型別,類似 c/c 艹,這種型別需要用 x 指令來讀取內容:
(lldb) n
Process 21186 stopped
* thread #1, stop reason = step over
frame #0: 0x000000000104c369 normal_example`main.main at normal_example.go:6
3 func main() {
4 var a = make(chan int, 4)
5 a <- 1
-> 6 a <- 1
7 a <- 1
8 a <- 1
9 close(a)
Target 0: (normal_example) stopped.
(lldb) p a.buf
(void *) buf = 0x000000c42007a060
(lldb) x a.buf
0xc42007a060: 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0xc42007a070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
可以看到向 channel 中寫入一個 1 之後,a.buf 中的內容發生了變化。同時,a 中的 sendx 和 qcount 也都發生了變化:
(lldb) p *a
(hchan<int>) *a = {
qcount = 1 // 這裡這裡
dataqsiz = 4
buf = 0x000000c42007a060
elemsize = 8
closed = 0
elemtype = 0x0000000001055ee0
sendx = 1 // 這裡這裡
recvx = 0
recvq = {
first = 0x0000000000000000
last = 0x0000000000000000
}
sendq = {
first = 0x0000000000000000
last = 0x0000000000000000
}
lock = (key = 0x0000000000000000)
}
這樣就可以非常方便地結合程式碼,觀察 channel 的傳送和接收行為。其實從 debugger 裡得到的資訊都非常的直觀,比看圖表要直觀得多。比如這裡我們可以直接看到 lock 欄位。這也說明 channel 本身為了併發安全是帶鎖的。
recvq 和 sendq 是用來維護髮送接收時被阻塞需要休眠的 goroutine 列表。
elemtype 是 runtime._type 型別,可以看到 channel 中的元素型別資訊。
close(a) 以後再看看結構:
(chan int) a = 0x000000c42007a000
(lldb) p *a
(hchan<int>) *a = {
qcount = 4
dataqsiz = 4
buf = 0x000000c42007a060
elemsize = 8
closed = 1 // 重點在這裡
elemtype = 0x0000000001055ee0
sendx = 0
recvx = 0
recvq = {
first = 0x0000000000000000
last = 0x0000000000000000
}
sendq = {
first = 0x0000000000000000
last = 0x0000000000000000
}
lock = (key = 0x0000000000000000)
}
比畫一堆圖不知道高到哪裡去了。
再嘗試在 a 上阻塞幾個 goroutine:
(lldb) p a.recvq
(waitq<int>) recvq = {
first = 0x000000c42007c000
last = 0x000000c42007c060
}
(lldb) p a.recvq.first
(*sudog<int>) first = 0x000000c42007c000
(lldb) p *a.recvq.first
(sudog<int>) *first = {
g = 0x000000c420000f00
isSelect = false
next = 0x000000c42007c060
prev = 0x0000000000000000
elem = 0x0000000000000000
acquiretime = 0
releasetime = 0
ticket = 0
parent = 0x0000000000000000
waitlink = 0x0000000000000000
waittail = 0x0000000000000000
c = 0x000000c42007a000
}
可以看到,channel 的 recvq 和 sendq 就是個 sudog 的雙向連結串列,沒有什麼難理解的~
確認 panic 的現場
程式裡有時候會有這種程式碼:
someFunction(r.A, *r.B, *r.C, *r.D, r.E, *r.F)
然後在這裡 panic 了。但是 go 只會告訴你 nil pointer deference,卻不會告訴你是哪個 nil pointer deference。著實蛋疼。
這個就是用 debugger 最基本斷點功能了。如果是用 delve,斷點可以用很多種方法來設定,比如 function+ 行號,檔名 + 行號,如果有歧義,delve 也會告訴你具體要怎麼來消除歧義。
(lldb) n
Process 22595 stopped
* thread #1, stop reason = step over
frame #0: 0x000000000104c344 nilPointer`main.main at nilPointer.go:16
13 }
14
15 func main() {
-> 16 var t = T{A: 1}
17 test(t.A, *t.B, *t.C, *t.D, t.E, *t.F)
18 }
Target 0: (nilPointer) stopped.
(lldb) n
Process 22595 stopped
* thread #1, stop reason = step over
frame #0: 0x000000000104c365 nilPointer`main.main at nilPointer.go:17
14
15 func main() {
16 var t = T{A: 1}
-> 17 test(t.A, *t.B, *t.C, *t.D, t.E, *t.F)
18 }
Target 0: (nilPointer) stopped.
(lldb) p t
(main.T) t = {
A = 1
B = 0x0000000000000000
C = 0x0000000000000000
D = 0x0000000000000000
E = 0
F = 0x0000000000000000
}
哪裡是 nil 一目瞭然~
string 和 byte 之間到底有沒有進行相互轉換
例子:
package main
func main() {
var str = "abcde"
var b = []byte("defg")
println(str)
println(string(b))
}
還是看反編譯的結果:
** 6 var b = []byte("defg")
7
0x104cf17 <+71>: lea rax, [rsp + 0x30]
0x104cf1c <+76>: mov qword ptr [rsp], rax
0x104cf20 <+80>: lea rax, [rip + 0x1c95b] ; go.string.* + 210
0x104cf27 <+87>: mov qword ptr [rsp + 0x8], rax
0x104cf2c <+92>: mov qword ptr [rsp + 0x10], 0x4
0x104cf35 <+101>: call 0x1038390 ; runtime.stringtoslicebyte at string.go:146
0x104cf3a <+106>: mov rax, qword ptr [rsp + 0x20]
0x104cf3f <+111>: mov rcx, qword ptr [rsp + 0x18]
0x104cf44 <+116>: mov rdx, qword ptr [rsp + 0x28]
0x104cf49 <+121>: mov qword ptr [rsp + 0xa0], rcx
0x104cf51 <+129>: mov qword ptr [rsp + 0xa8], rax
0x104cf59 <+137>: mov qword ptr [rsp + 0xb0], rdx
重點在這裡的
0x104cf35 <+101>: call 0x1038390 ; runtime.stringtoslicebyte at string.go:146
runtime 裡還有一個對應的:
0x104c624 <+196>: call 0x10378c0 ; runtime.slicebytetostring at string.go:72
有了這樣的手段,如果別人和你說 go 會優化 string 和 [] byte 之間的轉換。你就可以隨時掏出 debugger 來打他的臉了。
我程式的 select 到底被翻譯成什麼樣的執行過程了
select 是 golang 提供的一種特權語法,實現的功能比較神奇。先不說行為怎麼樣。這種特權語法實際上最終一定會被翻譯成某種彙編指令或者 runtime 的內建函式。
用反彙編來看一眼。
-> 6 select {
-> 0x104e3d5 <+117>: mov qword ptr [rsp + 0x38], 0x0
0x104e3de <+126>: lea rdi, [rsp + 0x40]
0x104e3e3 <+131>: xorps xmm0, xmm0
0x104e3e6 <+134>: lea rdi, [rdi - 0x10]
0x104e3ea <+138>: mov qword ptr [rsp - 0x10], rbp
0x104e3ef <+143>: lea rbp, [rsp - 0x10]
0x104e3f4 <+148>: call 0x1048d5a ; runtime.duffzero + 250 at duff_amd64.s:87
0x104e3f9 <+153>: mov rbp, qword ptr [rbp]
0x104e3fd <+157>: lea rax, [rsp + 0x38]
0x104e402 <+162>: mov qword ptr [rsp], rax
0x104e406 <+166>: mov qword ptr [rsp + 0x8], 0xb8
0x104e40f <+175>: mov dword ptr [rsp + 0x10], 0x3
0x104e417 <+183>: call 0x10305d0 ; runtime.newselect at select.go:60
** 6 select {
0x104e425 <+197>: mov rax, qword ptr [rsp + 0x30]
** 6 select {
0x104e445 <+229>: mov rax, qword ptr [rsp + 0x28]
** 6 select {
0x104e46a <+266>: lea rax, [rsp + 0x38]
0x104e46f <+271>: mov qword ptr [rsp], rax
0x104e473 <+275>: call 0x1030b10 ; runtime.selectgo at select.go:202
0x104e478 <+280>: mov rax, qword ptr [rsp + 0x8]
0x104e47d <+285>: mov qword ptr [rsp + 0x20], rax
看起來 select 被翻譯成了多段彙編程式碼。說明這個函式稍微複雜一些,不過反彙編過程已經幫我們定位到了 select 被翻譯成的函式的位置。
實際上 select 的執行過程為: newselect->selectsend/selectrecv->selectgo 這幾個過程。如果你的程式是下面這樣的:
for {
select {
case <-ch:
case ch2<-1:
default:
}
}
在每次進入 for 迴圈的時候,runtime 裡的 hselect 結構都會重新建立。也就是說寫一個有 default case 的無限迴圈,不僅僅是你知道的 cpu 佔用爆炸,實際上還在不斷地在堆上分配、釋放、分配、釋放空間。感覺這裡官方應該是可以做一些優化的,不知道為什麼邏輯這麼原始。(當然,在 go 語言學習筆記裡看到雨痕老師也吐槽他們的程式碼寫得渣哈哈哈。
正在執行的 goroutine 到底是阻塞在什麼地方了
golang 中常見的記憶體洩露套路是這樣的:
func main() {
var ch chan int
go func() {
select {
case <-ch:
}
}()
}
監聽了一個永遠阻塞的 channel,或者向一個沒有接收方的 channel 發資料,如果這些事情沒有發生在主 goroutine 裡的話,在 runtime 的 checkdead 函式中不會認為這是個 deadlock。而這樣的 goroutine 建立過程往往在 for 迴圈裡。
公司內的某個程式就曾經線上下 debug 的時候發現每次來一個請求,就會導致 goroutine 總數 +1。這顯然是不正常的。在 goroutine 達到一定數量之後,可以適用 delve attach 到你的程式,然後執行:
goroutines
一下就看到你洩露的 goroutine 都是卡在什麼地方了。
當然,如果你的程式開了 pprof,那通過網頁來看倒是更為方便。
之前公司內的某個庫在找不到 disf 的 ip 的時候就會阻塞在 lib 的 channel 上。用這個辦法可以非常快的找到問題根結。不用像某些程式設計師一樣到處加 fmt.Println 了。
程式的 cpu 佔用非常高,似乎在哪裡有死迴圈
這個問題有兩個工具可以用,一個是 perf,一個是 debugger。
sudo perf top
可以找到死迴圈所處的位置,這個在之前寫的文章中有過涉及了。這裡就不再贅述。
還有一種死迴圈,但是程式本身沒死掉的,那就可以直接用 dlv attach 進去了,基本上切換至可疑的 goroutine,跟個十幾步就可以找到問題所在,當然,結合 perf 來看更高效。這個可以參考之前定位 jsoniter 時候的步驟:https://github.com/gin-gonic/gin/issues/1086。
怎麼一直觀察某一個變數的變化過程
也很簡單,在希望觀察的地方打上斷點,如果斷點 id 是 13,那麼用 delve 的 on 命令:
on 13 print xxx
即可
(dlv) n
> main.main() ./for.go:6 (hits goroutine(1):11 total:11) (PC: 0x44d694)
count: 45
1: package main
2:
3: func main() {
4: count:=0
5: for i:=0;i<10000;i++ {
=> 6: count+=i
7: }
8: println(count)
9: }
(dlv) n
我的程式只有執行到 for 迴圈的第 1000 次疊代的時候才會出 bug,我怎麼在第 1000 次迴圈的時候才設定這個斷點
用 delve 很簡單:
ubuntu@ubuntu-xenial:~$ dlv exec ./for
Type 'help' for list of commands.
(dlv) b for.go:6
Breakpoint 1 set at 0x44d694 for main.main() ./for.go:6
(dlv) cond 1 i==1000 ////// => 重點在這裡
(dlv) r
Process restarted with PID 29024
(dlv) c
> main.main() ./for.go:6 (hits goroutine(1):1 total:1) (PC: 0x44d694)
1: package main
2:
3: func main() {
4: count:=0
5: for i:=0;i<10000;i++ {
=> 6: count+=i
7: }
8: println(count)
9: }
(dlv) p i
1000
(dlv) p count
499500
滴滴平臺技術部招聘 golang 工程師,感興趣的同學簡歷請發至 caochunhui@didichuxing.com
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- Golang 學習——interface 介面學習(一)Golang
- Golang 學習——interface 介面學習(二)Golang
- 我的 golang 學習筆記系列二:golang的函式運用Golang筆記函式
- golang 學習筆記Golang筆記
- 學習golang的迷茫Golang
- Golang學習--開篇Golang
- 【學習筆記】Golang 切片筆記Golang
- GOLang 學習筆記(一)Golang筆記
- golang學習之路 之mapGolang
- goLang學習筆記(三)Golang筆記
- goLang學習筆記(四)Golang筆記
- goLang學習筆記(一)Golang筆記
- goLang學習筆記(二)Golang筆記
- golang學習第二課Golang
- golang 學習傳送門Golang
- 深入學習golang(2)—channelGolang
- 深入學習golang(5)—介面Golang
- golang 學習筆記1Golang筆記
- Golang 學習——如何判斷 Golang 介面是否實現?Golang
- 分享基本golang學習的書Golang
- golang學習第三天Golang
- golang學習筆記(二)—— 深入golang中的協程Golang筆記
- Golang 學習——常量 const 和 iotaGolang
- Golang 學習——結構體 struct (一)Golang結構體Struct
- Golang 學習——結構體 struct (二)Golang結構體Struct
- Golang學習筆記之方法(method)Golang筆記
- Golang學習筆記-1.6 函式Golang筆記函式
- golang入門學習筆記(一)Golang筆記
- 深入學習golang(3)—型別方法Golang型別
- 深入學習golang(4)—new與makeGolang
- Golang學習筆記(1):包管理Golang筆記
- 學習 golang 中,寫了個 golang http client 練練手GolangHTTPclient
- Golang標準庫學習—container/heapGolangAI
- 4. 黑科技 Interface |《 刻意學習 Golang 》Golang
- Golang學習筆記(一):命名規範Golang筆記
- golang學習筆記(1):安裝&helloworldGolang筆記
- 《Golang學習筆記》error最佳實踐Golang筆記Error
- golang 學習之路之 struct 結構體GolangStruct結構體