1. 程式人生 > >一個 GO 語言效能問題的發現和解決

一個 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

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