Go語言的出現,讓我見到了一門語言把網路程式設計這件事情給做“正確”了,當然,除了Go語言以外,還有很多語言也把這件事情做”正確”了。我一直堅持著這樣的理念——要做”正確”的事情,而不是”高效能”的事情;很多時候,我們在做系統設計、技術選型的時候,都被“高效能”這三個字給綁架了,當然不是說效能不重要,你懂的。
目前很多高效能的基礎網路伺服器都是採用的C語言開發的,比如:Nginx、Redis、memcached等,它們都是基於”事件驅動 + 事件回撥函式”的方式實現,也就是採用epoll等作為網路收發資料包的核心驅動。不少人(包括我自己)都認為“事件驅動 + 事件回撥函式”的程式設計方法是“反人類”的;因為大多數人都更習慣線性的處理一件事情,做完第一件事情再做第二件事情,並不習慣在N件事情之間頻繁的切換幹活。為了解決程式設計師在開發伺服器時需要自己的大腦不斷的“上下文切換”的問題,Go語言引入了一種使用者態執行緒goroutine來取代編寫非同步的事件回撥函式,從而重新迴歸到多執行緒併發模型的線性、同步的程式設計方式上。
用Go語言寫一個最簡單的echo伺服器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
package main import ( "log" "net" ) func main() { ln, err := net.Listen("tcp", ":8080") if err != nil { log.Println(err) return } for { conn, err := ln.Accept() if err != nil { log.Println(err) continue } go echoFunc(conn) } } func echoFunc(c net.Conn) { buf := make([]byte, 1024) for { n, err := c.Read(buf) if err != nil { log.Println(err) return } c.Write(buf[:n]) } } |
main函式的過程就是首先建立一個監聽套接字,然後用一個for迴圈不斷的從監聽套接字上Accept新的連線,最後呼叫echoFunc函式在建立的連線上幹活。關鍵程式碼是:
1 |
go echoFunc(conn) |
每收到一個新的連線,就建立一個“執行緒”去服務這個連線,因此所有的業務邏輯都可以同步、順序的編寫到echoFunc函式中,再也不用去關心網路IO是否會阻塞的問題。不管業務多複雜,Go語言的併發伺服器的程式設計模型都是長這個樣子。可以肯定的是,在linux上Go語言寫的網路伺服器也是採用的epoll作為最底層的資料收發驅動,Go語言網路的底層實現中同樣存在“上下文切換”的工作,只是這個切換工作由runtime的排程器來做了,減少了程式設計師的負擔。
弄明白網路庫的底層實現,貌似只要弄清楚echo伺服器中的Listen、Accept、Read、Write四個函式的底層實現關係就可以了。本文將採用自底向上的方式來介紹,也就是從最底層到上層的方式,這也是我閱讀原始碼的方式。底層實現涉及到的核心原始碼檔案主要有:
net/fd_unix.go
net/fd_poll_runtime.go
runtime/netpoll.goc
runtime/netpoll_epoll.c
runtime/proc.c (排程器)
netpoll_epoll.c檔案是Linux平臺使用epoll作為網路IO多路複用的實現程式碼,這份程式碼可以瞭解到epoll相關的操作(比如:新增fd到epoll、從epoll刪除fd等),只有4個函式,分別是runtime·netpollinit、runtime·netpollopen、runtime·netpollclose和runtime·netpoll。init函式就是建立epoll物件,open函式就是新增一個fd到epoll中,close函式就是從epoll刪除一個fd,netpoll函式就是從epoll wait得到所有發生事件的fd,並將每個fd對應的goroutine(使用者態執行緒)通過連結串列返回。用epoll寫過程式的人應該都能理解這份程式碼,沒什麼特別之處。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
void runtime·netpollinit(void) { epfd = runtime·epollcreate1(EPOLL_CLOEXEC); if(epfd >= 0) return; epfd = runtime·epollcreate(1024); if(epfd >= 0) { runtime·closeonexec(epfd); return; } runtime·printf("netpollinit: failed to create descriptor (%d)\n", -epfd); runtime·throw("netpollinit: failed to create descriptor"); } |
runtime·netpollinit函式首先使用runtime·epollcreate1建立epoll例項,如果沒有建立成功,就換用runtime·epollcreate再建立一次。這兩個create函式分別等價於glibc的epoll_create1和epoll_create函式。只是因為Go語言並沒有直接使用glibc,而是自己封裝的系統呼叫,但功能是等價於glibc的。可以通過man手冊檢視這兩個create的詳細資訊。
1 2 3 4 5 6 7 8 9 10 11 |
int32 runtime·netpollopen(uintptr fd, PollDesc *pd) { EpollEvent ev; int32 res; ev.events = EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET; ev.data = (uint64)pd; res = runtime·epollctl(epfd, EPOLL_CTL_ADD, (int32)fd, &ev); return -res; } |
新增fd到epoll中的runtime·netpollopen函式可以看到每個fd一開始都關注了讀寫事件,並且採用的是邊緣觸發,除此之外還關注了一個不常見的新事件EPOLLRDHUP,這個事件是在較新的核心版本新增的,目的是解決對端socket關閉,epoll本身並不能直接感知到這個關閉動作的問題。注意任何一個fd在新增到epoll中的時候就關注了EPOLLOUT事件的話,就立馬產生一次寫事件,這次事件可能是多餘浪費的。
epoll操作的相關函式都會在事件驅動的抽象層中去呼叫,為什麼需要這個抽象層呢?原因很簡單,因為Go語言需要跑在不同的平臺上,有Linux、Unix、Mac OS X和Windows等,所以需要靠事件驅動的抽象層來為網路庫提供一致的介面,從而遮蔽事件驅動的具體平臺依賴實現。runtime/netpoll.goc原始檔就是整個事件驅動抽象層的實現,抽象層的核心資料結構是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct PollDesc { PollDesc* link; // in pollcache, protected by pollcache.Lock Lock; // protectes the following fields uintptr fd; bool closing; uintptr seq; // protects from stale timers and ready notifications G* rg; // G waiting for read or READY (binary semaphore) Timer rt; // read deadline timer (set if rt.fv != nil) int64 rd; // read deadline G* wg; // the same for writes Timer wt; int64 wd; }; |
每個新增到epoll中的fd都對應了一個PollDesc結構例項,PollDesc維護了讀寫此fd的goroutine這一非常重要的資訊。可以大膽的推測一下,網路IO讀寫操作的實現應該是:當在一個fd上讀寫遇到EAGAIN錯誤的時候,就將當前goroutine儲存到這個fd對應的PollDesc中,同時將goroutine給park住,直到這個fd上再此發生了讀寫事件後,再將此goroutine給ready啟用重新執行。事實上的實現大概也是這個樣子的。
事件驅動抽象層主要乾的事情就是將具體的事件驅動實現(比如: epoll)通過統一的介面封裝成Go介面供net庫使用,主要的介面也是:建立事件驅動例項、新增fd、刪除fd、等待事件以及設定DeadLine。runtime_pollServerInit負責建立事件驅動例項,runtime_pollOpen將分配一個PollDesc例項和fd繫結起來,然後將fd新增到epoll中,runtime_pollClose就是將fd從epoll中刪除,同時將刪除的fd繫結的PollDesc例項刪除,runtime_pollWait介面是至關重要的,這個介面一般是在非阻塞讀寫發生EAGAIN錯誤的時候呼叫,作用就是park當前讀寫的goroutine。
runtime中的epoll事件驅動抽象層其實在進入net庫後,又被封裝了一次,這一次封裝從程式碼上看主要是為了方便在純Go語言環境進行操作,net庫中的這次封裝實現在net/fd_poll_runtime.go檔案中,主要是通過pollDesc物件來實現的:
1 2 3 |
type pollDesc struct { runtimeCtx uintptr } |
注意:此處的pollDesc物件不是上文提到的runtime中的PollDesc,相反此處pollDesc物件的runtimeCtx成員才是指向的runtime的PollDesc例項。pollDesc物件主要就是將runtime的事件驅動抽象層給再封裝了一次,供網路fd物件使用。
1 2 3 4 5 6 7 8 9 10 11 |
var serverInit sync.Once func (pd *pollDesc) Init(fd *netFD) error { serverInit.Do(runtime_pollServerInit) ctx, errno := runtime_pollOpen(uintptr(fd.sysfd)) if errno != 0 { 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方法,當然除了這三個方法以外,還有很多非常有用的方法供使用者使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// Network file descriptor. type netFD struct { // locking/lifetime of sysfd + serialize access to Read and Write methods fdmu fdMutex // immutable until Close sysfd int family int sotype int isConnected bool net string laddr Addr raddr Addr // wait server pd pollDesc } |
通過netFD物件的定義可以看到每個fd都關聯了一個pollDesc例項,通過上文我們知道pollDesc物件最終是對epoll的封裝。
1 2 3 4 5 6 |
func (fd *netFD) init() error { if err := fd.pd.Init(fd); err != nil { return err } return nil } |
netFD物件的init函式僅僅是呼叫了pollDesc例項的Init函式,作用就是將fd新增到epoll中,如果這個fd是第一個網路socket fd的話,這一次init還會擔任建立epoll例項的任務。要知道在Go程式裡,只會有一個epoll例項來管理所有的網路socket fd,這個epoll例項也就是在第一個網路socket fd被建立的時候所建立。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
for { n, err = syscall.Read(int(fd.sysfd), p) if err != nil { n = 0 if err == syscall.EAGAIN { if err = fd.pd.WaitRead(); err == nil { continue } } } err = chkReadErr(n, err, fd) break } |
上面程式碼段是從netFD的Read方法中摘取,重點關注這個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操作的排程,達到同步阻塞程式設計的目的。
最後,為什麼需要同步阻塞的方式程式設計?只有看多、寫多了非同步非阻塞程式碼的時候才能夠深切體會到這個問題。真正的高大上絕對不是——“別人不會,我會;別人寫不出來,我寫得出來。”