Golang基于epoll實現(xiàn)最簡單網(wǎng)絡通信框架
系列源碼已經(jīng)上傳github:https://github.com/HobbyBear/tinyredis/tree/chapter1
redis的網(wǎng)絡模型是基于epoll實現(xiàn)的,所以這一節(jié)讓我們先基于epoll,實現(xiàn)一個最簡單的服務端客戶端通信模型。在實現(xiàn)前,先來簡單的了解下epoll的原理。
為什么不用golang的原生的netpoll網(wǎng)絡框架呢,這是因為netpoll框架雖然底層也是基于epoll實現(xiàn),但是它提供給開發(fā)人員使用網(wǎng)絡io方式依然是同步阻塞模式,一個連接單獨的拿給一個協(xié)程去處理,為了更加真實的感受下redis的網(wǎng)絡模型,我們不用netpoll框架,而是自己寫一個非阻塞的網(wǎng)絡模型。
epoll 網(wǎng)絡通信原理
通常情況下服務端的處理客戶端請求的邏輯是客戶端每發(fā)起一個連接,服務端就單獨起一個線程去處理這個連接的請求,對于go應用程序而言,則是啟用一個協(xié)程去處理這個連接。 而采用epoll相關的api后,能夠讓我們在一個線程或者協(xié)程里去處理多個連接的請求。
一個套接字連接對應一個文件描述符,當收到客戶端的連接請求時,可以將對應的文件描述符加入到epoll實例關注的事件中去。
在golang里,可以通過syscall.EpollCreate1 去創(chuàng)建一個epoll實例。
func EpollCreate1(flag int) (fd int, err error)
其返回結果的fd就代表epoll實例的fd,當收到客戶端的連接請求時,便可以將客戶端連接的fd,通過EpollCtl 加入到epoll實例感興趣的事件當中。
func EpollCtl(epfd int, op int, fd int, event *EpollEvent) (err error)
EpollCtl 方法參數(shù)的epfd則是EpollCreate1 返回的fd,EpollCtl的第二個參數(shù)則是代表客戶端連接的fd,通過我們在獲取到客戶端連接后,后續(xù)的行為便是查看客戶端是否有數(shù)據(jù)發(fā)送過來或者往客戶端發(fā)送數(shù)據(jù),這些在epoll api里用event事件去表示,分別對應了讀event和寫event,這便是EpollCtl第三個參數(shù)所代表的含義。
將這些感興趣事件添加到epoll實例中后,就代表epoll實例后續(xù)會監(jiān)聽這些連接的讀寫事件的到達,那么讀寫事件到達后,用戶程序又是如何知道的呢,這就要提到epoll相關的另一個api,EpollWait。
func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error)
EpollWait的第二個參數(shù)是一個事件數(shù)組,用戶應用程序調(diào)用EpollWait時傳入一個固定長度的事件數(shù)組,然后EpollWait會將這個數(shù)組盡可能填滿,這樣用戶程序便能知道有哪些事件類型到達了,EpollEvent類型如下所示:
type EpollEvent struct { Events uint32 Fd int32 Pad int32 }
其中fd則代表這些事件所關聯(lián)的客戶端連接的fd,通過這個fd,我們便可以對對應連接進行讀寫操作了。
而Events是個枚舉類型,比較常用的枚舉以及含義如下:
類型 | 解釋 |
---|---|
EPOLLIN | 表示文件描述符可讀。 |
EPOLLRDHUP | 表示 TCP 連接的遠程端點關閉或半關閉連接 |
EPOLLET | 表示使用邊緣觸發(fā)模式來監(jiān)聽事件 |
EPOLLOUT | 表示文件描述符可寫 |
EPOLLERR | 表示文件描述符發(fā)生錯誤時發(fā)生,這個事件不通過EpollCtl添加也能觸發(fā) |
EPOLLHUP | 與EPOLLRDHUP類似同樣表示連接關閉,在不支持EPOLLRDHUP的linux版本會觸發(fā),這個事件不通過EpollCtl添加也能觸發(fā) |
雖然epoll event還有其他類型,不過一般情況下監(jiān)控這幾種類型就足夠了,golang的netpoll框架在添加連接的文件描述符時事件時也只添加了這幾種類型。netpoll的部分源碼如下:
func netpollopen(fd uintptr, pd *pollDesc) int32 { var ev epollevent ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET *(**pollDesc)(unsafe.Pointer(&ev.data)) = pd return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev) }
如何用golang創(chuàng)建基于epoll的網(wǎng)絡框架
了解完epoll的一些概念以后,現(xiàn)在來看下我們需要實現(xiàn)的網(wǎng)絡框架模型是怎樣的。我們先實現(xiàn)一個最簡單的網(wǎng)絡通信框架,客戶端發(fā)送來消息,然后服務端打印收到的消息。
如上圖所示,我們收到新的連接后,會調(diào)用epoll實例的EpollCtl方法將連接的可讀事件添加到epoll實例中,接著調(diào)用EpollWait方法等待客戶端再次發(fā)送消息時,讓連接變?yōu)榭勺x。
下面是程序的效果測試結果
效果測試
啟動了兩個終端,其中右邊的終端連接上redis以后,發(fā)送了1231,然后左邊的終端收到后將收到的消息打印出來。
go代碼實現(xiàn)
接著,我們來看看實際代碼編寫邏輯。
我們定義一個Server的結構體來代表epoll的server。
Conn是對golang原生連接類型net.Conn的包裝,。
poll結構體是封裝了對epoll api的調(diào)用。
type Server struct { Poll *poll addr string listener net.Listener ConnMap sync.Map } type Conn struct { s *Server conn *net.TCPConn nfd int } type poll struct { EpollFd int }
接著來看下如何啟動一個Server,NewServer是返回一個Server實例,Server 調(diào)用Run方法后,才算Server正式啟動了起來。
在Run 方法里,構建監(jiān)聽連接的listener,構建一個epoll實例,用于后續(xù)對事件的監(jiān)聽,同時把監(jiān)聽握手連接和處理連接可讀數(shù)據(jù)分成了兩個協(xié)程分別用accept方法,和handler方法執(zhí)行。
func NewServ(addr string) *Server { return &Server{addr: addr, ConnMap: sync.Map{}} } func (s *Server) Run() error { listener, err := net.Listen("tcp", s.addr) if err != nil { return err } s.listener = listener epollFD, err := syscall.EpollCreate1(0) if err != nil { return err } s.Poll = &poll{EpollFd: epollFD} go s.accept() go s.handler() ch := make(chan int) <-ch return nil }
accept 方法里執(zhí)行的邏輯就是將握手完成的鏈接從全連接隊列里取出來,將其連接的文件描述符和連接存儲到一個map里, 然后將對應的文件描述符通過epoll的epollCtl 系統(tǒng)調(diào)用監(jiān)聽它的可讀事件,后續(xù)客戶端再使用這個連接發(fā)送數(shù)據(jù)時,epoll就能監(jiān)聽到了。
func (s *Server) accept() { for { acceptConn, err := s.listener.Accept() if err != nil { return } var nfd int rawConn, err := acceptConn.(*net.TCPConn).SyscallConn() if err != nil { log.Error(err.Error()) continue } rawConn.Control(func(fd uintptr) { nfd = int(fd) }) // 設置為非阻塞狀態(tài) err = syscall.SetNonblock(nfd, true) if err != nil { return } err = s.Poll.AddListen(nfd) if err != nil { log.Error(err.Error()) continue } c := &Conn{ conn: acceptConn.(*net.TCPConn), nfd: nfd, s: s, } s.ConnMap.Store(nfd, c) } }
handler里的邏輯則是通過epoll Wait系統(tǒng)調(diào)用等待可讀事件產(chǎn)生,到達后,根據(jù)事件的文件描述符找到對應連接,然后讀取對應連接的數(shù)據(jù)。
func (s *Server) handler() { for { events, err := s.Poll.WaitEvents() if err != nil { log.Error(err.Error()) continue } for _, e := range events { connInf, ok := s.ConnMap.Load(int(e.FD)) if !ok { continue } conn := connInf.(*Conn) if IsClosedEvent(e.Type) { conn.Close() continue } if IsReadableEvent(e.Type) { buf := make([]byte, 1024) rd, err := conn.Read(buf) if err != nil && err != syscall.EAGAIN { conn.Close() continue } fmt.Println("收到消息", string(buf[:rd])) } } } }
主干代碼是比較容易理解的,但是用golang使用epoll 時有幾個點 需要注意下:
第一點是IsReadableEvent 的判斷方式,epoll的每個event 都有一個位掩碼,位掩碼是什么意思呢?比如EPOLLIN 的值 是0x1,二進制就是00000001,EPOLLHUP 的值是0x10,二進制表示是00010000,那么epoll wait系統(tǒng)調(diào)用的event要如何同時表示同一個文件描述符同時擁有這兩個事件呢? epoll 的event會將對應的位掩碼設置為和對應事件一致,比如同時擁有EPOLLIN和EPOLLHUP,那么event的值將會是00010001,所以利用與位運算是不是就能判斷event是否具有某個事件了。因為1只有與1進行與運算結果才為1。
func IsReadableEvent(event uint32) bool { if event&syscall.EPOLLIN != 0 { return true } return false }
第二點是如何讀取連接的數(shù)據(jù), 我們后續(xù)要達到的目的是在同一個事件循環(huán)里能處理多個連接,所以要保證讀取連接中的數(shù)據(jù)時不能阻塞,通過調(diào)用golang的net.Conn下的read方法是阻塞的,其read實現(xiàn)最終會調(diào)用到下面的這個方法。
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 := ignoringEINTRIO(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循環(huán)中判斷系統(tǒng)調(diào)用syscall.Read 的返回,如果是syscall.EAGAIN 那么會讓當前協(xié)程睡眠,等待被喚醒。
syscall.EAGAIN 錯誤是在非阻塞io進行讀寫時才有可能產(chǎn)生的,在讀取數(shù)據(jù)時,如果發(fā)現(xiàn)讀緩沖區(qū)沒有數(shù)據(jù)到達,則返回這個syscall.EAGAIN錯誤,在寫入數(shù)據(jù)時,如果寫緩沖區(qū)滿了,也會返回這個錯誤。
既然golang的net.Conn下的read方法是阻塞的,那么我們就自己實現(xiàn)下conn的Read方法。
func (c *Conn) Read(p []byte) (n int, err error) { rawConn, err := c.conn.SyscallConn() if err != nil { return 0, err } rawConn.Read(func(fd uintptr) (done bool) { n, err = syscall.Read(int(fd), p) if err != nil { return true } return true }) return }
的Read方法是我們自定義的Conn類型實現(xiàn)的Read方法,原生的連接類型是net.Conn,它有一個SyscallConn 能夠獲取到更加底層的連接類型,從這個類型能夠獲取到該網(wǎng)絡連接的文件描述符fd,我們通過直接調(diào)用系統(tǒng)調(diào)用syscall.Read來從該網(wǎng)絡連接讀取數(shù)據(jù)。 并且碰到錯誤則直接返回。后續(xù) syscall.EAGAIN錯誤會交給上層handler方法去進行處理。
總結
這節(jié)算是用golang去演示了下如何對epoll api的調(diào)用,并且能夠實現(xiàn)最簡單的客戶端服務端通信,下一節(jié)我會講解redis的網(wǎng)絡模型是怎么樣的,你可以從中了解到經(jīng)常說的redis的單線程具體是指什么,了解到reactor網(wǎng)絡模型是怎樣的?
到此這篇關于Golang基于epoll實現(xiàn)最簡單網(wǎng)絡通信框架的文章就介紹到這了,更多相關Golang epoll網(wǎng)絡通信框架內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Go語言的變量、函數(shù)、Socks5代理服務器示例詳解
這篇文章主要介紹了Go語言的變量、函數(shù)、Socks5代理服務器的相關資料,需要的朋友可以參考下2017-09-09