一個GO語言性能問題的發現和解決 原 薦

本文是大 U 同事的一篇實操性經驗貼,是發現問題、分析問題到解決問題的完整案例,藉此分享,希望對各位有所幫助。

事件起因

事情起因於公司一位同事在內部郵件組中 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。

問題分析

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。

原因猜測

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 長時間等待。

實驗論證

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

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

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

寫在最後

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

更多技術乾貨,請關注 “雲計算總動員” ,我們一起在這裏,用雲計算改變未來。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章