linux系統(tǒng)中,實現(xiàn)socket多路復用的技術有select 、poll 、epoll 等多種方式。這些不同方式個有優(yōu)缺點和適用場景,這不是本文討論的重點,又興趣的可以自己搜索學習一下。但是在高并發(fā)場景下, epoll 性能是最高的, Nginx 都聽說過吧,大名鼎鼎的Nginx 底層用的就是 epoll。
這篇文章主要是寫怎么用 epoll,而不是原理分析。這篇文章不是最全的,也不是最深入的,但是絕對是一篇能讓普通人看懂的,看完能自己用epoll寫出一個tcpserver的文章。全廢話不多說,直接開始搞
首先明確一點,epoll 是linux系統(tǒng)提供的系統(tǒng)調(diào)用,也就說,epoll 在Windows系統(tǒng)上是沒法使用的,相應的代碼也是沒法編譯的。如果有人知道怎么在Windows中編譯,請賜教。
工具
文中使用的開發(fā)環(huán)境
- 系統(tǒng):
Debian GNU/Linux 10 (buster) - linux內(nèi)核:
4.19.0-14 - gcc版本:
8.3.0
準備知識
epoll是linux內(nèi)核提供的功能,這個功能對外提供系統(tǒng)調(diào)用,在C/C++中通過三個函數(shù)對用戶提供功能
epoll_create(int __size)創(chuàng)建一個epoll,_size 參數(shù)在linux2.6內(nèi)核之后就沒有什么作用了, 但是要>0,一般直接填 1 就好了。函數(shù)返回創(chuàng)建的epoll的文件描述符,如果創(chuàng)建失敗,會返回 -1。-
epoll_ctl(nt __epfd, int __op, int __fd,struct epoll_event *__event)操作已有的epoll,epfdepoll的文件描述符;op操作方式,有添加、刪除、修改等等;_fd 要操作對象的描述符,如果是操作tcp連接,也會就是這個連接的描述符。_event epoll 的響應事件,當epoll管理的tcp連接有事件發(fā)生時,會通過 _event 這個對象傳遞出來,所以在添加連接時,要把這個連接包裝成一個 epoll_event 對象 </br>- op 類型
- EPOLL_CTL_ADD 添加一個描述符
- EPOLL_CTL_DEL 刪除一個描述符
- EPOLL_CTL_MOD 修改一個描述符
-
epoll_wait(int __epfd, struct epoll_event *__events,int __maxevents, int __timeout)當epoll管理的連接中有響應事件發(fā)生時,會回調(diào)這個函數(shù)。epfdepoll的文件描述符;__events 可以操作的連接數(shù)組;__maxevents 一個可以處理的最大事件數(shù)量;__timeout 超時時間(單位毫秒),如果填-1,會直到有可操作事件發(fā)生時才會返回,因為C++不支持函數(shù)多返回值,像Go可以直接返回所有事件和數(shù)量了 (╥╯^╰╥)。events 中的常用的類型:
- EPOLLIN :表示對應的文件描述符可以讀(SOCKET正常關閉)
- EPOLLOUT:表示對應的文件描述符可以寫
- EPOLLPRI:表示對應的文件描述符有緊急的數(shù)據(jù)可讀(表示有帶外數(shù)據(jù)到來)
- EPOLLERR:表示對應的文件描述符發(fā)生錯誤(默認注冊)
- EPOLLHUP:表示對應的文件描述符被掛斷(默認注冊)
- EPOLLET: 將EPOLL設為邊緣觸發(fā)(Edge Triggered)模式,這是相對于水平觸發(fā)(Level Triggered)來說的
- EPOLLONESHOT:只監(jiān)聽一次事件,當監(jiān)聽完這次事件之后,如果還需要繼續(xù)監(jiān)聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里
是不是很驚奇,這么牛逼的epoll就三個函數(shù),第一次看到的時候我也覺得很奇怪,三個函數(shù)就能搞定那么復雜的事情。不過想想也是,把復雜的東西簡化,才能體現(xiàn)出大神的實力
epoll的兩種模式
epoll 事件有兩種模型 Level Triggered (LT) 和 Edge Triggered (ET):</br>
LT(level triggered,水平觸發(fā)模式)是默認的工作方式,并且同時支持 block 和 non-block socket。在這種做法中,內(nèi)核告訴你一個文件描述符是否就緒了,然后你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內(nèi)核還是會繼續(xù)通知你的,所以,這種模式編程出錯誤可能性要小一點。
ET(edge-triggered,邊緣觸發(fā)模式)是高速工作方式,只支持no-block socket。在這種模式下,當描述符從未就緒變?yōu)榫途w時,內(nèi)核通過epoll告訴你。然后它會假設你知道文件描述符已經(jīng)就緒,并且不會再為那個文件描述符發(fā)送更多的就緒通知,等到下次有新的數(shù)據(jù)進來的時候才會再次出發(fā)就緒事件。
把socket設置為非阻塞模式的方法
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
需要的頭文件 #include <fcntl.h>
epoll原理
簡單通過畫圖解釋一下epoll的工作原理

這里沒有涉及太底層的東西,因為太底層的我也沒研究過,不敢亂講。知之為知之,不知為不知。</br>
epoll可以看做是一個由操作系統(tǒng)提供的容器,這個容器管理了一些 epoll_event (圖中我畫成單向鏈表了,實際上用的是紅黑樹,因為畫樹太麻煩了),這個event是我們添加進去的,event中設置了要響應的事件類型,當epoll 檢測到具體的 event 有對應的事件發(fā)生時,會通過epoll_wait() 通知。
簡單的epoll實現(xiàn)
#include <iostream>//控制臺輸出
#include <sys/socket.h>//創(chuàng)建socket
#include <netinet/in.h>//socket addr
#include <sys/epoll.h>//epoll
#include <unistd.h>//close函數(shù)
#include <fcntl.h>//設置非阻塞
using namespace std;
int main() {
const int EVENTS_SIZE = 20;
//讀socket的數(shù)組
char buff[1024];
//創(chuàng)建一個tcp socket
int socketFd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
//設置socket監(jiān)聽的地址和端口
sockaddr_in sockAddr{};
sockAddr.sin_port = htons(8088);
sockAddr.sin_family = AF_INET;
sockAddr.sin_addr.s_addr = htons(INADDR_ANY);
//將socket和地址綁定
if (bind(socketFd, (sockaddr *) &sockAddr, sizeof(sockAddr)) == -1) {
cout << "bind error" << endl;
return -1;
}
//開始監(jiān)聽socket,當調(diào)用listen之后,
//進程就可以調(diào)用accept來接受一個外來的請求
//第二個參數(shù),請求隊列的長度
if (listen(socketFd, 10) == -1) {
cout << "listen error" << endl;
return -1;
}
//創(chuàng)建一個epoll,size已經(jīng)不起作用了,一般填1就好了
int eFd = epoll_create(1);
//把socket包裝成一個epoll_event對象
//并添加到epoll中
epoll_event epev{};
epev.events = EPOLLIN;//可以響應的事件,這里只響應可讀就可以了
epev.data.fd = socketFd;//socket的文件描述符
epoll_ctl(eFd, EPOLL_CTL_ADD, socketFd, &epev);//添加到epoll中
//回調(diào)事件的數(shù)組,當epoll中有響應事件時,通過這個數(shù)組返回
epoll_event events[EVENTS_SIZE];
//整個epoll_wait 處理都要在一個死循環(huán)中處理
while (true) {
//這個函數(shù)會阻塞,直到超時或者有響應事件發(fā)生
int eNum = epoll_wait(eFd, events, EVENTS_SIZE, -1);
if (eNum == -1) {
cout << "epoll_wait" << endl;
return -1;
}
//遍歷所有的事件
for (int i = 0; i < eNum; i++) {
//判斷這次是不是socket可讀(是不是有新的連接)
if (events[i].data.fd == socketFd) {
if (events[i].events & EPOLLIN) {
sockaddr_in cli_addr{};
socklen_t length = sizeof(cli_addr);
//接受來自socket連接
int fd = accept(socketFd, (sockaddr *) &cli_addr, &length);
if (fd > 0) {
//設置響應事件,設置可讀和邊緣(ET)模式
//很多人會把可寫事件(EPOLLOUT)也注冊了,后面會解釋
epev.events = EPOLLIN | EPOLLET;
epev.data.fd = fd;
//設置連接為非阻塞模式
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) {
cout << "set no block error, fd:" << fd << endl;
continue;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) {
cout << "set no block error, fd:" << fd << endl;
continue;
}
//將新的連接添加到epoll中
epoll_ctl(eFd, EPOLL_CTL_ADD, fd, &epev);
cout << "client on line fd:" << fd << endl;
}
}
} else {//不是socket的響應事件
//判斷是不是斷開和連接出錯
//因為連接斷開和出錯時,也會響應`EPOLLIN`事件
if (events[i].events & EPOLLERR || events[i].events & EPOLLHUP) {
//出錯時,從epoll中刪除對應的連接
//第一個是要操作的epoll的描述符
//因為是刪除,所有event參數(shù)天null就可以了
epoll_ctl(eFd, EPOLL_CTL_DEL, events[i].data.fd, nullptr);
cout << "client out fd:" << events[i].data.fd << endl;
close(events[i].data.fd);
} else if (events[i].events & EPOLLIN) {//如果是可讀事件
//如果在windows中,讀socket中的數(shù)據(jù)要用recv()函數(shù)
int len = read(events[i].data.fd, buff, sizeof(buff));
//如果讀取數(shù)據(jù)出錯,關閉并從epoll中刪除連接
if (len == -1) {
epoll_ctl(eFd, EPOLL_CTL_DEL, events[i].data.fd, nullptr);
cout << "client out fd:" << events[i].data.fd << endl;
close(events[i].data.fd);
} else {
//正常讀取,打印讀到的數(shù)據(jù)
cout << buff << endl;
//向客戶端發(fā)數(shù)據(jù)
char a[] = "123456";
//如果在windows中,向socket中寫數(shù)據(jù)要用send()函數(shù)
write(events[i].data.fd, a, sizeof(a));
}
}
}
}
}
}
常見的問題和注意事項在注釋中,就單解釋一下新連接注冊事件的問題吧,很多文章中都會把可寫事件也注冊進去,像這樣
sockaddr_in cli_addr{};
socklen_t length = sizeof(cli_addr);
//接受來自socket連接
int fd = accept(socketFd, (sockaddr *) &cli_addr, &length);
if (fd > 0) {
epev.events = EPOLLIN | EPOLLET | EPOLLOUT;
epev.data.fd = fd;
epoll_ctl(eFd, EPOLL_CTL_ADD, fd, &epev);
cout << "client on line fd:" << fd << endl;
}
但是經(jīng)過測試,不注冊可寫事件,直接往socket中寫也是可以的
經(jīng)過查資料得知:
- EPOLLIN : 如果狀態(tài)改變了(比如 從無到有),只要輸入緩沖區(qū)可讀就會觸發(fā)
-
EPOLLOUT : 如果狀態(tài)改變了(比如 從滿到不滿),只要輸出緩沖區(qū)可寫就會觸
如果把可寫也注冊上,會頻繁回調(diào),這里會有很多無用的回調(diào),導致性能下降。
有一種思路,當向socket寫失敗后(write函數(shù)返回值 == -1),注冊上 EPOLLOUT 當響應了可寫事件后,重新往socket中寫數(shù)據(jù),寫成功后,再取消掉 EPOLLOUT。 這里就不給出示例了
客戶端測試
這次關注的是服務端實現(xiàn),客戶端就不用C++寫了,用Go寫了一個client(沒別的原因,只是因為Go寫起了簡單)
package main
import (
"fmt"
"net"
"sync"
"time"
)
const (
MAX_CONN = 10
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
for i := 0; i < MAX_CONN; i++ {
go Conn("192.168.199.164:8088", i)
time.Sleep(time.Millisecond * 100)
}
wg.Wait()
}
func Conn(addr string, id int) {
conn, err := net.Dial("tcp", addr)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("connect ", id)
go func() {
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
break
}
fmt.Println(id, "read: ", string(buf[:n]))
}
}()
time.Sleep(time.Second * 1)
for {
_, err := conn.Write([]byte("hello"))
if err != nil {
break
}
time.Sleep(time.Second * 10)
}
}
這只是一個測試用的,寫的很粗糙,但是不影響使用
服務端打印信息

客戶端打印信息

總結(jié)
- epoll是socket多路復用技術的一種,還有select和poll
- epoll 只能在linux使用(Windows下怎么用我沒找到,如果說錯了請指正)
- epoll 事件有 Level Triggered (LT) 和 Edge Triggered (ET) 兩種模型,LT是默認模式,ET是高性能模式
另外,我使用面向?qū)ο蟮姆绞椒庋b了一個epoll的tcpserver 代碼有點多,就不貼在這了,已經(jīng)上傳
github
碼云
歡迎給點個star ヾ(o???)?ヾ