公司目前的後臺是用Beego框架搭的,並且為了服務的不中斷升級,我們開啟了Beego的Grace模組,用於熱升級支援。一切都跑井然有序,直到有一天,領導甩出一些服務日誌,告知程式一直報錯:
2018/03/08 17:49:34 20848 Received SIGINT.
2018/03/08 17:49:34 20848 [::]:5490 Listener closed.
2018/03/08 17:49:34 20848 Waiting for connections to finish...
2018/03/08 17:49:34 [C] [asm_amd64.s:2337] ListenAndServe: accept tcp [::]:5490: use of closed network connection 20848
複製程式碼
問題出在第4行,每次服務關閉時,都會報出use of closed network connection。按理說這時候網路連線應該關閉了啊,程式都退出了,怎麼還Accept 5490埠?到Beego的Issues列表裡一搜,已經有人問過這個問題了(#2809),下面還沒有人回答,搜也搜尋不到,只剩最後一個工具了:看原始碼。
1. Grace模式
首先可以肯定,不開Grace模式的話,是沒有這些日誌打出來的,而是直接結束。因此我們先要對Beego的Grace模式有一些瞭解。Beego官網對此一定的介紹:Grace模組。大致是說他們參照:Grace_restart_in_golang這篇文章的思路實現的熱升級功能,文章很長,講述的思路很清晰,大體過程如下:
開源中國翻譯-GracefulRestart 這篇中文翻譯說明的更通俗易懂。明白了熱升級的原理,我們就可以進入程式碼中詳細尋找了。一切都從beego.Run()
開始。
beego.Run()
建立好了BeeApp物件,並且呼叫BeeApp.Run()
執行。Run方法有不同的啟動模式,在此,我們只關注Grace部分。
func (app *App) Run() {
addr := BConfig.Listen.HTTPAddr
...
// run graceful mode
if BConfig.Listen.Graceful {
...
if BConfig.Listen.EnableHTTP {
go func() {
// 建立了GraceServer 是對http.Server的一層封裝
server := grace.NewServer(addr, app.Handlers)
...
if err := server.ListenAndServe(); err != nil {
logs.Critical("ListenAndServe: ", err, fmt.Sprintf("%d", os.Getpid()))
endRunning <- true
}
}()
}
<-endRunning
return
}
...
}
複製程式碼
程式碼裡可以看到,logs.Critical("ListenAndServe: ", err, fmt.Sprintf("%d", os.Getpid()))
正是打出上述日誌的源頭:
2018/03/08 17:49:34 [C] [asm_amd64.s:2337] ListenAndServe: accept tcp [::]:5490: use of closed network connection 20848
複製程式碼
那麼為什麼會返回use of closed network connection
這個錯誤呢?跟進ListenAndServe()
方法中檢視:
func (srv *Server) ListenAndServe() (err error) {
...
// 處理上圖中的熱升級訊號(fork子程式),SIGINT、SIGTERM訊號(程式結束訊號)
go srv.handleSignals()
...
// 如果是子程式執行,Getppid()拿到父程式pid,並且Kill
if srv.isChild {
process, err := os.FindProcess(os.Getppid())
if err != nil {
log.Println(err)
return err
}
err = process.Kill()
if err != nil {
return err
}
}
log.Println(os.Getpid(), srv.Addr)
return srv.Serve()
}
複製程式碼
跟進Serve()
方法:
func (srv *Server) Serve() (err error) {
srv.state = StateRunning
//這裡我們傳入了一個GraceListener,對net.Listener做了封裝,在後面會用到。
err = srv.Server.Serve(srv.GraceListener)
log.Println(syscall.Getpid(), "Waiting for connections to finish...")
//此處會等待所有連線處理完成,對應圖中的父程式結束流程。
srv.wg.Wait()
srv.state = StateTerminate
return
}
複製程式碼
還是調回了http.Server.Serve()
方法,看這個方法:
func (srv *Server) Serve(l net.Listener) error {
defer l.Close()
...
for {
//程式碼在這裡阻塞,如果沒有連線進來的話。
rw, e := l.Accept()
if e != nil {
select {
//正常的結束流程
case <-srv.getDoneChan():
return ErrServerClosed
default:
}
...
//不正常的結束流程
return e
}
//開啟一個go程處理新連線
c := srv.newConn(rw)
go c.serve(ctx)
}
}
複製程式碼
如果是正常結束,我們應該會收到ErrServerClosed
,這雖然是個Error,但是類似於io.EOF
,是一個正常的結束流程,問題在於,我們收到的並不是ServerClosed
,而是use of closed connection
,由l.Accept()
方法返回,那我們進到Accept()
一探究竟。
2.Accept
家族
前面說了,l.Accept()
中l
是一個GraceListener
,那我們直接去看它的Accept()
方法。
func (gl *graceListener) Accept() (c net.Conn, err error) {
//調AcceptTCP()
tc, err := gl.Listener.(*net.TCPListener).AcceptTCP()
if err != nil {
return
}
...
//每次新來一個連線+1,當連線處理完成時-1。 前面wg.Wait()等的就是這個值減為0。
gl.server.wg.Add(1)
return
}
複製程式碼
還是調回了net.TCPListener
的AcceptTCP()
,去TCPListener
下看看它的AcceptTCP()
:
func (l *TCPListener) AcceptTCP() (*TCPConn, error) {
...
c, err := l.accept()
if err != nil {
return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
}
return c, nil
}
複製程式碼
這裡我們看到返回了一個OpError
,它的格式正如accept tcp [::]:5490: use of closed network connection
所示,以accept
開頭,有net
、addr
資訊,還有一個Err
的封裝,看來沒有找錯,錯誤就是從這裡發出來的,趕緊進到l.accept()
看看:
func (ln *TCPListener) accept() (*TCPConn, error) {
fd, err := ln.fd.accept()
if err != nil {
return nil, err
}
//建立了新的TCP連線
return newTCPConn(fd), nil
}
複製程式碼
這裡調了fd.accept()
涉及到一點UNIX系統知識,fd
即file descriptor
(檔案描述符),在UNIX中,一切皆檔案,一個Socket連線,一個程式,都可以看作是一個個的檔案,前面的圖中我們介紹的熱升級技術,子程式之所以能拿到父程式的Socket連線,也是父程式在fork子程式的過程中,把自己的Socket連線的檔案作為啟動引數傳遞給了子程式,從而讓子程式可以通過這個檔案接管新來的請求,我們直接進入ln.fd.accept()
看看這個fd
當中有何玄機:
func (fd *netFD) accept() (netfd *netFD, err error) {
d, rsa, errcall, err := fd.pfd.Accept()
if err != nil {
if errcall != "" {
err = wrapSyscallError(errcall, err)
}
//有可能
return nil, err
}
if netfd, err = newFD(d, fd.family, fd.sotype, fd.net); err != nil {
poll.CloseFunc(d)
//有可能
return nil, err
}
if err = netfd.init(); err != nil {
fd.Close()
//有可能
return nil, err
}
...
return netfd, nil
}
複製程式碼
上面三處程式碼,都有可能返回err
,通過本地執行服務,發現啟動服務後,沒有連線進來時,(也就是說,程式碼現在在Accept()
阻塞,並沒有走到下面的控制流程中去),這時向其傳送SIGINT訊號,依然會打出use of closed connection
日誌,這說明這個err
就是由fd.pfd.Accept()
方法丟擲來的,進到這個方法裡看看詳情:
// Accept wraps the accept network call.
func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) {
...
if err := fd.pd.prepareRead(fd.isFile); err != nil {
return -1, nil, "", err
}
for {
//這個accept()是對accept系統呼叫的封裝方法
s, rsa, errcall, err := accept(fd.Sysfd)
...
switch err {
case syscall.EAGAIN:
if fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
...
}
return -1, nil, errcall, err
}
}
複製程式碼
函式裡的accept()
是對accept
系統呼叫的封裝,再往下已然超出這篇文章的範疇,那又是UNIX系統的一大篇知識,Accept
家族到這結束,我們得出的結論是:這個錯誤是UNIX系統發出的,與我們的服務和Go語言都無關。這個結論無疑是鴕鳥式做法,儘管錯誤是系統發出的,但是系統不會無故的彙報錯誤,必然是什麼操作有問題,觸發系統彙報了這個錯誤。讓我門再仔細的掃視這個方法,它首先prepareRead()
準備了一番,然後才進行accept
系統呼叫,儘管不可能是卡在這個prepareRead()
方法上(因為我們的服務啟動後,是可以正常接受和處理連線的),但是我們還是可以去看看這裡做了哪些prepare
。
func (pd *pollDesc) prepareRead(isFile bool) error {
return pd.prepare('r', isFile)
}
func (pd *pollDesc) prepare(mode int, isFile bool) error {
if pd.runtimeCtx == 0 {
return nil
}
res := runtime_pollReset(pd.runtimeCtx, mode)
return convertErr(res, isFile)
}
複製程式碼
最終呼叫了Runtime
的runtime_pollReset()
對IO輪詢器進行重置,並且convertRuntime
丟擲的Err,進入這個convertErr()
看看:
func convertErr(res int, isFile bool) error {
switch res {
case 0:
return nil
case 1:
return errClosing(isFile)
case 2:
return ErrTimeout
}
...
}
// ErrNetClosing is returned when a network descriptor is used after it has been closed.
var ErrNetClosing = errors.New("use of closed network connection")
// Return the appropriate closing error based on isFile.
func errClosing(isFile bool) error {
if isFile {
return ErrFileClosing
}
return ErrNetClosing
}
複製程式碼
終於,我們在不可能丟擲這個Err
的地方發現了這個Err
:use of closed network connection
。這話說的實在有些繞,因為我們知道prepareRead()
是沒有返回Err
的,不然我們的服務也不可能啟動並且監聽埠,所以雖然這個地方宣告瞭這個ErrNetClosing
物件,但是卻不是這裡丟擲來的Err
。前面的分析我們已經得出結論,Err
是系統返回給我們的。分析到了這裡又中斷了,不過我們可以擴充下思路,看看這個ErrNetClosing
的註釋寫了什麼:大意是說,當使用一個被關閉的網路描述符時,這個Error
會被返回,那麼我們找找這個ErrNetClosing
在那裡被使用到了,在prepareRead()
方法附近,我們發現了下面的這個方法:
//修改了上述變數ErrNetClosing的值
var ErrNetClosing = errors.New("use of closed network connection --- 改")
func (pd *pollDesc) wait(mode int, isFile bool) error {
...
res := runtime_pollWait(pd.runtimeCtx, mode)
println(" 沒錯,錯誤就是我丟擲來的 res:", res)
//如果res為1 ,丟擲錯誤ErrNetClosing。如果res為0,err = nil。
return convertErr(res, isFile)
}
複製程式碼
上面的程式碼是我對原有程式碼進行修改,加上一些日誌,現在重新執行,傳送SIGINT
訊號,看看日誌有什麼變化:
注意:如果修改了go標準庫中的程式碼,你需要go build -a ,新增a引數,意思是所有的程式碼都重新編譯,這也包括go標準庫中的程式碼。
沒錯,錯誤就是我丟擲來的 res:0
沒錯,錯誤就是我丟擲來的 res:0
2018/03/09 11:42:57 31164 0.0.0.0:5490
2018/03/09 11:43:09 31164 Received SIGINT.
2018/03/09 11:43:09 31164 [::]:5490 Listener closed.
2018/03/09 11:43:09 31164 Waiting for connections to finish...
沒錯,錯誤就是我丟擲來的 res:1
2018/03/09 11:43:09 [C] [asm_amd64.s:2337] ListenAndServe: accept tcp [::]:5490: use of closed network connection --- 改 31164
複製程式碼
當res=0
時,一切都沒什麼問題,當接收到SIGINT
,從日誌可以看到,res
這時候為1,這就導致了convertErr()
返回錯誤ErrNetClosing
。看起來,這個wait()
就是丟擲這個錯誤的真正源頭了。然而,我們雖然找到了它,但卻沒有解決我們的疑問,為什麼res會被系統置為1,最終導致我們收到ErrNetClosing
?到底是什麼操作導致了這個錯誤?而且,看到這個wait()
方法,不禁又有新的疑問:為什麼Accept()
家族最後阻塞在Accept
系統呼叫上,錯誤卻是這個wait()
返回的,wait()
和Accept()
家族有著怎樣的關聯呢?
3. 網路程式設計中的 Accept
、Wait
看來Go幫我們做了太多太多,我們只知道,服務端在ListenAndServe()
中阻塞,更進一步,是在fd.Accept()
方法中阻塞,等待連線的到來。而前面的分析我們卻發現了一個wait()
方法,無疑,wait更能比accept表達出阻塞等待的含義,我們再次審視fd.Accept()
方法:
// Accept wraps the accept network call.
func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) {
...
if err := fd.pd.prepareRead(fd.isFile); err != nil {
return -1, nil, "", err
}
for {
//這個accept()是對accept系統呼叫的封裝方法,其實它是返回了的,沒有阻塞
s, rsa, errcall, err := accept(fd.Sysfd)
...
switch err {
case syscall.EAGAIN:
if fd.pd.pollable() {
//這裡呼叫了waitRead(),說明了上面的accept()方法其實沒有阻塞,真正的阻塞在這裡。
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
}
return -1, nil, errcall, err
}
}
func (pd *pollDesc) waitRead(isFile bool) error {
return pd.wait('r', isFile)
}
複製程式碼
終於,我們發現了waitRead()
,而它正是wait()
方法的一層封裝。形勢已經清晰明瞭了,我們陷入了網路會阻塞在Accept()
方法這個思維定勢中,一直在Accept
的呼叫鏈中追尋不得結果,現在來看,網路阻塞在Accept()
這句話沒錯,但是是對應用層的程式碼有效,在系統底層,fd.Accept()
完成會返回syscall.EAGAIN
這個Err
,正是捕捉到這個syscall.EAGAIN
,讓我們的程式碼停在waitRead()
中,直到有連線過來。那麼,wait()
方法其實是對runtime
的runtime_pollWait
的一層封裝,要知道wait()
的具體內容,還要去runtime
包中尋找。
//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
err := netpollcheckerr(pd, int32(mode))
if err != 0 {
return err
}
...
//程式碼將會阻塞在這裡,如果netpollblock()不返回true,程式碼將一直迴圈。
for !netpollblock(pd, int32(mode), false) {
//迴圈會不斷檢查是否有錯誤,有錯誤則退出
err = netpollcheckerr(pd, int32(mode))
if err != 0 {
return err
}
}
return 0
}
func netpollcheckerr(pd *pollDesc, mode int32) int {
if pd.closing {
//pd關閉會導致這裡返回 1
return 1 // errClosing
}
if (mode == 'r' && pd.rd < 0) || (mode == 'w' && pd.wd < 0) {
return 2 // errTimeout
}
return 0
}
複製程式碼
至此我們已經找出了res=1
是出自何處,正是netpollcheckerr()
返回了1,導致res接收到了1,從而丟擲ErrNetClosing
錯誤,這個錯誤導致我們接收到use of closed netword connection
這句日誌。鏈條到此終止,可是我們現在是隻知其然,而不知其所以然。到底經歷了一個怎樣的過程,導致了pd.closing
變成了true
?這個錯誤又會不會影響到我們的業務邏輯,可不可以忽略?為什麼同樣是服務關閉,只有開啟Graceful模式的服務才丟擲這個錯誤呢?很明顯要解決這些疑問,我們要調轉方向,回到ListenAndServe()
,找找服務是怎麼被Closed
的。
4. Close
鏈
func (srv *Server) ListenAndServe() (err error) {
...
// 處理上圖中的熱升級訊號(fork子程式),SIGINT、SIGTERM訊號(程式結束訊號)
go srv.handleSignals()
//如果是子程式,getListener()拿到的還是父程式的監聽器,如果是父程式,建立新的監聽器。
l, err := srv.getListener(addr)
if err != nil {
log.Println(err)
return err
}
//對監聽器做包裝
srv.GraceListener = newGraceListener(l, srv)
...
}
func (srv *Server) handleSignals() {
var sig os.Signal
signal.Notify(
srv.sigChan,
hookableSignals...,
)
pid := syscall.Getpid()
for {
sig = <-srv.sigChan
...
switch sig {
case syscall.SIGHUP:
log.Println(pid, "Received SIGHUP. forking.")
err := srv.fork()
...
case syscall.SIGINT:
log.Println(pid, "Received SIGINT.")
srv.shutdown()
case syscall.SIGTERM:
log.Println(pid, "Received SIGTERM.")
srv.shutdown()
...
}
}
複製程式碼
這裡可以看到ListenAndServe()
被呼叫時,會啟動一個goroutine
,等待系統訊號的到來,一旦收到SIGHUP
訊號,則馬上fork一個子程式;如果收到SIGINT
或者SIGTERM
訊號,則會呼叫stv.shutdown()
關閉連線,看看stv.shutdown()
做了些什麼:
func (srv *Server) shutdown() {
...
srv.state = StateShuttingDown
if DefaultTimeout >= 0 {
go srv.serverTimeout(DefaultTimeout)
}
err := srv.GraceListener.Close()
...
}
func (srv *Server) serverTimeout(d time.Duration) {
...
time.Sleep(d)
//當d時間過去後,程式結束休眠,強制將計數器置0
for {
if srv.state == StateTerminate {
break
}
//計數器減1
srv.wg.Done()
}
}
複製程式碼
這裡呼叫了GraceListener
的Close()
,前面我們說過,這個GraceListener
實際上是對TCPListener
的封裝。主要是在Accept()
中新增一個計數器,當有連線來了,計數器加1,連線處理完成,計數器減1。當然,如果網路有延遲,或者客戶端有連線被掛起導致計數器不為0,經過DefaultTimeout
(60秒)後,serverTimeout()
會強制把計數器置為0。那麼,我們進入Close()
方法看看如何關閉監聽。
func (gl *graceListener) Close() error {
if gl.stopped {
return syscall.EINVAL
}
//簡單的向stop channer 傳送nil訊號
gl.stop <- nil
//等待TCPListener.Close()執行完畢
return <-gl.stop
}
func newGraceListener(l net.Listener, srv *Server) (el *graceListener) {
el = &graceListener{
Listener: l,
stop: make(chan error),
server: srv,
}
//開啟一個goroutine,不阻塞程式碼
go func() {
//等待Close()的nil訊號
<-el.stop
el.stopped = true
el.stop <- el.Listener.Close()
}()
return
}
複製程式碼
這裡涉及到goroutine
間的通訊,在我們新建這個GraceListener
時,就已經在監聽結束訊號了,程式碼將在<-el.stop
阻塞,直到gl.stop<-nil
語句被執行,stoped
被置為true,且呼叫TCPListener.Close()
並等待其執行完畢。那麼TCPListener.Close()
又執行了什麼操作?
func (l *TCPListener) Close() error {
...
if err := l.close(); err != nil {
return &OpError{Op: "close", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
}
return nil
}
func (ln *TCPListener) close() error {
return ln.fd.Close()
}
複製程式碼
Close()
方法是對close()
方法的封裝,因為Go中沒有訪問修飾符,方法首字母的大小寫就代表這個方法是公有的還是私有的。所以Close()
-close()
也算是Go的特色了。close()
方法呼叫了fd.close()
,前面我們分析過了,這個fd
是指檔案描述符,也就是當前的Socket
連線,這個連線被關閉,我們就不能接受新連線了。
func (fd *netFD) Close() error {
runtime.SetFinalizer(fd, nil)
return fd.pfd.Close()
}
func (fd *FD) Close() error {
...
fd.pd.evict()
return fd.decref()
}
func (pd *pollDesc) evict() {
if pd.runtimeCtx == 0 {
return
}
runtime_pollUnblock(pd.runtimeCtx)
}
複製程式碼
最終還是呼叫了runtime_pollUnblock()
,直接看runtime
包的原始碼:
func poll_runtime_pollUnblock(pd *pollDesc) {
lock(&pd.lock)
if pd.closing {
throw("runtime: unblock on closing polldesc")
}
pd.closing = true
...
}
複製程式碼
還記得我們分析Wait
過程說的pd.closing
,正是它為true
導致了res=1
:
//如果netpollblock()不返回true,程式碼將一直迴圈
for !netpollblock(pd, int32(mode), false) {
//迴圈會不斷檢查是否有錯誤,有錯誤則退出
err = netpollcheckerr(pd, int32(mode))
...
}
---------
if pd.closing {
//pd關閉會導致這裡返回 1
return 1 // errClosing
}
複製程式碼
現在可以清晰的瞭解到發生了什麼了,上面的程式碼吧pd.closing
置為true,而Accpet()
所在的goroutine
檢查到這個值發生了改變,於是終止了Accept()
過程並報錯,這些goroutine
的關係如下圖所示:
上圖中的三個goroutine
,從最右邊的shutdown()
開始看起,一步一步的完成關閉流程。最左邊的是Accept()
所在的goroutine
一般我們正常結束程式的話,這個goroutine
會正常返回。但是我們為了實現Graceful
模式,還新建了兩個goroutine
,正是這兩個新建的goroutine
合作把當前的網路連線關閉,Accept()
所在的goroutine
不瞭解當前狀況,以為是意外的關閉,導致我們收到那行Err
日誌。但是,這種關閉並不會對現有程式有什麼影響,因為是我們知道是自己主動執行的這個過程。
5. 後記
Beego日誌的分析過程算是結束了,文章很長,能看到最後的小夥伴都是棒棒的,雖然我中途考慮過分成兩篇,權衡之下還是作罷,因為這些分析前後相連,也是我思路的文字記錄,如果分開,總是有被強行打斷之感,想必讀者也會覺得麻煩,不如一氣呵成,刨根問底來的痛快。為了照顧讀者,以及用手機閱讀的人,此文引用的程式碼儘量精簡,並且在該註釋的地方做了註釋,望能在這資訊爆炸的時代,讀者(也包括未來的我)能儘快的從中提取出關鍵資訊來。
在我預計要寫這篇文章時,遠沒有現在這麼長,不過是發現了Accept阻塞時,fd.close()以後系統會報'use of network connection'錯誤,但不影響業務邏輯。但是隨著文章的推進,一個又一個問題不斷冒出來,促使我更深入的去尋找答案,也算是一個意外的學習過程。也使我最近一直在考慮,追溯一個問題的答案,到底要到何種地步?在程式設計技術被高度封裝的今天,我們要窺探到語言底層為止?還是作業系統層面?亦或者深入到彙編,對每個暫存器,每條指令都有所涉獵?前人和我們把計算機這座山越堆越高,我們站在高處,是否有一天會望不到山下的景象了,也許窮盡一生也學不盡最底下的東西。最近還聽說JS現在被各種封裝,甚至很多語言都是先編譯成JS再執行,有人說JS都快成前端的彙編了。又聽說了Flutter,這個框架封裝了Android和iOS的開發流程,提供更高層次的介面相容兩個平臺的開發。不禁有些迷茫,新潮的技術層出不窮,去年RN、Weex還火的不行,今年就出了跨平臺開發的大殺器,年年翻新,還學的過來麼?
這次的分析過程倒是對這些問題有了一點心得,文中對Accept過程分析到Accept系統呼叫就沒再往下了,在對wait函式的分析中,分析到了runtime也就停止了,至於那些系統核心的事兒,一概沒有涉及(也不懂)。因為這些現有的分析,足以形成對文章開頭那個問題的解釋,並且得出不會影響現有業務流程的結論。畢竟你的領導,他想要的只有結果。如果你先有的知識能hold住你的工作,那就沒必要做無謂的涉獵。畢竟學以致用,最終還是為了那份薪水服務。如果想要更好的薪水,找到更好的工作,導致能力上hold不住,那自然而然會去主動補充自己。畢竟人生苦短,我用Python少加班,多幹幹自己喜歡的事,多陪陪重要的人。