1. 程式人生 > 實用技巧 >淺析伺服器併發IO效能提升之路 — 從網路程式設計基礎到epoll

淺析伺服器併發IO效能提升之路 — 從網路程式設計基礎到epoll

從網路程式設計基本概念說起

我們常常使用HTTP協議來傳輸各種格式的資料,其實HTTP這個應用層協議的底層,是基於傳輸層TCP協議來實現的。TCP協議僅僅把這些資料當做一串無意義的資料流來看待。所以,我們可以說:客戶端與伺服器通過在建立的連線上傳送位元組流來進行通訊。
這種C/S架構的通訊機制,需要標識通訊雙方的網路地址和埠號資訊。對於客戶端來說,需要知道我的資料接收方位置,我們用網路地址和埠來唯一標識一個服務端實體;對於服務端來說,需要知道資料從哪裡來,我們同樣用網路地址和埠來唯一標識一個客戶端實體。那麼,用來唯一標識通訊兩端的資料結構就叫做套接字。一個連線可以由它兩端的套接字地址唯一確定:

(客戶端地址:客戶端埠號,服務端地址:服務端埠號)

有了通訊雙方的地址資訊之後,就可以進行資料傳輸了。那麼我們現在需要一個規範,來規定通訊雙方的連線及資料傳輸過程。在Unix系統中,實現了一套套接字介面,用來描述和規範雙方通訊的整個過程。

  • socket():建立一個套接字描述符
  • connect():客戶端通過呼叫connect函式來建立和伺服器的連線
  • bind():告訴核心將socket()建立的套接字與某個服務端地址與埠連線起來,後續會對這個地址和埠進行監聽
  • listen():告訴核心,將這個套接字當成伺服器這種被動實體來看待(伺服器是等待客戶端連線的被動實體,而核心認為socket()建立的套接字預設是主動實體,所以才需要listen()函式,告訴核心進行主動到被動實體的轉換)
  • accept():等待客戶端的連線請求並返回一個新的已連線描述符

最簡單的單程序伺服器

由於Unix的歷史遺留問題,原始的套接字介面對地址和埠等資料封裝並不簡潔,為了簡化這些我們不關注的細節而只關注整個流程,我們使用PHP來進行分析。PHP對Unix的socket相關介面進行了封裝,所有相關套接字的函式都被加上了socket_字首,並且使用一個資源型別的套接字控制代碼代替Unix中的檔案描述符fd。在下文的描述中,均用“套接字”代替Unix中的檔案描述符fd進行闡述。一個PHP實現的簡單伺服器虛擬碼如下:

<?php

if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
    echo '套接字建立失敗';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
    echo '繫結地址與埠失敗';
}
if (socket_listen($listenSocket) === false) {
    echo '轉換主動套接字到被動套接字失敗';
}
while (1) {
    if (($connSocket = socket_accept($listenSocket)) === false) {
        echo '客戶端的連線請求還沒有到達';
    } else {
        socket_close($listenSocket); //釋放監聽套接字
        socket_read($connSocket);  //讀取客戶端資料,阻塞
        socket_write($connSocket); //給客戶端返回資料,阻塞
        
    }
    socket_close($connSocket);
}

我們梳理一下這個簡單的伺服器建立流程:

  • socket_create():建立一個套接字,這個套接字就代表建立的連線上的一個端點。第一個引數AF_INET為使用的底層協議為IPv4;第二個引數SOCK_STREAM表示使用位元組流進行資料傳輸;第三個引數SQL_TCP代表本層協議為TCP協議。這裡建立的套接字只是一個連線上的端點的一個抽象概念。
  • socket_bind():繫結這個套接字到一個具體的伺服器地址和埠上,真正例項化這個套接字。引數就是你之前建立的一個抽象的套接字,還有你具體的網路地址和埠。
  • socket_listen():我們觀察到只有一個函式引數就是之前建立的套接字。有些同學之前可能認為這一步函式呼叫完全沒有必要。但是它告訴核心,我是一個伺服器,將套接字轉換為一個被動實體,其實是有很大的作用的。
  • socket_accept():接收客戶端發來的請求。因為伺服器啟動之後,是不知道客戶端什麼時候有連線到來的。所以,需要在一個while迴圈中不斷呼叫這個函式,如果有連線請求到來,那麼就會返回一個新的套接字,我們可以通過這個新的套接字進行與客戶端的資料通訊,如果沒有,就只能不斷地進行迴圈,直到有請求到來為止。

注意,在這裡我將套接字分為兩類,一個是監聽套接字,一個是連線套接字。注意這裡對兩種套接字的區分,在下面的討論中會用到:

  • 監聽套接字:伺服器對某個埠進行監聽,這個套接字用來表示這個埠($listenSocket)
  • 連線套接字:伺服器與客戶端已經建立連線,所有的讀寫操作都要在連線套接字上進行($connSocket)

那麼我們對這個伺服器進行分析,它存在什麼問題呢?

一個這樣的伺服器程序只能同時處理一個客戶端連線與相關的讀寫操作。因為一旦有一個客戶端連線請求到來,我們對監聽套接字進行accept之後,就開啟了與該客戶端的資料傳輸過程。在資料讀寫的過程中,整個程序被該客戶端連線獨佔,當前伺服器程序只能處理該客戶端連線的讀寫操作,無法對其它客戶端的連線請求進行處理。

IO併發效能提升之路

由於上述伺服器的效能太爛,無法同時處理多個客戶端連線以及讀寫操作,所以優秀的開發者們想出了以下幾種方案,用以提升伺服器的效率,分別是:

  • 多程序
  • 多執行緒
  • 基於單程序的IO多路複用(select/poll/epoll)

多程序

那麼如何去優化單程序呢?很簡單,一個程序不行,那搞很多個程序不就可以同時處理多個客戶端連線了嗎?我們想了想,寫出了程式碼:

<?php

if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
    echo '套接字建立失敗';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
    echo '繫結地址與埠失敗';
}
if (socket_listen($listenSocket) === false) {
    echo '轉換主動套接字到被動套接字失敗';
}
for ($i = 0; $i < 10; $i++) { //初始建立10個子程序
    if (pcntl_fork() == 0) {
        if (($connSocket = socket_accept($listenSocket)) === false) {
            echo '客戶端的連線請求還沒有到達';
        } else {
            socket_close($listenSocket); //釋放監聽套接字
            socket_read($connSocket);  //讀取客戶端資料
            socket_write($connSocket); //給客戶端返回資料
        }
        socket_close($connSocket);
    }
}

我們主要關注這個for迴圈,一共迴圈了10次代表初始的子程序數量我們設定為10。接著我們呼叫了pcntl_fork()函式建立子程序。由於一個客戶端的connect就對應一個服務端的accept。所以在每個fork之後的10個子程序中,我們均進行accept的系統呼叫,等待客戶端的連線。這樣,就可以通過10個伺服器程序,同時接受10個客戶端的連線、同時為10個客戶端提供讀寫資料服務。
注意這樣一個細節,由於所有子程序都是預先建立好的,那麼請求到來的時候就不用建立子程序,也提高了每個連線請求的處理效率。同時也可以藉助程序池的概念,這些子程序在處理完連線請求之後並不立即回收,可以繼續服務下一個客戶端連線請求,就不用重複的進行fork()的系統呼叫,也能夠提高伺服器的效能。這些小技巧在PHP-FPM的實現中都有所體現。其實這種程序建立方式是其三種執行模式中的一種,被稱作static(靜態程序數量)模式:

  • ondemand:按需啟動。PHP-FPM啟動的時候不會啟動任何一個子程序(worker程序),只有客戶端連線請求到達時才啟動
  • dynamic:在PHP-FPM啟動時,會初始啟動一些子程序,在執行過程中視情況動態調整worker數量
  • static:PHP-FPM啟動時,啟動固定大小數量的子程序,在執行期間也不會擴容

回到正題,多程序這種方式的的確確解決了伺服器在同一時間只能處理一個客戶端連線請求的問題,但是這種基於多程序的客戶端連線處理模式,仍存在以下劣勢:

  • fork()等系統呼叫會使得程序的上下文進行切換,效率很低
  • 程序建立的數量隨著連線請求的增加而增加。比如100000個請求,就要fork100000個程序,開銷太大
  • 程序與程序之間的地址空間是私有、獨立的,使得程序之間的資料共享變得困難

既然談到了多程序的資料共享與切換開銷的問題,那麼我們能夠很快想到解決該問題的方法,就是化多程序為更輕量級的多執行緒。

多執行緒

執行緒是執行在程序上下文的邏輯流。一個程序可以包含多個執行緒,多個執行緒執行在單一的程序上下文中,因此共享這個程序的地址空間的所有內容,解決了程序與程序之間通訊難的問題。同時,由於一個執行緒的上下文要比一個程序的上下文小得多,所以執行緒的上下文切換,要比程序的上下文切換效率高得多。執行緒是輕量級的程序,解決了程序上下文切換效率低的問題。
由於PHP中沒有多執行緒的概念,所以我們僅僅把上面的虛擬碼中建立程序的部分,改成建立執行緒即可,程式碼大體類似,在此不再贅述。

IO多路複用

前面談到的都是通過增加程序和執行緒的數量來同時處理多個套接字。而IO多路複用只需要一個程序就能夠處理多個套接字。IO多路複用這個名詞看起來好像很複雜很高深的樣子。實際上,這項技術所能帶來的本質成果就是:一個服務端程序可以同時處理多個套接字描述符。

  • 多路:多個客戶端連線(連線就是套接字描述符)
  • 複用:使用單程序就能夠實現同時處理多個客戶端的連線

在之前的講述中,一個服務端程序,只能同時處理一個連線。如果想同時處理多個客戶端連線,需要多程序或者多執行緒的幫助,免不了上下文切換的開銷。IO多路複用技術就解決了上下文切換的問題。IO多路複用技術的發展可以分為select->poll->epoll三個階段。

IO多路複用的核心就是添加了一個套接字集合管理員,它可以同時監聽多個套接字。由於客戶端連線以及讀寫事件到來的隨機性,我們需要這個管理員在單程序內部對多個套接字的事件進行合理的排程。

select

最早的套接字集合管理員是select()系統呼叫,它可以同時管理多個套接字。select()函式會在某個或某些套接字的狀態從不可讀變為可讀、或不可寫變為可寫的時候通知伺服器主程序。所以select()本身的呼叫是阻塞的。但是具體哪一個套接字或哪些套接字變為可讀或可寫我們是不知道的,所以我們需要遍歷所有select()返回的套接字來判斷哪些套接字可以進行處理了。而這些套接字中又可以分為監聽套接字與連線套接字(上文提過)。我們可以使用PHP為我們提供的socket_select()函式。在select()的函式原型中,為套接字們分了個類:讀、寫與異常套接字集合,分別監聽套接字的讀、寫與異常事件。:

functionsocket_select(array &$read, array &$write, array &$except, $tv_sec, $tv_usec =0){}

舉個例子,如果某個客戶單通過呼叫connect()連線到了伺服器的監聽套接字($listenSocket)上,這個監聽套接字的狀態就會從不可讀變為可讀。由於監聽套接字只有一個,select()對於監聽套接字上的處理仍然是阻塞的。一個監聽套接字,存在於整個伺服器的生命週期中,所以在select()的實現中並不能體現出其對監聽套接字的優化管理。
在當一個伺服器使用accept()接受多個客戶端連線,並生成了多個連線套接字之後,select()的管理才能就會體現出來。這個時候,select()的監聽列表中有一個監聽套接字、和與一堆客戶端建立連線後新建立的連線套接字。在這個時候,可能這一堆已建立連線的客戶端,都會通過這個連線套接字傳送資料,等待服務端接收。假設同時有5個連線套接字都有資料傳送,那麼這5個連線套接字的狀態都會變成可讀狀態。由於已經有套接字變成了可讀狀態,select()函式解除阻塞,立即返回。具體哪一個套接字或哪些套接字變為可讀或可寫我們是不知道的,所以我們需要遍歷所有select()返回的套接字,來判斷哪些套接字已經就緒,可以進行讀寫處理。遍歷完畢之後,就知道有5個連線套接字可以進行讀寫處理,這樣就實現了同時對多個套接字的管理。使用PHP實現select()的程式碼如下:

<?php
if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
    echo '套接字建立失敗';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
    echo '繫結地址與埠失敗';
}
if (socket_listen($listenSocket) === false) {
    echo '轉換主動套接字到被動套接字失敗';
}

/* 要監聽的三個sockets陣列 */
$read_socks = array(); //讀
$write_socks = array(); //寫
$except_socks = NULL; //異常

$read_socks[] = $listenSocket; //將初始的監聽套接字加入到select的讀事件監聽陣列中

while (1) {
    /* 由於select()是引用傳遞,所以這兩個陣列會被改變,所以用兩個臨時變數 */
    $tmp_reads = $read_socks;
    $tmp_writes = $write_socks;
    $count = socket_select($tmp_reads, $tmp_writes, $except_socks, NULL);
    foreach ($tmp_reads as $read) { //不知道哪些套接字有變化,需要對全體套接字進行遍歷來看誰變了
        if ($read == $listenSocket) { //監聽套接字有變化,說明有新的客戶端連線請求到來
            $connSocket = socket_accept($listenSocket);  //響應客戶端連線, 此時一定不會阻塞
            if ($connSocket) {
                //把新建立的連線socket加入監聽
                $read_socks[] = $connSocket;
                $write_socks[] = $connSocket;
            }
        } else { //新建立的連線套接字有變化
            /*客戶端傳輸資料 */
            $data = socket_read($read, 1024);  //從客戶端讀取資料, 此時一定會讀到資料,不會產生阻塞
            if ($data === '') { //已經無法從連線套接字中讀到資料,需要移除對該socket的監聽
                foreach ($read_socks as $key => $val) {
                    if ($val == $read) unset($read_socks[$key]); //移除失效的套接字
                }
                foreach ($write_socks as $key => $val) {
                    if ($val == $read) unset($write_socks[$key]);
                }
                socket_close($read);
            } else { //能夠從連線套接字讀到資料。此時$read是連線套接字
                if (in_array($read, $tmp_writes)) {
                    socket_write($read, $data);//如果該客戶端可寫 把資料寫回到客戶端
                }
            }
        }
    }
}
socket_close($listenSocket);

但是,select()函式本身的呼叫阻塞的。因為select()需要一直等到有狀態變化的套接字之後(比如監聽套接字或者連線套接字的狀態由不可讀變為可讀),才能解除select()本身的阻塞,繼續對讀寫就緒的套接字進行處理。雖然這裡是阻塞的,但是它能夠同時返回多個就緒的套接字,而不是之前單程序中只能夠處理一個套接字,大大提升了效率
總結一下,select()的過人之處有以下幾點:

  • 實現了對多個套接字的同時、集中管理
  • 通過遍歷所有的套接字集合,能夠獲取所有已就緒的套接字,對這些就緒的套接字進行操作不會阻塞

但是,select()仍存在幾個問題:

  • select管理的套接字描述符們存在數量限制。在Unix中,一個程序最多同時監聽1024個套接字描述符
  • select返回的時候,並不知道具體是哪個套接字描述符已經就緒,所以需要遍歷所有套接字來判斷哪個已經就緒,可以繼續進行讀寫

為了解決第一個套接字描述符數量限制的問題,聰明的開發者們想出了poll這個新套接字描述符管理員,用以替換select這個老管理員,select()就可以安心退休啦。

poll

poll解決了select帶來的套接字描述符的最大數量限制問題。由於PHP的socket擴充套件沒有poll對應的實現,所以這裡放一個Unix的C語言原型實現:

intpoll(struct pollfd *fds,unsignedintnfds,inttimeout);

poll的fds引數集合了select的read、write和exception套接字陣列,合三為一。poll中的fds沒有了1024個的數量限制。當有些描述符狀態發生變化並就緒之後,poll同select一樣會返回。但是遺憾的是,我們同樣不知道具體是哪個或哪些套接字已經就緒,我們仍需要遍歷套接字集合去判斷究竟是哪個套接字已經就緒,這一點並沒有解決剛才提到select的第二個問題。
我們可以總結一下,select和poll這兩種實現,都需要在返回後,通過遍歷所有的套接字描述符來獲取已經就緒的套接字描述符。事實上,同時連線的大量客戶端在一時刻可能只有很少的處於就緒狀態,因此隨著監視的描述符數量的增長,其效率也會線性下降。
為了解決不知道返回之後究竟是哪個或哪些描述符已經就緒的問題,同時避免遍歷所有的套接字描述符,聰明的開發者們又發明出了epoll機制,完美解決了select和poll所存在的問題。

epoll

epoll是最先進的套接字們的管理員,解決了上述select和poll中所存在的問題。它將一個阻塞的select、poll系統呼叫拆分成了三個步驟。一次select或poll可以看作是由一次 epoll_create、若干次 epoll_ctl、若干次 epoll_wait構成:

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  • epoll_create():建立一個epoll例項。後續操作會使用
  • epoll_ctl():對套接字描述符集合進行增刪改操作,並告訴核心需要監聽套接字描述符的什麼事件
  • epoll_wait():等待監聽列表中的連線事件(監聽套接字描述符才會發生)或讀寫事件(連線套接字描述符才會發生)。如果有某個或某些套接字事件已經準備就緒,就會返回這些已就緒的套接字們

看起來,這三個函式明明就是從select、poll一個函式拆成三個函數了嘛。我們對某套接字描述符的新增、刪除、修改操作由之前的程式碼實現變成了呼叫epoll_ctl()來實現。epoll_ctl()的引數含義如下:

  • epfd:epoll_create()的返回值
  • op:表示對下面套接字描述符fd所進行的操作。EPOLL_CTL_ADD:將描述符新增到監聽列表;EPOLL_CTL_DEL:不再監聽某描述符;EPOLL_CTL_MOD:修改某描述符
  • fd:上面op操作的套接字描述符物件(之前在PHP中是listenSocket與listenSocket與connSocket兩種套接字描述符)例如將某個套接字新增到監聽列表中
  • event:告訴核心需要監聽該套接字描述符的什麼事件(如讀寫、連線等)

最後我們呼叫epoll_wait()等待連線或讀寫等事件,在某個套接字描述符上準備就緒。當有事件準備就緒之後,會存到第二個引數epoll_event結構體中。通過訪問這個結構體就可以得到所有已經準備好事件的套接字描述符。這裡就不用再像之前select和poll那樣,遍歷所有的套接字描述符之後才能知道究竟是哪個描述符已經準備就緒了,這樣減少了一次O(n)的遍歷,大大提高了效率。
在最後返回的所有套接字描述符中,同樣存在之前說過的兩種描述符:監聽套接字描述符和連線套接字描述符。那麼我們需要遍歷所有準備就緒的描述符,然後去判斷究竟是監聽還是連線套接字描述符,然後視情況做做出accept(監聽套接字)或者是read(連線套接字)的處理。一個使用C語言編寫的epoll伺服器的虛擬碼如下(重點關注程式碼註釋):

int main(int argc, char *argv[]) {

    listenSocket = socket(AF_INET, SOCK_STREAM, 0); //同上,建立一個監聽套接字描述符
    
    bind(listenSocket)  //同上,繫結地址與埠
    
    listen(listenSocket) //同上,由預設的主動套接字轉換為伺服器適用的被動套接字
    
    epfd = epoll_create(EPOLL_SIZE); //建立一個epoll例項
    
    ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE); //建立一個epoll_event結構儲存套接字集合
    event.events = EPOLLIN;
    event.data.fd = listenSocket;
    
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenSocket, &event); //將監聽套接字加入到監聽列表中
    
    while (1) {
    
        event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); //等待返回已經就緒的套接字描述符們
        
        for (int i = 0; i < event_cnt; ++i) { //遍歷所有就緒的套接字描述符
            if (ep_events[i].data.fd == listenSocket) { //如果是監聽套接字描述符就緒了,說明有一個新客戶端連線到來
            
                connSocket = accept(listenSocket); //呼叫accept()建立連線
                
                event.events = EPOLLIN;
                event.data.fd = connSocket;
                
                epoll_ctl(epfd, EPOLL_CTL_ADD, connSocket, &event); //新增對新建立的連線套接字描述符的監聽,以監聽後續在連線描述符上的讀寫事件
                
            } else { //如果是連線套接字描述符事件就緒,則可以進行讀寫
            
                strlen = read(ep_events[i].data.fd, buf, BUF_SIZE); //從連線套接字描述符中讀取資料, 此時一定會讀到資料,不會產生阻塞
                if (strlen == 0) { //已經無法從連線套接字中讀到資料,需要移除對該socket的監聽
                
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); //刪除對這個描述符的監聽
                    
                    close(ep_events[i].data.fd);
                } else {
                    write(ep_events[i].data.fd, buf, str_len); //如果該客戶端可寫 把資料寫回到客戶端
                }
            }
        }
    }
    close(listenSocket);
    close(epfd);
    return 0;
}

我們看這個通過epoll實現一個IO多路複用伺服器的程式碼結構,除了由一個函式拆分成三個函式,其餘的執行流程基本同select、poll相似。只是epoll會只返回已經就緒的套接字描述符集合,而不是所有描述符的集合,IO的效率不會隨著監視fd的數量的增長而下降,大大提升了效率。同時它細化並規範了對每個套接字描述符的管理(如增刪改的過程)。此外,它監聽的套接字描述符是沒有限制的,這樣,之前select、poll的遺留問題就全部解決啦。

總結

我們從最基本網路程式設計說起,開始從一個最簡單的同步阻塞伺服器到一個IO多路複用伺服器,我們從頭到尾瞭解到了一個伺服器效能提升的思考與實現過程。而提升伺服器的併發效能的方式遠不止這幾種,還包括協程等新的概念需要我們去對比與分析,大家加油。