天天看點

淺析伺服器并發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()的函數原型中,為套接字們分了個類:讀、寫與異常套接字集合,分别監聽套接字的讀、寫與異常事件。:

function socket_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語言原型實作:

int poll (struct pollfd *fds, unsigned int nfds, int timeout);           

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多路複用伺服器,我們從頭到尾了解到了一個伺服器性能提升的思考與實作過程。而提升伺服器的并發性能的方式遠不止這幾種,還包括協程等新的概念需要我們去對比與分析,大家加油。

原文連結:https://www.cnblogs.com/shoshana-kong/p/14073277.html

繼續閱讀