Golang基于epoll實(shí)現(xiàn)最簡(jiǎn)單網(wǎng)絡(luò)通信框架
系列源碼已經(jīng)上傳github:https://github.com/HobbyBear/tinyredis/tree/chapter1
redis的網(wǎng)絡(luò)模型是基于epoll實(shí)現(xiàn)的,所以這一節(jié)讓我們先基于epoll,實(shí)現(xiàn)一個(gè)最簡(jiǎn)單的服務(wù)端客戶端通信模型。在實(shí)現(xiàn)前,先來(lái)簡(jiǎn)單的了解下epoll的原理。
為什么不用golang的原生的netpoll網(wǎng)絡(luò)框架呢,這是因?yàn)閚etpoll框架雖然底層也是基于epoll實(shí)現(xiàn),但是它提供給開(kāi)發(fā)人員使用網(wǎng)絡(luò)io方式依然是同步阻塞模式,一個(gè)連接單獨(dú)的拿給一個(gè)協(xié)程去處理,為了更加真實(shí)的感受下redis的網(wǎng)絡(luò)模型,我們不用netpoll框架,而是自己寫(xiě)一個(gè)非阻塞的網(wǎng)絡(luò)模型。
epoll 網(wǎng)絡(luò)通信原理
通常情況下服務(wù)端的處理客戶端請(qǐng)求的邏輯是客戶端每發(fā)起一個(gè)連接,服務(wù)端就單獨(dú)起一個(gè)線程去處理這個(gè)連接的請(qǐng)求,對(duì)于go應(yīng)用程序而言,則是啟用一個(gè)協(xié)程去處理這個(gè)連接。 而采用epoll相關(guān)的api后,能夠讓我們?cè)谝粋€(gè)線程或者協(xié)程里去處理多個(gè)連接的請(qǐng)求。
一個(gè)套接字連接對(duì)應(yīng)一個(gè)文件描述符,當(dāng)收到客戶端的連接請(qǐng)求時(shí),可以將對(duì)應(yīng)的文件描述符加入到epoll實(shí)例關(guān)注的事件中去。
在golang里,可以通過(guò)syscall.EpollCreate1 去創(chuàng)建一個(gè)epoll實(shí)例。
func EpollCreate1(flag int) (fd int, err error)
其返回結(jié)果的fd就代表epoll實(shí)例的fd,當(dāng)收到客戶端的連接請(qǐng)求時(shí),便可以將客戶端連接的fd,通過(guò)EpollCtl 加入到epoll實(shí)例感興趣的事件當(dāng)中。
func EpollCtl(epfd int, op int, fd int, event *EpollEvent) (err error)
EpollCtl 方法參數(shù)的epfd則是EpollCreate1 返回的fd,EpollCtl的第二個(gè)參數(shù)則是代表客戶端連接的fd,通過(guò)我們?cè)讷@取到客戶端連接后,后續(xù)的行為便是查看客戶端是否有數(shù)據(jù)發(fā)送過(guò)來(lái)或者往客戶端發(fā)送數(shù)據(jù),這些在epoll api里用event事件去表示,分別對(duì)應(yīng)了讀event和寫(xiě)event,這便是EpollCtl第三個(gè)參數(shù)所代表的含義。
將這些感興趣事件添加到epoll實(shí)例中后,就代表epoll實(shí)例后續(xù)會(huì)監(jiān)聽(tīng)這些連接的讀寫(xiě)事件的到達(dá),那么讀寫(xiě)事件到達(dá)后,用戶程序又是如何知道的呢,這就要提到epoll相關(guān)的另一個(gè)api,EpollWait。
func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error)
EpollWait的第二個(gè)參數(shù)是一個(gè)事件數(shù)組,用戶應(yīng)用程序調(diào)用EpollWait時(shí)傳入一個(gè)固定長(zhǎng)度的事件數(shù)組,然后EpollWait會(huì)將這個(gè)數(shù)組盡可能填滿,這樣用戶程序便能知道有哪些事件類型到達(dá)了,EpollEvent類型如下所示:
type EpollEvent struct {
Events uint32
Fd int32
Pad int32
}其中fd則代表這些事件所關(guān)聯(lián)的客戶端連接的fd,通過(guò)這個(gè)fd,我們便可以對(duì)對(duì)應(yīng)連接進(jìn)行讀寫(xiě)操作了。
而Events是個(gè)枚舉類型,比較常用的枚舉以及含義如下:
| 類型 | 解釋 |
|---|---|
| EPOLLIN | 表示文件描述符可讀。 |
| EPOLLRDHUP | 表示 TCP 連接的遠(yuǎn)程端點(diǎn)關(guān)閉或半關(guān)閉連接 |
| EPOLLET | 表示使用邊緣觸發(fā)模式來(lái)監(jiān)聽(tīng)事件 |
| EPOLLOUT | 表示文件描述符可寫(xiě) |
| EPOLLERR | 表示文件描述符發(fā)生錯(cuò)誤時(shí)發(fā)生,這個(gè)事件不通過(guò)EpollCtl添加也能觸發(fā) |
| EPOLLHUP | 與EPOLLRDHUP類似同樣表示連接關(guān)閉,在不支持EPOLLRDHUP的linux版本會(huì)觸發(fā),這個(gè)事件不通過(guò)EpollCtl添加也能觸發(fā) |
雖然epoll event還有其他類型,不過(guò)一般情況下監(jiān)控這幾種類型就足夠了,golang的netpoll框架在添加連接的文件描述符時(shí)事件時(shí)也只添加了這幾種類型。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)絡(luò)框架
了解完epoll的一些概念以后,現(xiàn)在來(lái)看下我們需要實(shí)現(xiàn)的網(wǎng)絡(luò)框架模型是怎樣的。我們先實(shí)現(xiàn)一個(gè)最簡(jiǎn)單的網(wǎng)絡(luò)通信框架,客戶端發(fā)送來(lái)消息,然后服務(wù)端打印收到的消息。

如上圖所示,我們收到新的連接后,會(huì)調(diào)用epoll實(shí)例的EpollCtl方法將連接的可讀事件添加到epoll實(shí)例中,接著調(diào)用EpollWait方法等待客戶端再次發(fā)送消息時(shí),讓連接變?yōu)榭勺x。
下面是程序的效果測(cè)試結(jié)果
效果測(cè)試

啟動(dòng)了兩個(gè)終端,其中右邊的終端連接上redis以后,發(fā)送了1231,然后左邊的終端收到后將收到的消息打印出來(lái)。
go代碼實(shí)現(xiàn)
接著,我們來(lái)看看實(shí)際代碼編寫(xiě)邏輯。
我們定義一個(gè)Server的結(jié)構(gòu)體來(lái)代表epoll的server。
Conn是對(duì)golang原生連接類型net.Conn的包裝,。
poll結(jié)構(gòu)體是封裝了對(duì)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
}接著來(lái)看下如何啟動(dòng)一個(gè)Server,NewServer是返回一個(gè)Server實(shí)例,Server 調(diào)用Run方法后,才算Server正式啟動(dòng)了起來(lái)。
在Run 方法里,構(gòu)建監(jiān)聽(tīng)連接的listener,構(gòu)建一個(gè)epoll實(shí)例,用于后續(xù)對(duì)事件的監(jiān)聽(tīng),同時(shí)把監(jiān)聽(tīng)握手連接和處理連接可讀數(shù)據(jù)分成了兩個(gè)協(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í)行的邏輯就是將握手完成的鏈接從全連接隊(duì)列里取出來(lái),將其連接的文件描述符和連接存儲(chǔ)到一個(gè)map里, 然后將對(duì)應(yīng)的文件描述符通過(guò)epoll的epollCtl 系統(tǒng)調(diào)用監(jiān)聽(tīng)它的可讀事件,后續(xù)客戶端再使用這個(gè)連接發(fā)送數(shù)據(jù)時(shí),epoll就能監(jiān)聽(tīng)到了。
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)
})
// 設(shè)置為非阻塞狀態(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里的邏輯則是通過(guò)epoll Wait系統(tǒng)調(diào)用等待可讀事件產(chǎn)生,到達(dá)后,根據(jù)事件的文件描述符找到對(duì)應(yīng)連接,然后讀取對(duì)應(yīng)連接的數(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 時(shí)有幾個(gè)點(diǎn) 需要注意下:
第一點(diǎn)是IsReadableEvent 的判斷方式,epoll的每個(gè)event 都有一個(gè)位掩碼,位掩碼是什么意思呢?比如EPOLLIN 的值 是0x1,二進(jìn)制就是00000001,EPOLLHUP 的值是0x10,二進(jìn)制表示是00010000,那么epoll wait系統(tǒng)調(diào)用的event要如何同時(shí)表示同一個(gè)文件描述符同時(shí)擁有這兩個(gè)事件呢? epoll 的event會(huì)將對(duì)應(yīng)的位掩碼設(shè)置為和對(duì)應(yīng)事件一致,比如同時(shí)擁有EPOLLIN和EPOLLHUP,那么event的值將會(huì)是00010001,所以利用與位運(yùn)算是不是就能判斷event是否具有某個(gè)事件了。因?yàn)?只有與1進(jìn)行與運(yùn)算結(jié)果才為1。
func IsReadableEvent(event uint32) bool {
if event&syscall.EPOLLIN != 0 {
return true
}
return false
}第二點(diǎn)是如何讀取連接的數(shù)據(jù), 我們后續(xù)要達(dá)到的目的是在同一個(gè)事件循環(huán)里能處理多個(gè)連接,所以要保證讀取連接中的數(shù)據(jù)時(shí)不能阻塞,通過(guò)調(diào)用golang的net.Conn下的read方法是阻塞的,其read實(shí)現(xiàn)最終會(huì)調(diào)用到下面的這個(gè)方法。
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
}
}這個(gè)方法會(huì)在for循環(huán)中判斷系統(tǒng)調(diào)用syscall.Read 的返回,如果是syscall.EAGAIN 那么會(huì)讓當(dāng)前協(xié)程睡眠,等待被喚醒。
syscall.EAGAIN 錯(cuò)誤是在非阻塞io進(jìn)行讀寫(xiě)時(shí)才有可能產(chǎn)生的,在讀取數(shù)據(jù)時(shí),如果發(fā)現(xiàn)讀緩沖區(qū)沒(méi)有數(shù)據(jù)到達(dá),則返回這個(gè)syscall.EAGAIN錯(cuò)誤,在寫(xiě)入數(shù)據(jù)時(shí),如果寫(xiě)緩沖區(qū)滿了,也會(huì)返回這個(gè)錯(cuò)誤。
既然golang的net.Conn下的read方法是阻塞的,那么我們就自己實(shí)現(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類型實(shí)現(xiàn)的Read方法,原生的連接類型是net.Conn,它有一個(gè)SyscallConn 能夠獲取到更加底層的連接類型,從這個(gè)類型能夠獲取到該網(wǎng)絡(luò)連接的文件描述符fd,我們通過(guò)直接調(diào)用系統(tǒng)調(diào)用syscall.Read來(lái)從該網(wǎng)絡(luò)連接讀取數(shù)據(jù)。 并且碰到錯(cuò)誤則直接返回。后續(xù) syscall.EAGAIN錯(cuò)誤會(huì)交給上層handler方法去進(jìn)行處理。
總結(jié)
這節(jié)算是用golang去演示了下如何對(duì)epoll api的調(diào)用,并且能夠?qū)崿F(xiàn)最簡(jiǎn)單的客戶端服務(wù)端通信,下一節(jié)我會(huì)講解redis的網(wǎng)絡(luò)模型是怎么樣的,你可以從中了解到經(jīng)常說(shuō)的redis的單線程具體是指什么,了解到reactor網(wǎng)絡(luò)模型是怎樣的?
到此這篇關(guān)于Golang基于epoll實(shí)現(xiàn)最簡(jiǎn)單網(wǎng)絡(luò)通信框架的文章就介紹到這了,更多相關(guān)Golang epoll網(wǎng)絡(luò)通信框架內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
如何使用Golang發(fā)送Get和Post請(qǐng)求
這篇文章主要給大家介紹了關(guān)于如何使用Golang發(fā)送Get和Post請(qǐng)求的相關(guān)資料,Go語(yǔ)言(Golang)的標(biāo)準(zhǔn)庫(kù)提供了處理HTTP請(qǐng)求的功能,這使得將Go用于web應(yīng)用程序變得非常容易,需要的朋友可以參考下2023-06-06
詳解golang函數(shù)多返回值錯(cuò)誤處理與error類型
這篇文章主要為大家詳細(xì)介紹了golang中函數(shù)多返回值錯(cuò)誤處理與error類型的相關(guān)知識(shí),文中的示例代碼簡(jiǎn)潔易懂,感興趣的小伙伴快跟隨小編一起學(xué)習(xí)吧2023-10-10
使用Go語(yǔ)言開(kāi)發(fā)一個(gè)命令行文件管理工具
Golang教程之不可重入函數(shù)的實(shí)現(xiàn)方法

