歡迎光臨
每天分享高質量文章

GO語言性能問題的發現和解決

1

事件起因

事情起因於公司一位同事在內部郵件組中post了一個問題,一個使用了go1.8.3寫的業務程式跑了一段時間後出現部分goroutine卡在等待一個鎖ForkLock的現象,同事認為這是go1.8.3的bug,升級到 go1.10 後沒有再重現。為了搞清楚這個事情,同事在 github 上發了 issue :

https://github.com/golang/go/issues/26836,期間也做了很多重現的嘗試,但並未重現。

我瀏覽了一下出現該問題的業務代碼,大概的使用方式是父行程呼叫os/exec下的Command開子行程執行shell命令。Command後面會呼叫golang封裝的forkExec來開子行程並執行命令,forkExec使用了ForkLock。

2

問題分析

ForkLock 的存在是為了避免下麵的情況:在有多個goroutine同時fork exec的情況下,  為了子行程只繼承它需要的檔案描述符,需要在父行程在創建這些檔案描述符的時候加上O_CLOEXEC標誌,這樣在子行程中這些描述符是關閉的,子行程按需把自己需要繼承的描述符打開即可。

 

Linux在2.6.27之後,打開檔案或者管道,和設置O_CLOEXEC是一個原子操作,因此問題不大,但golang對內核版本的要求是2.6.23及以上,另外Unix系統中,open和設置O_CLOEXEC是兩個操作,如果在兩個操作之間發生fork, 子行程就可能繼承它不需要的檔案描述符,因此需要加鎖。重點看下forkExec時候的原始碼:

                            

從問題的現象看,肯定是某goroutine在forkExecPipe或者forkAndExecInChild這兩步卡住了,鎖沒釋放,因此有些goroutine一直拿不到鎖,饑餓致死。forkExecPipe最後呼叫的是內核pipe2,forkAndExecInChild最後呼叫的是內核clone和exec。

 

3

原因猜測

pipe2是一個快速系統呼叫,因此可能block的系統呼叫是clone和exec, 加上在go1.10上這個問題沒有重現,對比go1.8代碼和go1.9在forkAndExecInChild函式上的差異:

  • go1.8

  • go1.9

 

go1.9增加了CLONE_VFORK和CLONE_VM 。只帶SIGCHILD的clone可以認為類似於fork(最後都是呼叫do_fork),  fork的問題是,在父行程占用記憶體越大能越差,具體可以看這個鏈接:

https://bugzilla.redhat.com/show_bug.cgi?id=682922

 

這個case 2011年提出,今年7月還在更新,這個case反饋的問題是,儘管Linux kernel 引入copy-on-write機制,但fork的時候依然要拷貝頁表,行程虛擬記憶體越大,需要拷貝的頁表項越多,因此fork越慢。Golang的討論組有人測試過,heap size在2G的情況下,fork耗時可以到毫秒級別, 正常是及幾十微秒,上千倍差距。

 

Go1.9加上這兩個引數是為了讓子行程和父行程共享記憶體,相當於呼叫vfork, 不需要拷貝頁表, 加快創建速度,從測試效果看,穩定在幾十微妙。

所以一個合理的猜測是,在低於go1.9版寫的程式中,當程式記憶體占用足夠大,而且創建行程頻率足夠頻繁,會導致ForkLock長時間等待。

 

4

實驗論證

我用go1.8.3寫了一個測試程式,在2核4G的虛擬機(kernel 3.10.0-693.17.1.el7.x86_64)下測試。

 

在外部每隔10秒,給這個程式發SIGUSR1信號,打印運行時堆棧,運行一段時間後,部分goroutine獲取ForkLock的時間越來越長。見下麵兩圖:

 

 

而在go1.9及以上版本上並未出現上述情況,這個結果我覺得已經可以說明問題。升級版本到go1.9及以上版本可以解決該問題。

 

5

寫在最後

vfork是為瞭解決fork拷貝頁表項導致的性能問題, 而且大部分場景fork之後是呼叫exec,exec要把所有頁表刪除重置新的頁表, 實在沒必要再拷貝頁表項。但由於vfork父子行程共享記憶體,所以使用要很小心,如果子行程修改某個變數,會影響到父行程,而且kernel會掛起父行程,讓子行程先執行,這些限制基本限制vfork只適合跟exec的場景,不如fork通用。

 

正因為vfork的使用需要小心,因此go1.9準備加入vfork發佈之前,有人提出代碼不夠健壯,因為rawVforkSyscall傳回之後,在父行程段還執行指令,這樣子行程有機會破壞雙方的共享棧,因此提了一個commit去讓rawVforkSyscall在傳回後,在父行程段什麼都不做直接return,解決這個互相影響,如圖所示:

 

如有興趣深入瞭解,可以看下這個commit 的review,Rob Pike等人都有發言。

https://go-review.googlesource.com/c/go/+/46173

 

Gopher China 2019 最新資訊

赞(0)

分享創造快樂