天天看點

簡易webserver的設計與實作

簡易webserver的設計與實作

    最近學習IO多路複用的過程中嘗試寫了一個webserver, 使用Epoll多路複用(邊沿觸發)+線程池技術, 實作了半同步半反應堆模型. 通過狀态機解析http/1.1 GET請求, 可根據請求路徑調用自定義接口.這裡記錄一下設計與實作的過程.

    ​​​項目位址​​

1 編譯&運作

    github上的項目是編譯好的,二進制檔案在bin目錄下,運作時cd到bin目錄執行./SimpleWebServer即可(注意由于路徑問題一定要在bin目錄下運作).預設端口55555.

cd bin
  ./SimpleWebServer      

    如果想自己編譯的話可以将項目導入clion進行編譯(導入前删掉.idea, 使用release模式).或者在項目目錄下建立個build檔案夾,cd進去使用cmake -DCMAKE_BUILD_TYPE=Release …以及make進行編譯(編譯後的二進制檔案在bin目錄下).

mkdir build
  cd build
  cmake -DCMAKE_BUILD_TYPE=Release ..
  make      

    如果想自定義端口,自定義api的話需要去src/main.cpp裡進行修改,然後重新編譯:

#include <iostream>
#include "transmission/webserver/WebServer.h"
using std::cout;
using std::endl;

int main() {
    transmission::webserver::WebServer webServer(55555, 16, false);
    ...
    return 0;
}      

2 功能展示

2.1 擷取圖檔

簡易webserver的設計與實作

    可以重複多次測試,以檢測請求處理的正确性

2.2 檔案下載下傳

簡易webserver的設計與實作

    下載下傳好的視訊是可以正常播放的,可自行測試.

2.3 API調用

    支援根據請求路徑調用使用者自定義的API.

簡易webserver的設計與實作

2.4 抓包分析

    以擷取圖檔為例做簡單的wireshark抓包分析(為了友善示範我把項目部署到了雲伺服器)

簡易webserver的設計與實作

    建立連接配接, 三次握手:

簡易webserver的設計與實作

    注意浏覽器可能還會發送一個favicon.ico的請求,是以可能有兩次連接配接建立.圖中60132端口的請求才是我們的請求.

    擷取圖檔:

簡易webserver的設計與實作

    ack number表示期望對方下一次傳輸的seq number.可以看到tcp無法判斷資料流的邊界,也就是說http封包可能會被切分成多份.

    關閉連接配接, 四次分手:

簡易webserver的設計與實作

    通路網頁是長連結,是以會有tcp keep-alive.關閉網頁後wireshark捕捉到四次分手過程.

3 原理

    這一節介紹webserver的原理.以擷取一張圖檔為例,先看一下基本流程:

   (1) C向S發起請求

簡易webserver的設計與實作

   (2) S處理請求

簡易webserver的設計與實作

   (3) S将圖檔發送給C

簡易webserver的設計與實作

    接下來看看并發量升高時會有什麼問題:

簡易webserver的設計與實作

    假設有10w個長連接配接,S要為每個sokcet描述符配置設定一個線程,總共10w個線程.每個線程都會占用一定的系統資源.在并發量較高的場景下系統資源很容易被耗盡.

    怎麼辦呢?我們可以雇一個"管理者",負責通知我們哪些socket描述符可讀/可寫,這樣隻需要維護一個管理者線程就好.這裡所說的管理者其實就是epoll.epoll原理推薦看 ​​參考資料[7]​​ .

    有了epoll後S就輕松多了:

    (1) 接收請求封包:

簡易webserver的設計與實作

    (2) 回複響應封包:

簡易webserver的設計與實作

    有了epoll,我們可以隻用一個線程友善地把資料從核心緩沖區拷貝到使用者緩沖區.然而,為提升處理效率,應充分發揮多核處理器的優勢,是以采用線程池并行處理請求.

簡易webserver的設計與實作

    讨論:線程池容量不一定要嚴格等于處理器核心數.可以設為核心數+2防止有線程意外宕掉.如果處理任務要和磁盤打交道的話可以設得更多(這樣可以使得部分線程在cpu執行時,另一部分線程能夠通過DMA與磁盤打交道,提高cpu使用率).這些都取決于實際場景.

    上述架構中,epoll負責收發資料,線程池負責處理資料.這個架構大家習慣叫半同步半反應堆.半同步是指epoll,因為epoll本質上是阻塞的,屬于同步IO(windows下的IOCP才是真正的異步IO).半反應堆是指線程池,用于并行處理請求.

4 實作

    接下來開始實作.先來想一下我們要做哪些工作.既然是半同步半反應堆,那麼需要一個epoll子產品和一個線程池子產品.由于涉及核心空間和使用者空間的資料互動,需要設計一個buffer子產品.因為我們是用的http協定進行通信,需要設計一個http子產品進行請求封包的解析和響應封包的制作.最後需要設計一個webserver子產品去統籌排程上述子產品.

    出于上述考慮,項目結構如下.

bin
  doc
  include
   |   transmission
   |   |   webserver
   |   |   |   Http.h
   |   |   |   WebServer.h
   |   utils
   |   |   buffer
   |   |   |   Buffer.h
   |   |   concurrence
   |   |   |   ThreadPool.h`
   |   |   nio
   |   |   |   Epoll.h
   |   |   Error.h
  src
  test
  tools
  www      

    除了上述子產品外,Error子產品用做異常處理,bin目錄用于存放可執行程式,doc目錄用于存放文檔,include目錄存放頭檔案,src目錄存放源檔案,test目錄存放測試檔案,tools目錄存放一些工具,如代碼行數統計,壓力測試等.www目錄存放網頁用到的靜态資源檔案.

4.1 Epoll

    有關epoll的函數原型以及底層設計推薦看 ​​參考資料[8]​​     這個子產品就是把epoll做了簡單封裝,友善調用,這了解不詳細介紹了.

4.2 線程池

    有關線程池的設計細節推薦看 ​​參考資料[9]​​     基本原理是維護一個任務隊列queue<task>和一個線程池vector<thread> .每個線程都是一個無限循環,當任務隊列為空時wait,不為空時取一個任務執行.每添加一個新任務都會調用notify_one嘗試喚醒一個線程.設計細節可以參考項目源碼.

4.3 Buffer

    有關緩沖區的設計細節推薦看 ​​參考資料[10]​​​     為什麼需要緩沖區, 來一條請求處理一條不好嗎?回想第2節的抓包實驗,一條http封包可能會被分為若幹tcp分組發送,也就是說EPOLLIN來的資料可能有好幾條請求連在一起,甚至可能隻有一半請求.隻有一半請求的話我們是無法處理的,需要借助緩沖區進行存儲,待請求完整時才能繼續處理.

    這裡參考muduo的設計.拿vector做緩沖區.設定m_readPos和m_writePos兩個指針,[0, m_readPos)的資料已從緩沖區讀出,[m_readPos, m_writePos)的資料已寫入緩沖區,還未讀,[m_writePos, buffer size)這段空間是還能繼續寫入的空間.

[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-qitYRswz-1626875028606)(./webserver_pic/buffer.png)]

    當緩沖區沒有足夠的空間寫入時需要擴容.這裡要分兩種情況,一種情況是寫入的資料總量超過了buffer大小,這時直接對buffer做resize操作就好,resize操作不會影響已寫入的資料.另一種情況是寫入的資料總量沒有超過buffer大小,這時将[m_readPos, m_writePos)這段資料拷貝到緩沖區開頭,即[0, m_writePos-m_readPos),并更新m_writePos = m_writePos-m_readPos, m_readPos = 0.這樣做是為了盡可能避免緩沖區頻繁擴容,造成空間浪費.

    另外,當緩沖區的資料被一次性全部讀出時,設定m_readPos = m_writePos = 0,同樣也是為了節省空間.

4.4 Http

    http子產品的設計可以參考 ​​參考資料[4]​​​ ​​參考資料[5]​​​ ​​參考資料[6]​​​ 這三篇教程.

    http子產品要做的事情主要是從輸入緩沖區讀請求封包,然後處理請求,包括擷取相關檔案以及調用使用者自定義接口,最後将響應封包寫到輸出緩沖區.

    首先實作請求封包的讀取與響應封包的寫入:

// 從readBuffer中讀取請求
    // ET模式下WebServer會循環調用這個函數
    ssize_t Http::readRequest(int fd, int *ern) {
        char buffer[65536]; // 核心緩沖區比readBuffer剩餘空間大時先存到這裡,然後再對buffer進行擴容
        struct iovec iov[2];
        size_t wtb = m_readBuffer.writableBytes();
        iov[0].iov_base = m_readBuffer.nextWritePos();
        iov[0].iov_len = wtb;
        iov[1].iov_base = buffer;
        iov[1].iov_len = sizeof(buffer);
        ssize_t len = readv(fd, iov, 2); // 等于0貌似是對端關閉連接配接來着,在WebServer裡也要斷開連接配接
        if (len < 0) {
            *ern = errno;
        } else if (len > 0) {
            // 這裡有的直接改buffer,有的隻改下标,不太好看,不過為了效率就這麼寫吧...
            if (static_cast<size_t>(len) <= wtb) {
                m_readBuffer.writeBuffer_idx(static_cast<size_t>(len));
            } else {
                m_readBuffer.writeBuffer_idx(static_cast<size_t>(wtb));
                m_readBuffer.writeBuffer(buffer, len-wtb);
            }
            if (m_debug) {
                std::cout << "[fd:" << m_fd << "] ";
                m_readBuffer.output();
            }
        }
        return len;
    }

    // 将響應封包寫入writeBuffer
    // ET模式下WebServer會循環調用這個函數
    ssize_t Http::writeResponse(int fd, int *ern) {
        ssize_t len = writev(fd, m_iov, m_iovCnt); // 這個等于0沒什麼影響,一般就是核心緩沖區寫不進去了
        if (len < 0) {
            *ern = errno;
        }
        else if (len > 0) {
            if (static_cast<size_t>(len) > m_iov[0].iov_len) {
                m_iov[1].iov_base = (char*)m_iov[1].iov_base+(len-m_iov[0].iov_len);
                m_iov[1].iov_len -= (len - m_iov[0].iov_len);
                if (m_iov[0].iov_len != 0) {
                    m_writeBuffer.readBufferAll_idx(); // 相當于清空writeBuffer
                    m_iov[0].iov_len = 0;
                }
            } else {
                m_iov[0].iov_base = (char*)m_iov[0].iov_base+len;
                m_iov[0].iov_len -= len;
                m_writeBuffer.readBuffer_idx(len);
            }
        }
        return len;
    }      

    可以看到代碼在讀寫時都用到了io向量,也就是readv和writev函數.io向量可以幫助我們在一次系統調用内實作整個向量的讀/寫,否則的話要為向量中的每個元素做一次系統調用,影響效率.

    讀請求封包的時候用到了一個大小為65536的棧空間,如果讀取的内容超過m_readBuffer的大小會将超出的部分暫存在這個棧空間中,然後給m_readBuffer擴容,再将棧空間的資料寫入m_readBuffer,進而在每次讀取時多讀一些資料,減少系統調用的次數.

    寫響應封包的時候也用到了包含兩個元素的io向量.這兩個元素分别表示響應封包的狀态行+頭部以及響應内容.

    接下來進行請求的處理,包括解析請求封包,擷取請求檔案/調用使用者自定義接口以及生成響應封包.下述内容需要讀者對http請求封包, 響應封包格式以及狀态碼的含義有一個基本的認識, 不了解的可以參考 ​​參考資料[4]​​ .

    正如之前說的,m_readBuffer中的http請求可能不完整,也可能有若幹條,解析起來比較困難.這裡用一個狀态機去做這件事情.可以簡單的了解為一個while循環,每次處理請求中的一行資料.循環裡面用一個switch判斷目前的狀态,并交給相應的方法去處理,直到從m_readBuffer中解析出一條請求.之後狀态機會執行請求,并制作響應封包,存儲在m_writeBuffer中.狀體機是http子產品的核心,其結構框圖如下:

簡易webserver的設計與實作

    下面結合代碼解釋每個部分的作用.

// 處理使用者的請求(調用api或讀取檔案)
    // true表示之後可以發送響應封包了(有錯誤的話code會相應地設為BAD_REQUEST,INTERNAL_ERROR等),false表示資料不完整,還需要繼續等待資料到來
    // userFunction是使用者自定義的api
    bool Http::process(std::unordered_map<std::string, std::function<void(Http*)>> *userFunction) {
        // 注意緩沖區裡的請求可能不完整,也可能有多條請求,這些都要考慮在内
        // 請求不完整時return false繼續等資料就好,維護好狀态機的全局狀态
        // 多條請求時要在response發完後重新調用process,形成一個環路
        while (true) {
            if (m_state == START) {
                if (!readLine()) {
                    return false;
                }
                m_state = REQUEST_LINE;
            } else if (m_state == REQUEST_LINE) {
                if (!parseRequestLine()) {
                    m_code = BAD_REQUEST;
                    break;
                }
                m_state = HEADER;
                if (!readLine()) {
                    return false;
                }
            } else if (m_state == HEADER) {
                if (m_line.empty()) { // header後一定有個空行,接着根據是否有content進行狀态轉移
                    if (getContentLength() == 0) {
                        m_state = FINISH;
                    } else {
                        m_state = CONTENT;
                        if (!readLine()) {
                            return false;
                        }
                    }
                    continue;
                }
                if (!parseHeader()) {
                    m_code = BAD_REQUEST;
                    break;
                }
                if (!readLine()) {
                    return false;
                }
            } else if (m_state == CONTENT) {
                // todo
                utils::Error::Throw(utils::Error::SORRY);
            } else if (m_state == FINISH) {
                if (m_debug) {
                    std::cout << "[fd:" << m_fd << "] " << "finished" << std::endl;
                }
                break;
            } else {
                m_code = INTERNAL_ERROR;
                break;
            }
        }
        if (m_code == OK) {
            execute(userFunction); // 處理請求
        }
        // 制作響應封包
        addStateLine();
        addHeader();
        addContent();
        return true;
    }      

    盡管目前沒有實作POST請求的解析,但考慮到content不以\r\n結尾,我沒有選擇在每次循環開始時調用readLine讀取m_readBuffer中的一行資料,而是在每個狀态處理完畢後判斷是否需要繼續readLine.接着依據目前的狀态(START, REQUEST_LINE, HEADER, CONTENT, FINISH)調用相應的函數(parseRequestLine,parseHeader以及parseContent),并進行狀态轉移.HEADER轉移時要注意判斷Content-Length是否為0(不存在的話也視為0),等于0的話就沒必要去解析content了.

    解析過程中如果readLine傳回false則說明請求不完整,狀态機向上層傳回false表示繼續等待資料到達.一次解析過程中狀态m_state是持久化的,就是說如果目前解析到了HEADER,發現請求不完整傳回false,那麼下一次資料到達時狀态機依然從HEADER狀态進行解析.

    解析出一條封包後,若未出現請求格式錯誤或内部錯誤則調用execute執行請求. 執行時會先判斷請求路徑是否與使用者自定義的接口相關聯(參數userFunction儲存了請求路徑到使用者接口的映射,比如使用者寫了個helloworld函數,關聯到了位址/hello上,那麼userFunction這個map中就會儲存一條<"/hello", helloworld()>),若無接口關聯則嘗試擷取請求路徑對應的檔案.若檔案存在,且有通路權限,則使用mmap讀取檔案到m_file中.mmap使得磁盤到使用者記憶體隻需要一次拷貝(正常需要磁盤到頁緩存再到使用者記憶體兩次拷貝).相關原理可以查閱 ​​​參考資料11​​ .具體實作如下:

// 其實使用者api從WebServer那裡傳參過來也不怎麼優雅...
    void Http::execute(std::unordered_map<std::string, std::function<void(Http*)>> *userFunction) {
        if (userFunction->count(m_path) == 1) {
            // 根據路徑調用使用者api
            if (m_debug) {
                std::cout << "[fd:" << m_fd << "] " << "call user function" << std::endl;
            }
            auto func = (*userFunction)[m_path];
            func(this);
        } else {
            // 使用mmap将檔案映射到m_file
            if (m_debug) {
                std::cout << "[fd:" << m_fd << "] " << "read file" << std::endl;
            }
            std::string path = "../www"+m_path; // 我是預設在bin下面運作的,這個路徑可以自己改一下
            if (stat(path.c_str(), &m_fileStat) < 0 || S_ISDIR(m_fileStat.st_mode)) {
                if (m_debug) {
                    std::cout << "[fd:" << m_fd << "] " << "not found1" << std::endl;
                }
                m_code = NOT_FOUND;
                return;
            }
            if (!(m_fileStat.st_mode & S_IROTH)) { // 權限判斷
                if (m_debug) {
                    std::cout << "[fd:" << m_fd << "] " << "forbidden" << std::endl;
                }
                m_code = FORBIDDEN;
                return;
            }
            int fd = open(path.c_str(), O_RDONLY);
            if (fd < 0) {
                if (m_debug) {
                    std::cout << "[fd:" << m_fd << "] " << "not found2" << std::endl;
                }
                m_code = NOT_FOUND;
                return;
            }
            int* ret = (int*) mmap(nullptr, m_fileStat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
            if (*ret == -1) {
                if (m_debug) {
                    std::cout << "[fd:" << m_fd << "] " << "not found3" << std::endl;
                }
                m_code = NOT_FOUND;
                return;
            }
            m_file = (char*)ret;
            close(fd);
            if (m_debug) {
                std::cout << "[fd:" << m_fd << "] " << "read successfully" << std::endl;
            }
        }
    }      

    若出現了錯誤則生成響應封包告訴使用者請求出錯,随後斷開連接配接釋放所有資源.最後,狀态機會依次調用addStateLine,addHeader,addContent制作請求封包儲存到m_writeBuffer中,整個Http子產品就完成了一次任務.

    上述過程用到了readLine, parseRequestLine, parseHeader以及parseContent, execute, addStateLine, addHeader, addContent等函數.execute函數已經介紹過了, 其餘函數涉及一些字元串的查詢和正規表達式比對,可以自行查閱源碼.

    至此http子產品完工.回顧一下,我們用io向量讀寫緩沖區;用狀态機從m_readBuffer中解析請求封包,處理請求(根據請求路徑調用使用者接口/用mmap讀取請求檔案),并生成響應封包到m_writeBuffer中.不過目前整個項目還無法運作,我們還未将各個子產品整合到一起.最後的統籌排程工作交給webserver子產品完成.

4.5 webserver

    如下面的代碼所示,webserver本質上也是個狀态機.沒有IO事件觸發時epoll會阻塞在wait上.當事件觸發時,若目前事件的描述符與epoll的描述符相同,則說明要建立新的連接配接,webserver會調用addUser去accept這個連接配接,在epoll中為accept到的描述符注冊一個新事件,并建立一個http對象負責處理這個連接配接的請求.當EPOLLIN事件到來時webserver會調用processRead将核心緩沖區的請求封包拷貝到相關http對象的讀緩沖區,處理請求,這個過程會建立一個任務,交給線程池去執行.EPOLLOUT事件到來時表示核心緩沖區可寫,webserver會調用processWrite将相關http對象寫緩沖區的響應封包寫入核心緩沖區,這個過程同樣也會建立一個任務,交給線程池去處理.EPOLLRDHUP|EPOLLHUP|EPOLLERR表示對端關閉或者出錯,此時調用delUser關閉連接配接,在epoll中删除相應的事件,并删除對應的http對象.

void WebServer::start() {
        // 由于設定了oneshot每個fd隻會被一個線程處理,是以每個事件内部是無資料競争的
        // 但不同僚件間可能有資料競争,這個在delUser裡作了處理(分析一下會發現就delUser會有問題,其餘函數都是線程安全的)
        while (!isClosed) {
            if (m_debug) {
                std::cout << "========================================" << std::endl;
            }
            // blocking
            int eventCnt = m_epoll.wait(-1);
            // process events
            for (int i = 0; i < eventCnt; i++) {
                int fd = m_epoll.getFd(i);
                uint32_t events = m_epoll.getEvent(i);
                if (fd == m_fd) {
                    addUser();
                } else if (events & (EPOLLRDHUP|EPOLLHUP|EPOLLERR)) {
                    delUser(fd);
                } else if (events & EPOLLIN) {
                    processRead(fd);
                } else if (events & EPOLLOUT) {
                    processWrite(fd);
                } else {
                    utils::Error::Throw(utils::Error::EPOLL_UNEXPECTED_ERROR);
                }
            }
        }
    }      

    addUser,delUser,processRead,processWrite等函數的實作如下:

    (1) processRead & processWrite:

void WebServer::processRead(int fd) {
        assert(m_users.count(fd) > 0);
        Http* user = m_users[fd];
        assert(user != nullptr);
        if (m_debug) {
            std::cout << "epoll in" << std::endl;
        }
        m_threadPool.addTask([this, fd, user] {
            // read request
            while (true) {
                int ern = 0;
                ssize_t ret = user->readRequest(fd, &ern);
                if (ret < 0) {
                    if (ern == EAGAIN || ern == EWOULDBLOCK) {
                        break;
                    }
                    if (m_debug) {
                        std::cout << "processRead:ret < 0:delete user" << std::endl;
                    }
                    delUser(fd);
                    return;
                } else if (ret == 0) {
                    if (m_debug) {
                        std::cout << "processRead:ret == 0:delete user" << std::endl;
                    }
                    delUser(fd);
                    return;
                }
            }
            // 處理請求
            if (user->process(&userFunction)) {
                // 注意由于設定了oneshot這裡要更新一下才能繼續使用這個fd
                m_epoll.modFd(fd, m_userEvent | EPOLLOUT);
            } else {
                // 請求不完整,繼續等待資料
                m_epoll.modFd(fd, m_userEvent | EPOLLIN);
            }
        });
    }

    void WebServer::processWrite(int fd) {
        assert(m_users.count(fd) > 0);
        Http* user = m_users[fd];
        assert(user != nullptr);
        if (m_debug) {
            std::cout << "epoll out" << std::endl;
        }
        m_threadPool.addTask([this, fd, user] {
            while (true) {
                int ern = 0;
                ssize_t ret = user->writeResponse(fd, &ern);
                if (ret < 0) {
                    if (ern == EAGAIN || ern == EWOULDBLOCK) {
                        break;
                    }
                    if (m_debug) {
                        std::cout << "processWrite:ret < 0:delete user" << std::endl;
                    }
                    delUser(fd);
                } else if (ret == 0) {
                    break;
                }
            }
            if (user->toWriteBytes() == 0) {
                // buffer dirty
                if (user->hasError()) {
                    if (m_debug) {
                        std::cout << "processWrite:has error:delete user" << std::endl;
                    }
                    delUser(fd);
                    return;
                }
                // 短連接配接時server會在發送完響應封包後關閉連接配接
                if (!user->isKeepAlive()) {
                    if (m_debug) {
                        std::cout << "processWrite:keep alive:delete user" << std::endl;
                    }
                    delUser(fd);
                    return;
                }
                // 如process中描述的那樣, 多條請求時要在response發完後重新調用process,形成一個環路
                user->initNextHttp();
                if (user->process(&userFunction)) {
                    m_epoll.modFd(fd, m_userEvent | EPOLLOUT);
                } else {
                    m_epoll.modFd(fd, m_userEvent | EPOLLIN);
                }
            } else {
                m_epoll.modFd(fd, m_userEvent | EPOLLOUT);
            }
        });
    }      

    這裡涉及到了與Http子產品緩沖區的互動,也是之前遺留下來的一個問題.我們先來了解一下LT(水準觸發)模式和ET(邊緣觸發)模式.LT模式下隻要核心緩沖區不為空/不滿就持續觸發EPOLLIN/EPOLLOUT去通知使用者讀取/寫入,而ET模式下隻有緩沖區從空到非空/從滿到非滿時才會觸發一次EPOLLIN/EPOLLOUT去通知使用者讀取/寫入.換言之,LT模式下一次EPOLLIN/EPOLLOUT可以隻進行一次read/wirte,這次沒有讀/寫完的話epoll還會繼續通知.但在ET模式下不能這樣做.假設客戶隻發了一條請求,伺服器完整接受并儲存在了核心緩沖區.由于使用者緩沖區大小的限制,隻有一半請求從核心拷貝到了使用者.在這個場景下客戶後續不再發送請求,不會再觸發EPOLLIN,也就是說另一半請求會一直留在核心緩沖區中讀不出來.ET模式需要我們在一次EPOLLIN時循環讀取核心資料,直到errorno == EAGAIN, 表示核心緩沖區中已無資料(寫的時候一般沒什麼問題,不過為了效率也采用循環寫比較好).另外,ET模式通常是比LT模式高效的,項目也是采用了ET模式.

    還有一個很重要的問題,由于此處涉及到了并發,需警惕并發漏洞.慶幸的是,epoll的EPOLLONESHOT選項幫助我們簡化了這個問題.設定EPOLLONESHOT後一個事件隻會被觸發一次,也就是說它觸發後隻能被一個線程處理(不設定EPOLLONESHOT的話可能會有多個線程同時修改一個Http對象).這樣可以保證一個事件不會和自己産生資料競争.注意要線上程處理結束後重新設定這個事件,不然會影響後續的正常使用.

    另外,在processWrite中還有一些關鍵設定.一是在發送完響應封包後立即初始化并執行狀态機.這樣是為了處理上面提到的m_readBuffer中有多條請求的情況.現在整個過程形成了一個閉環,保證所有請求都能得到處理.二是我特别處理了一下請求出錯的情況,在發送完響應封包後會關閉連接配接,保護伺服器.關于發送完封包再關閉連接配接這個操作可以去設定socket的linger,裡面可以選擇正常FIN,RST或是定時FIN+RST,我用了定時(1s)FIN+RST,畢竟關閉連接配接時也不想讓伺服器等太長時間.

    (2) addUser & delUser:

void WebServer::addUser() {
        struct sockaddr_in address = {};
        socklen_t len = sizeof(address);
        while (true) {
            int fd = accept(m_fd, (struct sockaddr*)&address, &len);
            // 舉個例子,fd=4被close以後下次accept的fd可能還是4,也就是說一個序号會被重複利用
            // 是以我沒有delete掉Http,而是每次重新初始化,這樣空間會耗費一些,但就不用每次都new了,會快一點
            if (fd < 0 || m_userCnt >= MAX_USER) {
                return;
            }
            setNonBlocking(fd);
            if (m_users.count(fd) == 0) {
                m_users[fd] = new Http(fd, m_debug);
            } else {
                Http* user = m_users[fd];
                user->clearBuffer();
                user->initNextHttp();
            }
            m_userCnt++;
            m_epoll.addFd(fd, m_userEvent | EPOLLIN);
            if (m_debug) {
                std::cout << "add user, fd = " << fd << std::endl;
                std::cout << "userCnt = " << m_userCnt << std::endl;
            }
        }
    }

     void WebServer::delUser(int fd) {
        assert(m_users.count(fd) > 0);
        assert(m_users[fd] != nullptr);
        // 由于多個任務可能并發調用delUser,這裡會發生資料競争,是以用了原子變量
        m_userCnt--;
        m_epoll.delFd(fd);
        close(fd);
        if (m_debug) {
            std::cout << "delete user, fd = " << fd << std::endl;
            std::cout << "userCnt = " << m_userCnt << std::endl;
        }
    }      

    epoll需要為套接字設定非阻塞模式,addUser中的while(true) accept就是最好的例子.觀察一下會發現這個死循環隻有當accept接受不到新連接配接時才會跳出,而在阻塞模式下accept接受不到新連接配接的話會阻塞,會永遠卡死在這個地方.同理ET模式的while循環中也會有這個問題.是以我們把所有套接字都設定成了非阻塞模式.

    另外,被釋放的套接字編号是可以再次被accept利用的,這時我并沒有重新new一個Http對象,而是選擇了直接初始化(清空緩沖區+初始化狀機).

    addUser, delUser中的m_userCnt可能會被多個線程同時更新,是以要使用原子變量避免資料競争.由于我們并沒有去delete Http對象,是以無需擔心m_users的資料競争.epoll自身是線程安全的,也不需要我們擔心.

    至此,項目建構完畢!整體框圖如下:

簡易webserver的設計與實作

5 壓測&對比

    這一節我會用webbence1.5對本文的WebServer(SimpleWebServer), ​​參考資料[2]​​ 的WebServer(先叫它MPWebServer)以及gin做壓測對比,向它們請求同一張圖檔(40KB).環境是deepin20+AMD R7 4800U+16GB,SimpleWebServer和MPWebServer均開啟O3優化.因為沒有合适的裝置,壓測程式和WebServer都放在了本機上.建立100個連接配接壓測5秒

    (1) SimpleWebserver,11400QPS:

簡易webserver的設計與實作

    (2) MPWebserver,10400QPS:

簡易webserver的設計與實作

    (3) gin,21100QPS:

參考資料

繼續閱讀