PHP Socket 深度探索 (一)

簡介

Socket(套接字)一直是網絡層的底層核心內容,也是 TCP/IP 以及 UDP 底層協議的實現通道。隨着互聯網信息時代的爆炸式發展,當代服務器的性能問題面臨愈來愈大的挑戰,著名的 C10K 問題(http://www.kegel.com/c10k.html)也隨之出現。幸好經過大牛們的不懈努力,區別於傳統的 select/poll 的 epoll/kqueue 方式出現了,目前 linux2.6 以上的內核都廣泛支持,這是 Socket 領域一項巨大的進步,不只解決了 C10K 問題,也漸漸成爲了當代互聯網的底層核心技術。libevent 庫就是其中一個比較出彩的項目(如今很是多的開源項目都有用到,包括 Memcached),感興趣的朋友能夠研究一下。php

因爲網絡上系統介紹這個部分的文章並很少,而涉及 PHP 的就更少了,因此石頭君在這裏但願經過《Socket深度探究4PHP》這個系列給對這個領域感興趣的讀者們必定的幫助,也但願你們能和我一塊兒對這個問題進行更深刻的探討。首先,解釋一下目前 Socket 領域比較易於混淆的概念有:阻塞/非阻塞、同步/異步、多路複用等。html

閱讀準備

一、阻塞/非阻塞:這兩個概念是針對 IO 過程當中進程的狀態來講的,阻塞 IO 是指調用結果返回以前,當前線程會被掛起;相反,非阻塞指在不能馬上獲得結果以前,該函數不會阻塞當前線程,而會馬上返回。linux

二、同步/異步:這兩個概念是針對調用若是返回結果來講的,所謂同步,就是在發出一個功能調用時,在沒有獲得結果以前,該調用就不返回;相反,當一個異步過程調用發出後,調用者不能馬上獲得結果,實際處理這個調用的部件在完成後,經過狀態、通知和回調來通知調用者。編程

三、多路複用(IO/Multiplexing):爲了提升數據信息在網絡通訊線路中傳輸的效率,在一條物理通訊線路上創建多條邏輯通訊信道,同時傳輸若干路信號的技術就叫作多路複用技術。對於 Socket 來講,應該說能同時處理多個鏈接的模型都應該被稱爲多路複用,目前比較經常使用的有 select/poll/epoll/kqueue 這些 IO 模型(目前也有像 Apache 這種每一個鏈接用單獨的進程/線程來處理的 IO 模型,可是效率相對比較差,也很容易出問題,因此暫時不作介紹了)。在這些多路複用的模式中,異步阻塞/非阻塞模式的擴展性和性能最好。緩存

同步阻塞IO模型

socket_server.php服務器

<?php  
/** 
 * SocketServer Class 
 * By James.Huang <shagoo#gmail.com> 
**/  
set_time_limit(0);  
class SocketServer   
{  
    private static $socket;  
    function SocketServer($port)   
    {  
        global $errno, $errstr;  
        if ($port < 1024) {  
            die("Port must be a number which bigger than 1024/n");  
        }  
          
        $socket = stream_socket_server("tcp://0.0.0.0:{$port}", $errno, $errstr);  
        if (!$socket) die("$errstr ($errno)");  
          
//      stream_set_timeout($socket, -1); // 保證服務端 socket 不會超時,彷佛沒用:)  
          
        while ($conn = stream_socket_accept($socket, -1)) { // 這樣設置不超時才油用  
            static $id = 0;  
            static $ct = 0;  
            $ct_last = $ct;  
            $ct_data = '';  
            $buffer = '';  
            $id++; // increase on each accept  
            echo "Client $id come./n";  
            while (!preg_match('//r?/n/', $buffer)) { // 沒有讀到結束符,繼續讀  
//              if (feof($conn)) break; // 防止 popen 和 fread 的 bug 致使的死循環  
                $buffer = fread($conn, 1024);  
                echo 'R'; // 打印讀的次數  
                $ct += strlen($buffer);  
                $ct_data .= preg_replace('//r?/n/', '', $buffer);  
            }  
            $ct_size = ($ct - $ct_last) * 8;  
            echo "[$id] " . __METHOD__ . " > " . $ct_data . "/n";  
            fwrite($conn, "Received $ct_size byte data./r/n");  
            fclose($conn);  
        }  
          
        fclose($socket);  
    }  
}  
new SocketServer(2000);

socket_client.php網絡

<?php  
/** 
 * Socket Test Client 
 * By James.Huang <shagoo#gmail.com> 
**/  
function debug ($msg)  
{  
//  echo $msg;  
    error_log($msg, 3, '/tmp/socket.log');  
}  
if ($argv[1]) {  
      
    $socket_client = stream_socket_client('tcp://0.0.0.0:2000', $errno, $errstr, 30);  
      
//  stream_set_blocking($socket_client, 0);  
//  stream_set_timeout($socket_client, 0, 100000);  
      
    if (!$socket_client) {  
        die("$errstr ($errno)");  
    } else {  
        $msg = trim($argv[1]);  
        for ($i = 0; $i < 10; $i++) {  
            $res = fwrite($socket_client, "$msg($i)");  
            usleep(100000);  
            echo 'W'; // 打印寫的次數  
//          debug(fread($socket_client, 1024)); // 將產生死鎖,由於 fread 在阻塞模式下未讀到數據時將等待  
        }  
        fwrite($socket_client, "/r/n"); // 傳輸結束符  
        debug(fread($socket_client, 1024));  
        fclose($socket_client);  
    }  
}  
else {  
      
//  $phArr = array();  
//  for ($i = 0; $i < 10; $i++) {  
//      $phArr[$i] = popen("php ".__FILE__." '{$i}:test'", 'r');  
//  }  
//  foreach ($phArr as $ph) {  
//      pclose($ph);  
//  }  
      
    for ($i = 0; $i < 10; $i++) {  
        system("php ".__FILE__." '{$i}:test'");  
    }  
}

代碼分析

首先,解釋一下以上的代碼邏輯:客戶端 socket_client.php 循環發送數據,最後發送結束符;服務端 socket_server.php 使用 accept 阻塞方式接收 socket 鏈接,而後循環接收數據,直到收到結束符,返回結果數據(接收到的字節數)。雖然邏輯很簡單,可是其中有幾種狀況很值得分析一下:併發

A> 默認狀況下,運行 php socket_client.php test,客戶端打出 10 個 W,服務端打出若干個 R 後面是接收到的數據,/tmp/socket.log 記錄下服務端返回的接收結果數據。這種狀況很容易理解,再也不贅述。而後,使用 telnet 命令同時打開多個客戶端,你會發現服務器一個時間只處理一個客戶端,其餘須要在後面「排隊」;這就是阻塞 IO 的特色,這種模式的弱點很明顯,效率極低。異步

B> 只打開 socket_client.php 第 26 行的註釋代碼,再次運行 php socket_client.php test 客戶端打出一個 W,服務端也打出一個 R,以後兩個程序都卡住了。這是爲何呢,分析邏輯後你會發現,這是因爲客戶端在未發送結束符以前就向服務端要返回數據;而服務端因爲未收到結束符,也在向客戶端要結束符,形成死鎖。而之因此只打出一個 W 和 R,是由於 fread 默認是阻塞的。要解決這個死鎖,必須打開 socket_client.php 第 16 行的註釋代碼,給 socket 設置一個 0.1 秒的超時,再次運行你會發現隔 0.1 秒出現一個 W 和 R 以後正常結束,服務端返回的接收結果數據也正常記錄了。可見 fread 缺省是阻塞的,咱們在編程的時候要特別注意,若是沒有設置超時,就很容易會出現死鎖。socket

C> 只打開 15 行註釋,運行 php socket_client.php test,結果基本和狀況 A 相同,惟一不一樣的是 /tmp/socket.log 沒有記錄下返回數據。這裏能夠看出客戶端運行在阻塞和非阻塞模式的區別,固然在客戶端不在意接受結果的狀況下,可使用非阻塞模式來得到最大效率。

D> 運行 php socket_client.php 是連續運行 10 次上面的邏輯,這個沒什麼問題;可是很奇怪的是若是你使用 35 - 41 行的代碼,用 popen 同時開啓 10 個進程來運行,就會形成服務器端的死循環,十分怪異!後來經調查發現只要是用 popen 打開的進程建立的鏈接會致使 fread 或者 socket_read 出錯直接返回空字串,從而致使死循環,查閱 PHP 源代碼後發現 PHP 的 popen 和 fread 函數已經徹底不是 C 原生的了,裏面都插入了大量的 php_stream_* 實現邏輯,初步估計是其中的某個 bug 致使的 Socket 鏈接中斷所致使的,解決方法就是打開 socket_server.php 中 31 行的代碼,若是鏈接中斷則跳出循環,可是這樣一來就會有不少數據丟失了,這個問題須要特別注意!

同步非阻塞IO模型

select_server.php

<?php  
/** 
 * SelectSocketServer Class 
 * By James.Huang <shagoo#gmail.com> 
**/  
set_time_limit(0);  
class SelectSocketServer   
{  
    private static $socket;  
    private static $timeout = 60;  
    private static $maxconns = 1024;  
    private static $connections = array();  
    function SelectSocketServer($port)   
    {  
        global $errno, $errstr;  
        if ($port < 1024) {  
            die("Port must be a number which bigger than 1024/n");  
        }  
          
        $socket = socket_create_listen($port);  
        if (!$socket) die("Listen $port failed");  
          
        socket_set_nonblock($socket); // 非阻塞  
          
        while (true)   
        {  
            $readfds = array_merge(self::$connections, array($socket));  
            $writefds = array();  
              
            // 選擇一個鏈接,獲取讀、寫鏈接通道  
            if (socket_select($readfds, $writefds, $e = null, $t = self::$timeout))   
            {  
                // 若是是當前服務端的監聽鏈接  
                if (in_array($socket, $readfds)) {  
                    // 接受客戶端鏈接  
                    $newconn = socket_accept($socket);  
                    $i = (int) $newconn;  
                    $reject = '';  
                    if (count(self::$connections) >= self::$maxconns) {  
                        $reject = "Server full, Try again later./n";  
                    }  
                    // 將當前客戶端鏈接放入 socket_select 選擇  
                    self::$connections[$i] = $newconn;  
                    // 輸入的鏈接資源緩存容器  
                    $writefds[$i] = $newconn;  
                    // 鏈接不正常  
                    if ($reject) {  
                        socket_write($writefds[$i], $reject);  
                        unset($writefds[$i]);  
                        self::close($i);  
                    } else {  
                        echo "Client $i come./n";  
                    }  
                    // remove the listening socket from the clients-with-data array  
                    $key = array_search($socket, $readfds);  
                    unset($readfds[$key]);  
                }  
                  
                // 輪循讀通道  
                foreach ($readfds as $rfd) {  
                    // 客戶端鏈接  
                    $i = (int) $rfd;  
                    // 從通道讀取  
                    $line = @socket_read($rfd, 2048, PHP_NORMAL_READ);  
                    if ($line === false) {  
                        // 讀取不到內容,結束鏈接            
                        echo "Connection closed on socket $i./n";  
                        self::close($i);  
                        continue;  
                    }  
                    $tmp = substr($line, -1);  
                    if ($tmp != "/r" && $tmp != "/n") {  
                        // 等待更多數據  
                        continue;  
                    }  
                    // 處理邏輯  
                    $line = trim($line);  
                    if ($line == "quit") {  
                        echo "Client $i quit./n";  
                        self::close($i);  
                        break;  
                    }  
                    if ($line) {  
                        echo "Client $i >>" . $line . "/n";  
                    }  
                }  
                  
                // 輪循寫通道  
                foreach ($writefds as $wfd) {  
                    $i = (int) $wfd;  
                    $w = socket_write($wfd, "Welcome Client $i!/n");  
                }  
            }  
        }  
    }  
      
    function close ($i)   
    {  
        socket_shutdown(self::$connections[$i]);  
        socket_close(self::$connections[$i]);  
        unset(self::$connections[$i]);  
    }  
}  
new SelectSocketServer(2000);

select_client.php

<?php  
/** 
 * SelectSocket Test Client 
 * By James.Huang <shagoo#gmail.com> 
**/  
function debug ($msg)  
{  
//  echo $msg;  
    error_log($msg, 3, '/tmp/socket.log');  
}  
if ($argv[1]) {  
      
    $socket_client = stream_socket_client('tcp://0.0.0.0:2000', $errno, $errstr, 30);  
      
//  stream_set_timeout($socket_client, 0, 100000);  
      
    if (!$socket_client) {  
        die("$errstr ($errno)");  
    } else {  
        $msg = trim($argv[1]);  
        for ($i = 0; $i < 10; $i++) {  
            $res = fwrite($socket_client, "$msg($i)/n");  
            usleep(100000);  
//          debug(fread($socket_client, 1024)); // 將產生死鎖,由於 fread 在阻塞模式下未讀到數據時將等待  
        }  
        fwrite($socket_client, "quit/n"); // add end token  
        debug(fread($socket_client, 1024));  
        fclose($socket_client);  
    }  
}  
else {  
      
    $phArr = array();  
    for ($i = 0; $i < 10; $i++) {  
        $phArr[$i] = popen("php ".__FILE__." '{$i}:test'", 'r');  
    }  
    foreach ($phArr as $ph) {  
        pclose($ph);  
    }  
      
//  for ($i = 0; $i < 10; $i++) {  
//      system("php ".__FILE__." '{$i}:test'");  
//  }  
}

代碼分析

以上代碼的邏輯也很簡單,select_server.php 實現了一個相似聊天室的功能,你可使用 telnet 工具登陸上去,和其餘用戶文字聊天,也能夠鍵入「quit」命令離開;而 select_client.php 則模擬了一個登陸用戶連續發 10 條信息,而後退出。這裏也分析兩個問題:

A> 這裏若是咱們執行 php select_client.php 程序將會同時打開 10 個鏈接,同時進行模擬登陸用戶操做;觀察服務端打印的數據你會發現服務端確實是在同時處理這些鏈接,這就是多路複用實現的非阻塞 IO 模型,固然這個模型並無真正的實現異步,由於最終服務端程序仍是要去通道里面讀取數據,獲得結果後同步返回給客戶端。若是此次你也使用 telnet 命令同時打開多個客戶端,你會發現服務端能夠同時處理這些鏈接,這就是非阻塞 IO,固然比古老的阻塞 IO 效率要高多了,可是這種模式仍是有侷限的,繼續看下去你就會發現了~

B> 我在 select_server.php 中設置了幾個參數,你們能夠調整試試:
$timeout :表示的是 select 的超時時間,這個通常來講不要過短,不然會致使 CPU 負載太高。
$maxconns :表示的是最大鏈接數,客戶端超過這個數的話,服務器會拒絕接收。這裏要提到的一點是,因爲 select 是經過句柄來讀寫的,因此會受到系統默認參數 __FD_SETSIZE 的限制,通常默認值爲 1024,修改的話須要從新編譯內核;另外經過測試發現 select 模式的性能會隨着鏈接數的增大而線性便差(詳情見《Socket深度探究4PHP(二)》),這也就是 select 模式最大的問題所在,因此若是是超高併發服務器建議使用下一種模式。

異步非阻塞IO模型

epoll_server.php

<?php  
/** 
 * EpollSocketServer Class (use libevent) 
 * By James.Huang <shagoo#gmail.com> 
 *  
 * Defined constants: 
 *  
 * EV_TIMEOUT (integer) 
 * EV_READ (integer) 
 * EV_WRITE (integer) 
 * EV_SIGNAL (integer) 
 * EV_PERSIST (integer) 
 * EVLOOP_NONBLOCK (integer) 
 * EVLOOP_ONCE (integer) 
**/  
set_time_limit(0);  
class EpollSocketServer  
{  
    private static $socket;  
    private static $connections;  
    private static $buffers;  
      
    function EpollSocketServer ($port)  
    {  
        global $errno, $errstr;  
          
        if (!extension_loaded('libevent')) {  
            die("Please install libevent extension firstly/n");  
        }  
          
        if ($port < 1024) {  
            die("Port must be a number which bigger than 1024/n");  
        }  
          
        $socket_server = stream_socket_server("tcp://0.0.0.0:{$port}", $errno, $errstr);  
        if (!$socket_server) die("$errstr ($errno)");  
          
        stream_set_blocking($socket_server, 0); // 非阻塞  
          
        $base = event_base_new();  
        $event = event_new();  
        event_set($event, $socket_server, EV_READ | EV_PERSIST, array(__CLASS__, 'ev_accept'), $base);  
        event_base_set($event, $base);  
        event_add($event);  
        event_base_loop($base);  
          
        self::$connections = array();  
        self::$buffers = array();  
    }  
      
    function ev_accept($socket, $flag, $base)   
    {  
        static $id = 0;  
      
        $connection = stream_socket_accept($socket);  
        stream_set_blocking($connection, 0);  
      
        $id++; // increase on each accept  
      
        $buffer = event_buffer_new($connection, array(__CLASS__, 'ev_read'), array(__CLASS__, 'ev_write'), array(__CLASS__, 'ev_error'), $id);  
        event_buffer_base_set($buffer, $base);  
        event_buffer_timeout_set($buffer, 30, 30);  
        event_buffer_watermark_set($buffer, EV_READ, 0, 0xffffff);  
        event_buffer_priority_set($buffer, 10);  
        event_buffer_enable($buffer, EV_READ | EV_PERSIST);  
      
        // we need to save both buffer and connection outside  
        self::$connections[$id] = $connection;  
        self::$buffers[$id] = $buffer;  
    }  
      
    function ev_error($buffer, $error, $id)   
    {  
        event_buffer_disable(self::$buffers[$id], EV_READ | EV_WRITE);  
        event_buffer_free(self::$buffers[$id]);  
        fclose(self::$connections[$id]);  
        unset(self::$buffers[$id], self::$connections[$id]);  
    }  
      
    function ev_read($buffer, $id)   
    {  
        static $ct = 0;  
        $ct_last = $ct;  
        $ct_data = '';  
        while ($read = event_buffer_read($buffer, 1024)) {  
            $ct += strlen($read);  
            $ct_data .= $read;  
        }  
        $ct_size = ($ct - $ct_last) * 8;  
        echo "[$id] " . __METHOD__ . " > " . $ct_data . "/n";  
        event_buffer_write($buffer, "Received $ct_size byte data./r/n");  
    }  
      
    function ev_write($buffer, $id)   
    {  
        echo "[$id] " . __METHOD__ . "/n";  
    }  
}  
new EpollSocketServer(2000);

epoll_client.php

<?php  
/** 
 * EpollSocket Test Client 
 * By James.Huang <shagoo#gmail.com> 
**/  
function debug ($msg)  
{  
//  echo $msg;  
    error_log($msg, 3, '/tmp/socket.log');  
}  
if ($argv[1]) {  
    $socket_client = stream_socket_client('tcp://0.0.0.0:2000', $errno, $errstr, 30);  
//  stream_set_blocking($socket_client, 0);  
    if (!$socket_client) {  
        die("$errstr ($errno)");  
    } else {  
        $msg = trim($argv[1]);  
        for ($i = 0; $i < 10; $i++) {  
            $res = fwrite($socket_client, "$msg($i)");  
            usleep(100000);  
            debug(fread($socket_client, 1024));  
        }  
        fclose($socket_client);  
    }  
}  
else {  
      
    $phArr = array();  
    for ($i = 0; $i < 10; $i++) {  
        $phArr[$i] = popen("php ".__FILE__." '{$i}:test'", 'r');  
    }  
    foreach ($phArr as $ph) {  
        pclose($ph);  
    }  
      
//  for ($i = 0; $i < 10; $i++) {  
//      system("php ".__FILE__." '{$i}:test'");  
//  }  
}

代碼解析

先說一下,以上的例子是基於 PHP 的 libevent 擴展實現的,須要運行的話要先安裝此擴展,參考:http://pecl.php.net/package/l...

這個例子作的事情和前面介紹的第一個模型同樣,epoll_server.php 實現的服務端也是接受客戶端數據,而後返回結果(接收到的字節數)。可是,當你運行 php epoll_client.php 的時候你會發現服務端打印出來的結果和 accept 阻塞模型就大不同了,固然運行效率也有極大的提高,這是爲何呢?接下來就介紹一下 epoll/kqueue 模型:在介紹 select 模式的時候咱們提到了這種模式的侷限,而 epoll 就是爲了解決 poll 的這兩個缺陷而生的。首先,epoll 模式基本沒有限制(參考 cat /proc/sys/fs/file-max 默認就達到 300K,很使人興奮吧,其實這也就是所謂基於 epoll 的 Erlang 服務端能夠同時處理這麼多併發鏈接的根本緣由,不過如今 PHP 理論上也能夠作到了,呵呵);另外,epoll 模式的性能也不會像 select 模式那樣隨着鏈接數的增大而變差,測試發現性能仍是很穩定的(下篇會有詳細介紹)。

epoll 工做有兩種模式 LT(level triggered) 和 ET(edge-triggered),前者是缺省模式,同時支持阻塞和非阻塞 IO 模式,雖然性能比後者差點,可是比較穩定,通常來講在實際運用中,咱們都是用這種模式(ET 模式和 WinSock 都是純異步非阻塞模型)。而另一點要說的是 libevent 是在編譯階段選擇系統的 I/O demultiplex 機制的,不支持在運行階段根據配置再次選擇,因此咱們在這裏也就不細討論 libevent 的實現的細節了,若是朋友有興趣進一步瞭解的話,請參考:http://monkey.org/~provos/lib...

到這裏,第一部分的內容結束了,相信你們已經瞭解了 Socket 編程的幾個重點概念和一些實戰技巧,在下一篇《Socket深度探究4PHP(二) 》我將會對 select/poll/epoll/kqueue 幾種模式作一下深刻的介紹和對比,另外也會涉及到兩種重要的 I/O 多路複用模式:Reactor 和 Proactor 模式。

To be continued ...

相關文章
相關標籤/搜索