linux下 C++ 使用 epoll 多路復用 實現(xiàn)高性能的tcpserver

linux系統(tǒng)中,實現(xiàn)socket多路復用的技術有selectpoll 、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)境

  1. 系統(tǒng): Debian GNU/Linux 10 (buster)
  2. linux內(nèi)核: 4.19.0-14
  3. gcc版本: 8.3.0

準備知識

epoll是linux內(nèi)核提供的功能,這個功能對外提供系統(tǒng)調(diào)用,在C/C++中通過三個函數(shù)對用戶提供功能

  1. epoll_create(int __size) 創(chuàng)建一個epoll,_size 參數(shù)在linux2.6內(nèi)核之后就沒有什么作用了, 但是要>0,一般直接填 1 就好了。函數(shù)返回創(chuàng)建的epoll的文件描述符,如果創(chuàng)建失敗,會返回 -1。

  2. 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 修改一個描述符
  3. 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é)

  1. epoll是socket多路復用技術的一種,還有select和poll
  2. epoll 只能在linux使用(Windows下怎么用我沒找到,如果說錯了請指正)
  3. epoll 事件有 Level Triggered (LT) 和 Edge Triggered (ET) 兩種模型,LT是默認模式,ET是高性能模式

另外,我使用面向?qū)ο蟮姆绞椒庋b了一個epoll的tcpserver 代碼有點多,就不貼在這了,已經(jīng)上傳
github
碼云

歡迎給點個star ヾ(o???)?ヾ

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容