[連載] Socket 深度探究 四 PHP (一)
來源:互聯網
上載者:User
[連載] Socket 深度探究 4 PHP (一)
[連載] Socket 深度探究 4 PHP (一)
2011年05月05日
Socket(通訊端)一直是網路層的底層核心內容,也是 TCP/IP 以及 UDP 底層協議的實現通道。隨著互連網資訊時代的爆炸式發展,當代伺服器的效能問題面臨越來越大的挑戰,著名的 C10K 問題(http://www.kegel.com/c10k.html)也隨之出現。幸虧通過大牛們的不懈努力,區別於傳統的 select/poll 的 epoll/kqueue 方式出現了,目前 linux2.6 以上的核心都普遍支援,這是 Socket 領域一項巨大的進步,不僅解決了 C10K 問題,也漸漸成為了當代互連網的底層核心技術。libevent 庫就是其中一個比較出彩的項目(現在非常多的開源項目都有用到,包括 Memcached),感興趣的朋友可以研究一下。
由於網路上系統介紹這個部分的文章並不多,而涉及 PHP 的就更少了,所以石頭君在這裡希望通過《Socket深度探究4PHP》這個系列給對這個領域感興趣的讀者們一定的協助,也希望大家能和我一起對這個問題進行更深入的探討。首先,解釋一下目前 Socket 領域比較易於混淆的概念有:阻塞/非阻塞、同步/非同步、多工等。
1、阻塞/非阻塞:這兩個概念是針對 IO 過程中進程的狀態來說的,阻塞 IO 是指調用結果返回之前,當前線程會被掛起;相反,非阻塞指在不能立刻得到結果之前,該函數不會阻塞當前線程,而會立刻返回。
2、同步/非同步:這兩個概念是針對調用如果返回結果來說的,所謂同步,就是在發出一個功能調用時,在沒有得到結果之前,該調用就不返回;相反,當一個非同步程序呼叫發出後,調用者不能立刻得到結果,實際處理這個調用的組件在完成後,通過狀態、通知和回調來通知調用者。
3、多工(IO/Multiplexing):為了提高資料資訊在網路通訊線路中傳輸的效率,在一條物理通訊線路上建立多條邏輯通訊通道,同時傳輸若干路訊號的技術就叫做多工技術。對於 Socket 來說,應該說能同時處理多個串連的模型都應該被稱為多工,目前比較常用的有 select/poll/epoll/kqueue 這些 IO 模型(目前也有像 Apache 這種每個串連用單獨的進程/線程來處理的 IO 模型,但是效率相對比較差,也很容易出問題,所以暫時不做介紹了)。在這些多工模式中,非同步阻塞/非阻塞模式的擴充性和效能最好。
感覺概念很抽象對吧,"一切答案在於現場",下面讓我們從三種經典的 PHP Socket IO 模型執行個體來對以上的概念再做一次分析:
1、使用 accept 阻塞的古老模型:屬於同步阻塞 IO 模型,代碼如下:
socket_server.php
**/ set_time_limit(0); class SocketServer { private static $socket; function SocketServer($port) { global $errno, $errstr; if ($port 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
Socket Test Client * By James.Huang **/ 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 等待 } fwrite($socket_client, "\r\n"); // 傳輸結束符 debug(fread($socket_client, 1024)); fclose($socket_client); } } else { // $phArr = array(); // for ($i = 0; $i 發送資料,最後發送結束符;服務端 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 預設是阻塞的,我們在編程的時候要特別注意,如果沒有設定逾時,就很容易會出現死結。
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 行的代碼,如果串連中斷則跳出迴圈,但是這樣一來就會有很多資料丟失了,這個問題需要特別注意!
2、使用 select/poll 的同步模型:屬於同步非阻塞 IO 模型,代碼如下:
select_server.php
**/ 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 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
**/ 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 等待 } fwrite($socket_client, "quit\n"); // add end token debug(fread($socket_client, 1024)); fclose($socket_client); } } else { $phArr = array(); for ($i = 0; $i 這裡如果我們執行 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 模式最大的問題所在,所以如果是超高並發伺服器建議使用下一種模式。
3、使用 epoll/kqueue 的非同步模型:屬於非同步阻塞/非阻塞 IO 模型,代碼如下:
epoll_server.php
* * 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 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
**/ 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 返回結果(接收到的位元組數)。但是,當你運行 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/libevent/。
到這裡,第一部分的內容結束了,相信大家已經瞭解了 Socket 編程的幾個重點概念和一些實戰技巧,在下一篇《Socket深度探究4PHP(二)》我將會對 select/poll/epoll/kqueue 幾種模式做一下深入的介紹和對比,另外也會涉及到兩種重要的 I/O 多工模式:Reactor 和 Proactor 模式。
To be continued ...