【業務學習】淺析服務器併發IO性能提高之路 — 從網絡編程基礎到epoll

baiyanphp

從網絡編程基本概念提及

咱們經常使用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(靜態進程數量)模式:socket

  • 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與$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多路複用服務器,咱們從頭至尾瞭解到了一個服務器性能提高的思考與實現過程。而提高服務器的併發性能的方式遠不止這幾種,還包括協程等新的概念須要咱們去對比與分析,你們加油。

相關文章
相關標籤/搜索