1. 程式人生 > >golang原始碼剖析-網路庫的基礎實現-3

golang原始碼剖析-網路庫的基礎實現-3

runtime中的epoll事件驅動抽象層其實在進入net庫後,又被封裝了一次,這一次封裝從程式碼上看主要是為了方便在純Go語言環境進行操作,net庫中的這次封裝實現在poll/fd_poll_runtime.go檔案中,主要是通過pollDesc物件來實現的:
(ps: 這裡對應的版本是go1.9.1 的版本)

type pollDesc struct {
    runtimeCtx uintptr
}

注意:此處的pollDesc物件不是上文提到的runtime中的PollDesc,相反此處pollDesc物件的runtimeCtx成員才是指向的runtime的PollDesc例項。pollDesc物件主要就是將runtime的事件驅動抽象層給再封裝了一次,供網路fd物件使用。

func (pd *pollDesc) init(fd *FD) error {
    serverInit.Do(runtime_pollServerInit)
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    if errno != 0 {
        if ctx != 0 {
            runtime_pollUnblock(ctx)
            runtime_pollClose(ctx)
        }
        return syscall.Errno(errno)
    }
    pd.runtimeCtx = ctx
    return
nil }

pollDesc物件最需要關注的就是其Init方法,這個方法通過一個sync.Once變數來呼叫了runtime_pollServerInit函式,也就是建立epoll例項的函式。
意思就是runtime_pollServerInit函式在整個程序生命週期內只會被呼叫一次,也就是隻會建立一次epoll例項。epoll例項被建立後,會呼叫runtime_pollOpen函式將fd新增到epoll中。

網路程式設計中的所有socket fd都是通過netFD物件實現的,netFD是對網路IO操作的抽象,linux的實現在檔案net/fd_unix.go中。netFD物件實現有自己的init方法,還有完成基本IO操作的Read和Write方法,當然除了這三個方法以外,還有很多非常有用的方法供使用者使用。

/src/net/fd_unix.go

// Network file descriptor.
type netFD struct {
    pfd poll.FD

    // immutable until Close
    family      int
    sotype      int
    isConnected bool
    net         string
    laddr       Addr
    raddr       Addr
}

通過netFD物件的定義可以看到每個fd都關聯了一個pollDesc例項,通過上文我們知道pollDesc物件最終是對epoll的封裝。

func newFD(sysfd, family, sotype int, net string) (*netFD, error) {
    ret := &netFD{
        pfd: poll.FD{
            Sysfd:         sysfd,
            IsStream:      sotype == syscall.SOCK_STREAM,
            ZeroReadIsEOF: sotype != syscall.SOCK_DGRAM && sotype != syscall.SOCK_RAW,
        },
        family: family,
        sotype: sotype,
        net:    net,
    }
    return ret, nil
}

func (fd *netFD) init() error {
    return fd.pfd.Init(fd.net, true)
}

netFD物件的init函式僅僅是呼叫了pollDesc例項的Init函式,作用就是將fd新增到epoll中,如果這個fd是第一個網路socket fd的話,這一次init還會擔任建立epoll例項的任務。要知道在Go程序裡,只會有一個epoll例項來管理所有的網路socket fd,這個epoll例項也就是在第一個網路socket fd被建立的時候所建立。

/src/net/fd_unix.go
Read()函式:

// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
    if err := fd.readLock(); err != nil {
        return 0, err
    }
    defer fd.readUnlock()
    if len(p) == 0 {
        // If the caller wanted a zero byte read, return immediately
        // without trying (but after acquiring the readLock).
        // Otherwise syscall.Read returns 0, nil which looks like
        // io.EOF.
        // TODO(bradfitz): make it wait for readability? (Issue 15735)
        return 0, nil
    }
    if err := fd.pd.prepareRead(fd.isFile); err != nil {
        return 0, err
    }
    if fd.IsStream && len(p) > maxRW {
        p = p[:maxRW]
    }
    for {
        n, err := syscall.Read(fd.Sysfd, p)
        if err != nil {
            n = 0
            if err == syscall.EAGAIN && fd.pd.pollable() {
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }
        }
        err = fd.eofError(n, err)
        return n, err
    }
}

重點關注這個for迴圈中的syscall.Read呼叫的錯誤處理。當有錯誤發生的時候,會檢查這個錯誤是否是syscall.EAGAIN,如果是,則呼叫WaitRead將當前讀這個fd的goroutine給park住,直到這個fd上的讀事件再次發生為止。
當這個socket上有新資料到來的時候,WaitRead呼叫返回,繼續for迴圈的執行。這樣的實現,就讓呼叫netFD的Read的地方變成了同步“阻塞”方式程式設計,不再是非同步非阻塞的程式設計方式了。netFD的Write方法和Read的實現原理是一樣的,都是在碰到EAGAIN錯誤的時候將當前goroutine給park住直到socket再次可寫為止。

本文只是將網路庫的底層實現給大體上引導了一遍,知道底層程式碼大概實現在什麼地方,方便結合原始碼深入理解。Go語言中的高併發、同步阻塞方式程式設計的關鍵其實是”goroutine和排程器”,針對網路IO的時候,我們需要知道EAGAIN這個非常關鍵的排程點,掌握了這個排程點,即使沒有排程器,自己也可以在epoll的基礎上配合協程等使用者態執行緒實現網路IO操作的排程,達到同步阻塞程式設計的目的。

最後,為什麼需要同步阻塞的方式程式設計?只有看多、寫多了非同步非阻塞程式碼的時候才能夠深切體會到這個問題。真正的高大上絕對不是——“別人不會,我會;別人寫不出來,我寫得出來。”

EAGAIN:

ET還是LT?

LT的處理過程:
. accept一個連線,新增到epoll中監聽EPOLLIN事件
. 當EPOLLIN事件到達時,read fd中的資料並處理
. 當需要寫出資料時,把資料write到fd中;如果資料較大,無法一次性寫出,那麼在epoll中監聽EPOLLOUT事件
. 當EPOLLOUT事件到達時,繼續把資料write到fd中;如果資料寫出完畢,那麼在epoll中關閉EPOLLOUT事件

ET的處理過程:
. accept一個一個連線,新增到epoll中監聽EPOLLIN|EPOLLOUT事件
. 當EPOLLIN事件到達時,read fd中的資料並處理,read需要一直讀,直到返回EAGAIN為止
. 當需要寫出資料時,把資料write到fd中,直到資料全部寫完,或者write返回EAGAIN
. 當EPOLLOUT事件到達時,繼續把資料write到fd中,直到資料全部寫完,或者write返回EAGAIN

從ET的處理過程中可以看到,ET的要求是需要一直讀寫,直到返回EAGAIN,否則就會遺漏事件。而LT的處理過程中,直到返回EAGAIN不是硬性要求,但通常的處理過程都會讀寫直到返回EAGAIN,但LT比ET多了一個開關EPOLLOUT事件的步驟

LT的程式設計與poll/select接近,符合一直以來的習慣,不易出錯
ET的程式設計可以做到更加簡潔,某些場景下更加高效,但另一方面容易遺漏事件,容易產生bug

同步阻塞:
這裡寫圖片描述
非同步非阻塞:
這裡寫圖片描述

網路分析的部分,到此結束了, 後面還得多多的練習, 多多揣摩, 才能真正理解同步非同步的內涵.