???📚?博主的專欄
🐧?Linux???|?? 🖥??C++???|?? 📊?數據結構??|?💡C++ 算法?|?🅒?C 語言? |?🌐?計算機網絡
上篇文章:五種IO模型與阻塞IO以及多路轉接select機制編寫echoserver
下篇文章:?利用多路轉接epoll機制、ET模式,基于Reactor設計模式實現EchoServer
摘要:本文全面剖析Linux中epoll機制的原理與實現,詳解其核心接口(
epoll_create
、epoll_ctl
、epoll_wait
)及底層數據結構(紅黑樹與就緒隊列),對比水平觸發(LT)與邊緣觸發(ET)模式的工作機制與適用場景。通過編寫基于epoll的服務器實例,演示如何高效管理連接與數據讀寫,并深入探討ET模式下非阻塞文件描述符的必要性。文章還對比select、poll與epoll的性能差異,強調epoll在高并發場景中的優勢(無數量限制、事件回調機制、低內存拷貝開銷)。最后,指出epoll的局限性與優化方向,為開發高性能服務器提供實踐指導。
目錄
epoll 的作用與定位
epoll接口
1.?epoll_create:創建 epoll 實例
2.?epoll_ctl:管理監控的文件描述符
示例
3.?epoll_wait:等待事件就緒
epoll的工作原理
1.理解數據怎樣到達主機
2.epoll原理
3. 工作流程
在內核當中,如何管理epoll
編寫EpollServer實驗echo_server1.0:
EpollServer.hpp1.0
處理事件HandlerEvent()
紅黑樹是如何提高epoll效率的
處理就緒的讀事件(listen套接字的讀事件)
處理普通套接字的讀寫事件
總結, epoll 的使用過程三部曲:
epoll 的優點(和 select 的缺點對應)
對比總結 select, poll, epoll 之間的優點和缺點
epoll 的工作方式邊緣觸發(ET)和水平觸發(LT)模式:
理解 ET 模式和非阻塞文件描述符
對比 LT 和 ET
應用場景:
本篇文章的完整代碼:epollserver-echoserver
epoll 的作用與定位
epoll 的作用
epoll 用于監控多個文件描述符(fd),當這些 fd 上出現新的事件并準備就緒時,epoll 會通知程序,此時可以進行 IO 數據拷貝操作。
epoll 的定位
epoll 的核心職責是事件監控與就緒通知,它僅負責等待事件就緒,并在就緒后完成事件派發。
epoll接口
1.?epoll_create
:創建 epoll 實例
#include <sys/epoll.h>int epoll_create(int size);????????? // 傳統接口(已過時)
int epoll_create1(int flags);??????? // 現代接口(推薦使用)
功能
-
創建一個新的?epoll 實例,返回一個文件描述符(
epfd
),后續所有操作均基于此描述符。
參數
size
(傳統接口):
早期用于指定內核預分配的監控文件描述符數量,但內核會動態調整,實際已廢棄,傳入任意正整數均可(通常填?
1
)。
flags
(現代接口?epoll_create1
):
標志位,常用?
0
(默認行為)或?EPOLL_CLOEXEC
(設置文件描述符在執行?exec
?時自動關閉)。
返回值
成功:返回?
epfd
(epoll 實例的文件描述符)。失敗:返回?
-1
,錯誤碼在?errno
?中。
示例
int epfd = epoll_create1(0);
if (epfd == -1) {perror("epoll_create1 failed");exit(EXIT_FAILURE);
}
2.?epoll_ctl
:管理監控的文件描述符
#include <sys/epoll.h>int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能
向?
epoll
?實例中添加、修改或刪除需要監控的文件描述符(fd
)。
參數
epfd
:epoll_create
?返回的 epoll 實例描述符。
op
:操作類型:
EPOLL_CTL_ADD
:添加?fd
?到監控列表。
EPOLL_CTL_MOD
:修改?fd
?的監控事件。
EPOLL_CTL_DEL
:從監控列表中刪除?fd
。
fd
:需要監控的目標文件描述符(如 socket)。
event
:指向?struct epoll_event
?的指針,描述監控的事件類型和用戶數據。
struct epoll_event
?結構體typedef union epoll_data {void??? *ptr;???? // 用戶自定義指針(靈活,但需手動管理)int????? fd;????? // 關聯的文件描述符(常用)uint32_t u32;???? // 32位整數uint64_t u64;???? // 64位整數 } epoll_data_t;struct epoll_event {uint32_t???? events;??? // 監控的事件集合(位掩碼)epoll_data_t data;????? // 用戶數據(事件觸發時返回) };
events
?字段的常用標志
標志 | 說明 |
---|---|
| 文件描述符可讀(如 socket 接收緩沖區有數據) |
| 文件描述符可寫(如 socket 發送緩沖區有空間) |
| 文件描述符發生錯誤(自動監控,無需顯式設置) |
| 對端關閉連接(如 TCP 連接被關閉) |
| 設置為邊緣觸發(ET)模式(默認是水平觸發 LT) |
| 事件觸發后自動從監控列表移除,需重新添加(避免多線程重復處理) |
返回值
成功:返回?
0
。失敗:返回?
-1
,錯誤碼在?errno
?中(如?EBADF
、EINVAL
?等)。
示例
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 監聽可讀事件,使用 ET 模式
ev.data.fd = sockfd; // 關聯 socket 文件描述符if (epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev) == -1) {perror("epoll_ctl ADD failed");close(sockfd);
}
3.?epoll_wait
:等待事件就緒
#include <sys/epoll.h>int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能
阻塞等待,直到監控的文件描述符中有事件就緒,或超時發生。
參數
epfd
:epoll 實例描述符。
events
:指向?epoll_event
?數組的指針,用于接收就緒事件。
maxevents
:events
?數組的最大容量(必須大于 0)。
timeout
:超時時間(毫秒):
-1
:永久阻塞,直到事件發生。
0
:立即返回,非阻塞模式。
>0
:等待指定毫秒數。
返回值
成功:返回?就緒事件的數量(填充到?
events
?數組中)。失敗:返回?
-1
,錯誤碼在?errno
?中(如?EINTR
?被信號中斷)。
示例
#define MAX_EVENTS 10
struct epoll_event events[MAX_EVENTS];int n = epoll_wait(epfd, events, MAX_EVENTS, 1000); // 等待 1 秒
if (n == -1) {perror("epoll_wait failed");
} else if (n == 0) {printf("Timeout!\n");
} else {for (int i = 0; i < n; i++) {if (events[i].events & EPOLLIN) {// 處理可讀事件handle_read(events[i].data.fd);}if (events[i].events & EPOLLERR) {// 處理錯誤close(events[i].data.fd);}}
}
epoll的工作原理
1.理解數據怎樣到達主機
數據包通過網絡接口卡(NIC,Network Interface Card)被接收后,OS怎么知道網卡中有數據?操作系統(OS)通過硬件中斷機制來感知網卡中是否有數據到達。
具體來說,當網卡接收到數據包時,它會觸發一個硬件中斷信號,通知操作系統有數據需要處理。以下是這一過程的詳細說明:
網卡接收數據:當網絡數據包到達網卡時,網卡會將這些數據存儲在它的接收緩沖區(Rx Buffer)中。
觸發硬件中斷:網卡在數據存儲完成后,會向CPU發送一個硬件中斷信號(Interrupt Request, IRQ)。這個中斷信號是通過主板上的中斷控制器(如APIC或PIC)傳遞給CPU的。
中斷處理程序(ISR):CPU接收到中斷信號后,會暫停當前正在執行的任務,并根據中斷向量表(Interrupt Vector Table)找到對應的中斷處理程序(Interrupt Service Routine, ISR)。對于網卡中斷,OS會調用專門的中斷處理程序來處理網卡的數據。
讀取數據:在中斷處理程序中,操作系統會從網卡的接收緩沖區中讀取數據,并將其傳遞給網絡協議棧(如TCP/IP協議棧)進行進一步處理。
中斷結束:數據處理完成后,中斷處理程序會通知中斷控制器中斷處理已完成,CPU可以繼續執行之前被中斷的任務。
2.epoll原理
epoll 采用了事件驅動模型,其高效性主要源于以下設計:
- 紅黑樹結構: epoll 使用紅黑樹來存儲所有被監聽的文件描述符(紅黑樹的key),使得添加、刪除和查找操作的時間復雜度為 O(log n)。
- 就緒隊列: 當某個文件描述符的事件發生時,內核會將其放入就緒隊列(底層一旦有用戶關心的數據就緒了,“構建”就緒隊列的節點,寫清楚,什么是fd,什么事件就緒了,并鏈入就緒隊列),
epoll_wait
?只需從該隊列中獲取就緒的文件描述符,而不需要遍歷所有監聽的文件描述符。采用雙向鏈表存儲已就緒的fd,實現快速事件通知。- 邊緣觸發(ET)和水平觸發(LT)模式:
模式
觸發條件 特點 水平觸發(LT) 緩沖區有數據即觸發 類似poll機制 邊緣觸發(ET) 僅當狀態變化時觸發 需一次性讀取數據
3. 工作流程
注冊階段:通過
epoll_ctl
將fd加入紅黑樹時,內核:
- 為每個fd設置回調函數
ep_poll_callback
- 綁定fd到設備等待隊列
事件觸發:
- 當設備數據到達時,硬件產生中斷
- 內核中斷處理程序調用回調函數
- 回調函數將對應fd加入就緒隊列
事件獲取:
epoll_wait
檢查就緒隊列是否為空- 非空時直接返回就緒事件,時間復雜度O(1)
在內核當中,如何管理epoll
epoll模型是需要被OS內核管理的,先描述,再組織。它由操作系統內核進行管理,通過一個核心的數據結構
struct eventpoll
來實現事件的管理和通知。這個結構體在epoll
模型中扮演著關鍵角色,它負責維護兩個重要的數據結構:就緒隊列和紅黑樹。
?
紅黑樹中每個節點都是基于epitem結構中的rdllink成員與rbn成員的意思是?
在紅黑樹的實現中,每個節點通常包含多個成員變量,用于維護樹的結構和節點的屬性。具體到
epitem
結構中的rdllink
成員和rbn
成員,它們在紅黑樹中有特定的作用:
rbn
成員:
rbn
是紅黑樹節點的核心成員,通常是一個結構體或聯合體,包含了紅黑樹節點的關鍵信息。- 它通常包括以下字段:
color
:表示節點的顏色(紅色或黑色),用于維護紅黑樹的平衡性。parent
:指向父節點的指針,用于在樹中進行向上遍歷。left
?和?right
:分別指向左子節點和右子節點的指針,用于維護樹的結構。rbn
成員的作用是將epitem
結構嵌入到紅黑樹中,使得epitem
能夠作為紅黑樹的一個節點存在,并參與紅黑樹的插入、刪除和查找等操作。
rdllink
成員:
rdllink
通常是一個雙向鏈表的節點結構,用于將epitem
節點連接到一個雙向鏈表中。- 它通常包括以下字段:
prev
:指向前一個節點的指針。next
:指向后一個節點的指針。rdllink
成員的作用是將epitem
節點組織成一個雙向鏈表,這種結構常用于實現事件循環或事件隊列等場景。通過rdllink
,可以方便地對epitem
節點進行遍歷、插入和刪除操作。總結來說,
rbn
成員用于將epitem
結構嵌入到紅黑樹中,使其能夠作為紅黑樹的一個節點參與樹的平衡和維護;而rdllink
成員則用于將epitem
節點組織成一個雙向鏈表,便于在事件處理等場景中進行快速訪問和操作。兩者共同協作,使得epitem
結構既能高效地參與紅黑樹的動態平衡,又能方便地參與到其他數據結構的操作中。
? 當某一進程調用 epoll_create 方法時, Linux 內核會創建一個 eventpoll 結構體, 這個結構體中有的就緒隊列和紅黑樹與 epoll 的使用方式密切相關.
struct eventpoll
結構體通過兩個指針分別指向就緒隊列和紅黑樹:
rdllist
指針:指向就緒隊列的鏈表頭,用于快速獲取已經就緒的文件描述符。rbr
指針:指向紅黑樹的根節點,用于管理所有被監控的文件描述符。這種設計使得
epoll
模型能夠高效地處理大量并發連接。例如,在網絡服務器場景中,epoll
可以同時監控成千上萬的客戶端連接,當某個連接有數據到達時,內核會將其添加到就緒隊列中,用戶程序通過epoll_wait
可以立即獲取到這些連接并進行處理,而無需輪詢所有連接,從而顯著提高了系統的性能和響應速度。
編寫EpollServer實驗echo_server1.0:
準備好以下文件,可以在我的gitee獲取點擊鏈接,詳細講解可以看我之前的博客
.
├── EpollServer.hpp
├── InetAddr.hpp
├── LockGuard.hpp
├── Log.hpp
├── Main.cc
├── Makefile
└── Socket.hpp
EpollServer.hpp1.0
在服務器編程中,首先創建一個監聽套接字?
listensock
,綁定到特定 IP 和端口,并設置為監聽狀態。接著,通過?epoll_create()
?創建 epoll 實例,用于高效管理多個文件描述符的事件。使用?epoll_ctl()
?將?listensock
?添加到 epoll 監控列表,關聯?EPOLLIN
?事件以監控新連接請求。epoll 持續監控?listensock
,并在有新連接時通知應用程序。應用程序通過?epoll_wait()
?等待事件,檢測到?EPOLLIN
?事件時調用?accept()
?接受新連接,并將新客戶端套接字加入 epoll 監控列表,處理后續數據通信。
#pragma once #include <string> #include <iostream> #include <memory> #include <sys/epoll.h> #include "Log.hpp" #include "Socket.hpp" using namespace socket_ns; class EpollServer {const static int size = 128;const static int num = 128;public:EpollServer(uint16_t port) : _port(port), _listensock(std::make_unique<TcpSocket>()){_listensock->BuildListenSocket(port);_epfd = ::epoll_create(size);if (_epfd < 0){LOG(FATAL, "epoll create error\n");exit(1);}LOG(INFO, "epoll create success, epfd: %d\n", _epfd); // 4}void InitServer(){// 在獲取連接前,需要先把listen套接字添加到epoll模型里(使用epoll_ctl)// 構建好epoll_event結構體struct epoll_event ev;// 新連接到來,需要關注的是讀事件就緒ev.events = EPOLLIN;ev.data.fd = _listensock->Sockfd();// 關心listen套接字的ev事件int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Sockfd(), &ev);if (n < 0){LOG(FATAL, "epoll_ctl error!\n");exit(2);}LOG(INFO, "epoll_ctl success! add new sockfd: %d\n", _listensock->Sockfd());}void Loop(){int timeout = 1000;while (true){// 通過epoll等待事件就緒后才能獲取連接int n = ::epoll_wait(_epfd, revs, num, timeout); // 每隔1sswitch (n){case 0:LOG(INFO, "epoll time out...\n");break;case -1:LOG(ERROR, "epoll error...\n"); // 不會出現break;default:LOG(INFO, "haved event happend!, n: %d\n", n);break;}}}~EpollServer(){if (_epfd >= 0)::close(_epfd);_listensock->Close();}private:uint16_t _port;std::unique_ptr<Socket> _listensock;int _epfd;struct epoll_event revs[num]; };
運行結果:?這里我的timeout設置為1000ms也就是每等待1s監控是否有事件就緒。因為我并沒有訪問因此不斷地顯示超時。
?
接下來我將timeout設置為0,也就是非阻塞等待:只要沒有就會一直檢測并且發出時間超時的信號
?
將timeout設置為-1,也就是阻塞等待,直到有事件就緒。?
?
使用瀏覽器,或者telnet訪問服務器:事件就緒,n = 1,就緒事件為1
?
為什么會陷入死循環呢,因為事件就緒,但是并沒有處理事件,同Select和poll(未將該文件描述符從就緒集合中移除,或者未重置其狀態)。
在epoll:
事件被組織在一棵紅黑樹中,當某個事件被觸發時,對應的節點會被標記為“激活”狀態,并放入就緒隊列中等待處理。如果上層應用程序未能及時從就緒隊列中取出并處理該事件,或者未將節點的激活狀態重置為未激活狀態,那么該事件會一直保留在就緒隊列中,導致?
epoll
?在下一次事件檢測時再次返回該事件,從而形成死循環。在使用?epoll
?時,可以通過調用?epoll_ctl
?將文件描述符從紅黑樹中移除,或者通過正確處理事件來避免重復觸發。
因此接下代碼編寫處理事件:
處理事件HandlerEvent()
需要明確:在我的代碼里,就緒事件在revs這個數組里,就緒事件的個數為epoll_wait的返回值n,就緒事件在revs中,這些事件會按照順序依次存放在?
revs
?數組中,從索引?0
?開始,直到索引?n-1
?結束。因此在遍歷的時候我們只需要遍歷就緒事件的個數次:
?
std::string EventsToString(u_int32_t events){std::string eventsstr;if (events & EPOLLIN)eventsstr = "EPOLLIN ";if (events & EPOLLOUT)eventsstr += "| EPOLLOUT";return eventsstr;}void HandlerEvent(int n){for (int i = 0; i < n; i++){int fd = revs[i].data.fd;uint32_t revents = revs[i].events;// 因為revents只是一個簡單的標志位,因此我們設計一個接口能直接看到是什么事件LOG(INFO, "%d 上有事件就緒了,具體事件是:%s\n", fd, EventsToString(revents).c_str());}}
運行結果:上面的代碼將我哪個fd上的什么就緒的事件打印出,方便觀察,我通過telnet訪問我的服務器,因此監聽事件、代表有新連接到來連接,listen套接字的讀事件就緒,因此我們接下來要獲取連接。(在之后會有普通套接字就緒,我們的處理方式又會不同)
?
如何判斷是listen套接字的事件就緒還是普通套接字的事件就緒可以通過fd比較的方式直接判斷
在設置事件的時候,我們都會設置好listen套接字,在Tcp當中,當客戶端發起連接請求時,監聽套接字會變得可讀,epoll_wait()函數會立即返回,并將該監聽套接字的文件描述符放入就緒隊列(ready list)中。由于監聽套接字是服務器最先創建和監控的文件描述符,因此在epoll模型中,它通常是就緒隊列和紅黑樹節點中第一個就緒的文件描述符。
因此這樣判斷:
if(revs[i].data.fd == _listensock->Sockfd())
通過
listen
套接字獲取連接后,會獲得一個新的sockfd
。對于這個新的sockfd
,我們無需等待寫操作的就緒狀態,因為它天生就具備寫事件就緒,因此就可以send、write。然而,此時并不能進行讀操作,因為我們無法確定對方是否已經發送了數據。如果沒有數據發送,讀操作將會被阻塞,因此此時不能read、recv。
epoll清楚底層有數據,因此需要將新的sockfd(也就是普通sockfd)添加到epoll中,通過之后的監管,就能知道什么事件就緒。
紅黑樹是如何提高epoll效率的
epoll 的內部實現依賴于紅黑樹(Red-Black Tree)這一數據結構來管理監控的文件描述符。紅黑樹是一種自平衡的二叉搜索樹,具有以下特點:
- 查找效率高:紅黑樹的查找、插入和刪除操作的時間復雜度均為 O(log n),這使得它在處理大量文件描述符時依然能夠保持高效。
- 近似平衡:與 AVL 樹相比,紅黑樹的平衡條件相對寬松,雖然不如 AVL 樹嚴格平衡,但在實際應用中,紅黑樹的性能表現更為優越,尤其是在頻繁插入和刪除的場景下。
- 自動維護:紅黑樹在插入或刪除節點后會自動調整結構以保持平衡,無需程序員手動干預。這種自動維護的特性大大簡化了開發者的工作。
相比之下,傳統的 Select 和 Poll 機制使用輔助數組來管理文件描述符,存在以下問題:
- 手動維護:程序員需要手動維護輔助數組,包括添加、刪除和更新文件描述符,這不僅增加了代碼復雜度,還容易引入錯誤。
- 效率低下:Select 和 Poll 需要遍歷整個數組來檢查每個文件描述符的狀態,時間復雜度為 O(n),當監控的文件描述符數量較大時,性能會顯著下降。
- 擴展性差:由于輔助數組的大小通常固定,當需要監控的文件描述符數量超過數組容量時,程序可能無法正常工作。
因此,epoll 通過紅黑樹的高效管理和自動維護特性,顯著提升了 I/O 多路復用的性能和易用性,尤其適用于高并發場景。而 Select 和 Poll 的輔助數組則顯得笨重且低效,逐漸被 epoll 所取代。
處理就緒的讀事件(listen套接字的讀事件)
void HandlerEvent(int n){for (int i = 0; i < n; i++){int fd = revs[i].data.fd;uint32_t revents = revs[i].events;// 因為revents只是一個簡單的標志位,因此我們設計一個接口能直接看到是什么事件LOG(INFO, "%d 上有事件就緒了,具體事件是:%s\n", fd, EventsToString(revents).c_str());sleep(3);if (fd == _listensock->Sockfd()){InetAddr addr;int sockfd = _listensock->Accepter(&addr);if (sockfd < 0){LOG(ERROR, "獲取連接失敗\n");continue;}LOG(INFO, "得到了一個新的連接: %d, 客戶端信息:%s:%d\n", sockfd, addr.Ip().c_str(), addr.Port());// 得到了一個新的sockfd,我們需不需要等待寫的就緒,不需要,一個新的sockfd,先天就具備寫事件就緒// 等底層有數據(讀時間就緒),read/recv才不會被阻塞// epoll清楚底層有數據// 因此先將新的sockfd添加到epoll中,通過之后的監管,就能知道什么事件就緒struct epoll_event ev;ev.data.fd = sockfd;ev.events = EPOLLIN;::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);LOG(INFO, "epoll_ctl success! add new sockfd: %d\n", sockfd);}}}
代碼:實際上這里,我們只是用telnet和瀏覽器連服務器,按理來說telnet連接后獲取到的fd是5,瀏覽器連接后獲取到的fd是6,出現7的原因是,瀏覽器一旦連接了服務器不僅有一個新返回的讀事件就緒,瀏覽器還會向服務器發送數據,但是由于我們還未處理寫事件,因此寫事件一直處于就緒狀態,就陷入了死循環。
?
接下來處理普通套接字當中的事件:
處理普通套接字的讀寫事件
當客戶端退出連接:
關閉連接時產生的套接字描述符,從epoll模型中移除該文件描述符?先后順序是什么?
明確:要從epoll中移除一個fd,需要保證該fd是健康且合法的,否則會移除出錯:這意味著在移除之前,fd仍然是一個有效的、未被關閉的描述符。
從epoll中移除文件描述符(fd)
先使用
epoll_ctl
系統調用,將fd從epoll實例中移除。通常使用EPOLL_CTL_DEL
操作。確保了epoll不再監控該fd,避免在fd關閉后epoll仍然嘗試處理該fd的事件。epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);//紅黑樹底層是用的fd做的鍵值,因此并不需要寫清楚事件是什么
關閉套接字描述符(fd):
在確認fd已經從epoll中移除后,調用
close
函數關閉該fdelse // 處理其他套接字 {char buffer[4096];// 不會阻塞,因為讀事件就緒int n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);if (n > 0){buffer[n] = 0;std::cout << buffer;// 構建一個簡單的應答std::string response = "HTTP/1.0 200 OK\r\n";std::string content = "<html><body><h1>hello pupu, hello epoll</h1></body></html>";response += "Content-Type: text/html\r\n";response += "Content-Length: " + std::to_string(content.size()) + "\r\n";response += "\r\n";response += content;// 發送緩沖區有空間,寫事件就就緒,不會被阻塞::send(fd, response.c_str(), response.size(), 0);}else if (n == 0) // 說明對方關閉了連接,因此需要close,并且刪除{LOG(INFO, "client quit, close fd: %d\n", fd);// 關閉連接時產生的套接字描述符,從epoll模型中移除該文件描述符// 1.先移除epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 紅黑樹底層是用的fd做的鍵值,因此并不需要寫清楚事件是什么// 2.關閉文件描述符::close(fd);}else{LOG(ERROR, "recv error, close fd: %d\n", fd);// 1.先移除epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 紅黑樹底層是用的fd做的鍵值,因此并不需要寫清楚事件是什么// 2.關閉文件描述符::close(fd);} }
運行結果: 發送消息數據成功
?
退出成功:
?
優化代碼結構,提升可讀性:將連接獲取與普通套接字IO事件處理邏輯分別封裝為獨立模塊。
void Accepter(){InetAddr addr;int sockfd = _listensock->Accepter(&addr);if (sockfd < 0){LOG(ERROR, "獲取連接失敗\n");return;}LOG(INFO, "得到了一個新的連接: %d, 客戶端信息:%s:%d\n", sockfd, addr.Ip().c_str(), addr.Port());// 得到了一個新的sockfd,我們需不需要等待寫的就緒,不需要,一個新的sockfd,先天就具備寫事件就緒// 等底層有數據(讀時間就緒),read/recv才不會被阻塞// epoll清楚底層有數據// 因此先將新的sockfd添加到epoll中,通過之后的監管,就能知道什么事件就緒struct epoll_event ev;ev.data.fd = sockfd;ev.events = EPOLLIN;::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);LOG(INFO, "epoll_ctl success! add new sockfd: %d\n", sockfd);}void HandlerIO(int fd){char buffer[4096];// 不會阻塞,因為讀事件就緒int n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);if (n > 0){buffer[n] = 0;std::cout << buffer;// 構建一個簡單的應答std::string response = "HTTP/1.0 200 OK\r\n";std::string content = "<html><body><h1>hello pupu, hello epoll</h1></body></html>";response += "Content-Type: text/html\r\n";response += "Content-Length: " + std::to_string(content.size()) + "\r\n";response += "\r\n";response += content;// 發送緩沖區有空間,寫事件就就緒::send(fd, response.c_str(), response.size(), 0);}else if (n == 0) // 說明對方關閉了連接,因此需要close,并且刪除{LOG(INFO, "client quit, close fd: %d\n", fd);// 關閉連接時產生的套接字描述符,從epoll模型中移除該文件描述符// 1.先移除epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 紅黑樹底層是用的fd做的鍵值,因此并不需要寫清楚事件是什么// 2.關閉文件描述符::close(fd);}else{LOG(ERROR, "recv error, close fd: %d\n", fd);// 1.先移除epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 紅黑樹底層是用的fd做的鍵值,因此并不需要寫清楚事件是什么// 2.關閉文件描述符::close(fd);}}void HandlerEvent(int n){for (int i = 0; i < n; i++){int fd = revs[i].data.fd;uint32_t revents = revs[i].events;// 因為revents只是一個簡單的標志位,因此我們設計一個接口能直接看到是什么事件LOG(INFO, "%d 上有事件就緒了,具體事件是:%s\n", fd, EventsToString(revents).c_str());if (fd == _listensock->Sockfd())Accepter();else // 處理其他套接字HandlerIO(fd);}}
以上:
服務器通過epoll機制持續監測事件狀態,一旦檢測到就緒事件,便立即進行處理。隨后,系統會遍歷就緒事件列表,根據事件類型將其分別派發給Accepter或HandlerIO模塊進行后續處理。
總結, epoll 的使用過程三部曲:
? 調用 epoll_create 創建一個 epoll 句柄;
? 調用 epoll_ctl, 將要監控的文件描述符進行注冊;
? 調用 epoll_wait, 等待文件描述符就緒;
epoll 的優點(和 select 的缺點對應)
? 接口使用方便: 雖然拆分成了三個函數, 但是反而使用起來更方便高效. 不需要每次循環都設置關注的文件描述符, 也做到了輸入輸出參數分離開
? 數據拷貝輕量: 只在合適的時候調用 EPOLL_CTL_ADD 將文件描述符結構拷貝到內核中, 這個操作并不頻繁(而 select/poll 都是每次循環都要進行拷貝)
? 事件回調機制: 避免使用遍歷, 而是使用回調函數的方式, 將就緒的文件描述符結構加入到就緒隊列中, epoll_wait 返回直接訪問就緒隊列就知道哪些文件描述符就緒. 這個操作時間復雜度 O(1). 即使文件描述符數目很多, 效率也不會受到影響.
? 沒有數量限制: 文件描述符數目無上限
注意!!網上有些博客說, epoll 中使用了內存映射機制
? 內存映射機制: 內核直接將就緒隊列通過 mmap 的方式映射到用戶態. 避免了拷貝內存這樣的額外性能開銷.
這種說法是不準確的. 我們定義的 struct epoll_event 是我們在用戶空間中分配好的內存. 勢必還是需要將內核的數據拷貝到這個用戶空間的內存中的.
對比總結 select, poll, epoll 之間的優點和缺點
?
- select?和?poll?適合處理少量或中等數量的連接,但性能較差,無法高效處理高并發場景。
- epoll?是 Linux 下處理高并發連接的最佳選擇,性能優異,但缺乏跨平臺支持。
- 根據具體需求選擇合適的 I/O 多路復用機制,可以顯著提升系統性能和可擴展性。
epoll 的工作方式邊緣觸發(ET)和水平觸發(LT)模式:
假設你有一個水桶,水桶底部有一個水龍頭,水龍頭可以打開或關閉。
水桶代表一個文件描述符,水龍頭的開關狀態代表文件描述符的可讀或可寫狀態。
水平觸發(LT)模式: 在 LT 模式下,只要水桶里有水(即文件描述符處于可讀狀態),epoll 就會一直通知你。這就像你有一個朋友,他每隔一段時間就會過來看看水桶里是否有水。如果水桶里有水,他就會告訴你:“嘿,水桶里有水,你可以來取水了!”即使你取了一部分水,只要水桶里還有水,他下次來的時候還會繼續提醒你。
邊緣觸發(ET)模式: 在 ET 模式下,epoll 只在水桶里的水從無到有的時候通知你一次。這就像你有一個朋友,他只在第一次發現水桶里有水的時候告訴你:“嘿,水桶里有水了!”之后,無論水桶里還有多少水,他都不會再提醒你。除非水桶里的水被取完,然后又重新有水,他才會再次提醒你。
在我們前面所編寫的EchoServer1.0當中就是默認的LT模式:要設置成ET模式需要顯示設置標記位
| 設置為邊緣觸發(ET)模式(默認是水平觸發 LT) |
理解 ET 模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要將文件描述設置為非阻塞. 這個不是接口上的要求, 而是 "工程實踐" 上的要求.
假設這樣的場景:
服務器接收到一個 10k 的請求, 會向客戶端返回一個應答數據. 如果客戶端收不到應答, 不會發送第二個 10k 請求.
?
如果服務端寫的代碼是阻塞式的 read, 并且一次只 read 1k 數據的話(read 不能保證一次就把所有的數據都讀出來, 參考 man 手冊的說明, 可能被信號打斷), 剩下的 9k 數據就會待在緩沖區中
?
此時由于 epoll 是 ET 模式, 并不會認為文件描述符讀就緒. epoll_wait 就不會再次返回. 剩下的 9k 數據會一直在緩沖區中. 直到下一次客戶端再給服務器寫數據.epoll_wait 才能返回。
問題來了:
? 服務器只讀到 1k 個數據, 要 10k 讀完才會給客戶端返回響應數據.
? 客戶端要讀到服務器的響應, 才會發送下一個請求
? 客戶端發送了下一個請求, epoll_wait 才會返回, 才能去讀緩沖區中剩余的數據
?
?
所以, 為了解決上述問題(阻塞 read 不一定能一下把完整的請求讀完), 于是就可以使用非阻塞輪訓的方式來讀緩沖區, 保證一定能把完整的請求都讀出來.
因此ET工作模式下,所有的fd都必須是非阻塞的
而如果是 LT 就不會有這個問題. 只要緩沖區中的數據沒讀完, 就能夠讓 epoll_wait 返回文件描述符讀就緒。
對比 LT 和 ET
- 使用 ET 能夠減少 epoll 觸發的次數. 但是代價就是強逼著程序猿一次響應就緒過程中就把所有的數據都處理完.
- 相當于一個文件描述符就緒之后, 不會反復被提示就緒, 看起來就比 LT 更高效一些. 但是在 LT 情況下如果也能做到每次就緒的文件描述符都立刻處理, 不讓這個就緒被重復提示的話, 其實性能也是一樣的.
- 另一方面, ET 的代碼復雜程度更高了.
ET模式下,高效在哪里?
- 可能給對方通告一個更大的接收窗口,增加IO效率
- ET通知效率更高(不需要一直重復拷貝)
- IO效率更高,可以盡快取走數據(具有強制性,因為是規定,LT沒有被強制性)。
應用場景:
- LT 模式 適用于需要持續監控文件描述符狀態的場景,比如一個簡單的 HTTP 服務器,它需要持續監控客戶端的連接請求。
- ET 模式 適用于需要高效處理大量事件的場景,比如一個高性能的 Web 服務器,它需要快速響應大量的并發請求,而不希望被重復通知同一個事件。
在下一篇技術文章中,我們將深入探討如何使用ET(Edge Triggered)模式,基于多路轉接機制
epoll
,結合Reactor設計模式來編寫一個功能更為完善的服務器代碼。本篇文章中我們實現的EchoServer雖然能夠處理基本的網絡通信任務,但在處理I/O事件時仍存在一些明顯的缺陷。特別是在HandlerIO
函數中,當處理普通套接字的讀寫事件時,我們需要解決一個關鍵問題:如何確保從文件描述符(fd)讀取的數據緩沖區(buffer)中包含的是一個完整的請求,而不是多個請求的混合數據。
?
在實際的網絡通信中,數據可能以不完整的形式到達,或者多個請求的數據可能會被一次性讀取到緩沖區中。如果每個文件描述符共享同一個緩沖區,那么不同fd的數據可能會相互覆蓋,導致數據混亂或丟失。因此,我們必須確保每個文件描述符都有一個獨立的緩沖區,以避免數據沖突。
為了更有效地處理這一問題,我們還需要引入一種協議機制。例如,可以使用定長協議或變長協議來標識每個請求的邊界。定長協議假設每個請求的長度是固定的,而變長協議則需要在數據中包含長度信息,以便正確分割和解析請求。通過這種方式,我們可以確保每個請求被完整地讀取和處理,而不會與其他請求的數據混淆。
在具體實現中,我們可以為每個文件描述符分配一個獨立的緩沖區,并在讀取數據時根據協議規則進行解析。例如,如果使用變長協議,可以在每個請求的前幾個字節中存儲請求的長度信息,然后根據該長度信息讀取相應數量的字節,確保每個請求被完整處理。這種方法不僅提高了數據處理的準確性,還增強了服務器的健壯性和可維護性。
?結語:
? ? ? ?隨著這篇博客接近尾聲,我衷心希望我所分享的內容能為你帶來一些啟發和幫助。學習和理解的過程往往充滿挑戰,但正是這些挑戰讓我們不斷成長和進步。我在準備這篇文章時,也深刻體會到了學習與分享的樂趣。 ? ?
? ? ? ? ?在此,我要特別感謝每一位閱讀到這里的你。是你的關注和支持,給予了我持續寫作和分享的動力。我深知,無論我在某個領域有多少見解,都離不開大家的鼓勵與指正。因此,如果你在閱讀過程中有任何疑問、建議或是發現了文章中的不足之處,都歡迎你慷慨賜教。
? ? ? ? 你的每一條反饋都是我前進路上的寶貴財富。同時,我也非常期待能夠得到你的點贊、收藏,關注這將是對我莫大的支持和鼓勵。當然,我更期待的是能夠持續為你帶來有價值的內容。
?
?