利用多執行緒和 C++ 實現一個簡單的 HTTP 伺服器

語言: CN / TW / HK

前言:伺服器是現代軟體不可或缺的一部分,而伺服器的技術也是非常複雜和有趣的方向。隨著作業系統不斷地發展,伺服器的底層架構也在不斷變化。本文介紹一種使用 C++ 和 多執行緒實現的簡單 HTTP 伺服器。

首先我們先來看一下如何建立一個伺服器。

int main() 
{ 
    int server_fd;
    struct sockaddr_in server_addr;
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int on = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    if (server_fd < 0) { 
        perror("create socket error"); 
        goto EXIT;
    }
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family       = AF_INET;
    server_addr.sin_port         = htons(8888);
    server_addr.sin_addr.s_addr  = htonl(INADDR_ANY);
    if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { 
        perror("bind address error"); 
        goto EXIT;
    }
    if (listen(server_fd, 511) < 0) { 
        perror("listen port error"); 
        goto EXIT;
    }
    while(1) {
        int connfd = accept(server_fd, nullptr, nullptr);
        if (connfd < 0) 
        { 
            perror("accept error"); 
            continue;
        }
        // 處理
    }
    close(server_fd);
    return 0;
EXIT:
    exit(1); 
}

我們看到根據作業系統提供的 API,建立一個 TCP 伺服器非常簡單 ,只需要呼叫幾個函式就行。最後程序會阻塞在 accept 等待連線的到來,我們在一個死迴圈中序列地處理每個請求。顯然,這樣的效率肯定非常低,因為如果我們使用傳統的 read / write 函式的話,它是會引起程序阻塞的,這樣就會導致多個請求需要排隊進行處理。我們在此基礎上利用多執行緒提高一下效率。

std::thread threads[MAX_THREAD];
std::condition_variable condition_variable;
std::deque<int> requests;
std::mutex mutex;
for (int i = 0; i < MAX_THREAD; i++) {
    threads[i] = std::thread(worker, &mutex, &condition_variable, &requests);
}

多執行緒就會涉及到併發 / 同步的問題,所以需要使用互斥變數和條件變數來處理這些問題。上面的程式碼建立了幾個執行緒,然後在每個執行緒中執行 worker 函式來處理請求,除此之外,用 requests 變數來表示請求佇列,該變數會由主執行緒和子執行緒一起訪問。具體是由主執行緒生產任務,子執行緒消費。在瞭解子執行緒邏輯之前先看看主執行緒程式碼的改動。

while(1) {
      int connfd = accept(server_fd, nullptr, nullptr);
      if (connfd < 0) 
      { 
          perror("accept error"); 
          continue;
      }
      {
          std::lock_guard<std::mutex> lock(mutex);
          requests.push_back(connfd);
          condition_variable.notify_one();
      }
  }

我們看到當主執行緒收到請求時,自己不處理,而是新增到請求佇列讓子執行緒處理,因為子執行緒沒有任務處理時會自我阻塞,所以主執行緒需要喚醒一個執行緒來處理新的請求。接下來看看子執行緒的邏輯。

void worker(std::mutex *mutex,
            std::condition_variable *condition_variable,
            std::deque<int> *requests) {
    int connfd;
    while (true) {
        {
            std::unique_lock<std::mutex> lock(*mutex);
            // 沒有任務則等待,否則取出任務處理
            while ((*requests).size() == 0)
            {
                (*condition_variable).wait(lock);
            }
            connfd = (*requests).front();
            (*requests).pop_front();
        }
        char buf[4096];
        int ret;
        while (1) {
            memset(buf, 0, sizeof(buf));
            int bytes = read(connfd, buf, sizeof(buf)); 
            if (bytes <= 0) {
                close(connfd);
            } else {
                write(connfd, buf, bytes);
            }
        } 
    }
}

子執行緒不斷從任務佇列中取出任務,具體來說就是連線對應的檔案描述符,然後不斷讀取裡面的資料,最後返回給客戶端。但是這樣的功能顯然沒有太大意義,所以我們基於這個基礎上實現一個 HTTP 服務,讓它可以處理 HTTP 請求。當然我們手寫一個優秀的 HTTP 解析器並非易事,所以我們直接使用開源的就好,這裡選擇的是 llhttp,這是 Node.js 所使用的 HTTP 解析器。這裡就不具體羅列細節,大概介紹一下 llhttp 的用法。

typedef void (*p_on_headers_complete)(on_headers_complete_info, parser_callback);
typedef void (*p_on_body_complete)(on_body_complete_info, parser_callback);
typedef void (*p_on_body)(on_body_info, parser_callback);
struct parser_callback {
    void * data;
    p_on_headers_complete on_headers_complete;
    p_on_body on_body;
    p_on_body_complete on_body_complete;
};
class HTTP_Parser {
    public:
        HTTP_Parser(llhttp_type type, parser_callback callbacks = {});
        int on_message_begin(llhttp_t* parser);
        int on_status(llhttp_t* parser, const char* at, size_t length);
        int on_url(llhttp_t* parser, const char* at, size_t length);
        int on_header_field(llhttp_t* parser, const char* at, size_t length);
        int on_header_value(llhttp_t* parser, const char* at, size_t length);
        int on_headers_complete(llhttp_t* parser);
        int on_body(llhttp_t* parser, const char* at, size_t length);
        int on_message_complete(llhttp_t* parser);
        int parse(const char* data, int len);
        int finish();
        void print();
};

HTTP_Parser 是我自己實現的 HTTP Parser Wrapper,主要是對 llhttp 的封裝,我們看到 HTTP_Parser 裡有很多回調鉤子,對應的就是 llhttp 提供的,另外 HTTP_Parser 支援呼叫方傳入鉤子,也就是 parser_callback 所定義的。當 llhttp 回撥 HTTP_Parser 時,HTTP_Parser 在合適的時機就會呼叫 parser_callback 裡的回撥,比如在解析完 HTTP Header 時,或者解析完整個報文時。具體的解析過程是當呼叫方收到資料時,執行 parse 函式,然後 llhttp 就會不斷地呼叫我們傳入的鉤子。瞭解了 HTTP 解析器的大致使用,我們來看看怎麼在專案裡使用。

parser_callback callback = {
            &connfd,
            [](on_body_complete_info info, parser_callback callback) {
                int* connfd = (int *)callback.data;
                const char * data = "HTTP/1.1 200 OK\r\nServer: multi-thread-server\r\ncontent-length: 11\r\n\r\nhello:world\r\n\r\n";
                write(*connfd, data, strlen(data));
                close(*connfd);
            },
        };
        HTTP_Parser parser(HTTP_REQUEST, callback);
        char buf[4096];
        int ret;
        while (1) {
            memset(buf, 0, sizeof(buf));
            int error = 0;
            ret = read(connfd, buf, sizeof(buf)); 
            parser.parse(buf, ret);
        }

這裡只列出關鍵的程式碼,當我們收到資料時,我們通過 parser.parse(buf, ret) 呼叫 llhttp 進行解析,llhttp 就會不斷地回撥鉤子函式,當解析完一個報文後,on_body_complete 回撥就會被執行,在這裡我們就可以對 HTTP 請求進行響應,比如這裡返回一個 200 的響應報文,然後關閉連線。因為通過 llhttp 我們可以拿到具體的請求 url,所以我們還可以進一步拓展,根據 url 進行不同的處理。

到此為止,就實現了一個 HTTP 伺服器了 ,在早期的時候,伺服器也是採用這種多程序 / 多執行緒的處理方式,現在有了多路複用等技術後,很多伺服器都是基於事件驅動來實現了。但是主執行緒接收請求,分發給子執行緒處理這種思想在有些伺服器也還是存在的,比如 Node.js,只不過 Node.js 中是程序間進行傳遞。本文大概介紹到這裡,伺服器技術是非常複雜、有趣的方向,上層的架構也隨著作業系統的能力不斷在變化,本文只是作一個簡單的探索和興趣罷了,具體程式碼在 https://github.com/theanarkh/multi-thread-server。下面是架構圖。