PHP 如何發起異步請求

有人說,限制激發創造力。如果真這樣,PHP就是成熟的創造性解決方案。我剛上週構建了調用Segment.io的API的PHP庫,發現了各種不同的方法可以提高服務端請求性能。

設計客戶端類向API發送數據時,我們的首要任務之一就是保證我的代碼不影響到你的核心程序。這是很棘手的,尤其是使用單線程,無共享的語言,如PHP。

服務商PHP安裝方式很多,讓問題更復雜。幸運的,你的服務商允許你創建進程,寫入文件和安裝自己的擴展。不幸的話,你就得和一些糾結的鄰居分享同一個安裝配置,只能上傳文件。

理想狀態,我們喜歡用最小的滿足實現各種情況。當運行PHP時(可能就一兩個腳本),你應該深入理解這些。

我們嘗試用三種主要方法實現PHP發出請求,以下就是。

一:快速打開一個套接字(Socket)

搜索PHP異步請求,最先的結果都是相同的方法:寫一個Socket然後在等待返回前關閉它。

這個想法是開啓一次連接到服務端,連接好就寫入內容。Socket寫入是很快的,而且你不要返回信息,寫入後直接關閉連接。這就節省了等待一次往返的事件。

但是當你看StackOverflow上的評論,Socket到底發生了什麼有一些爭論。也讓我疑問:Socket怎麼實現的異步?

下面是我們的Socket實現:

01 <?php
02 private function request($body) {
03  
04     $protocol "ssl";
05     $host "api.segment.io";
06     $port = 443;
07     $path "/v1/" $body;
08     $timeout $this->options['timeout'];
09  
10     try {
11       # Open our socket to the API Server.
12       $socket fsockopen($protocol "://" $host$port,
13                           $errno$errstr$timeout);
14  
15       # Create the request body, and make the request.
16       $req $this->create_body($host$path$content);
17       fwrite($socket$req);
18       # ...
19     } catch (Exception $e) {
20       # ...
21     }
22 }
23 ?>

最初的結果並不樂觀。一次fsockopen花了300毫秒,偶爾更長。

事實證明,fsockopen是阻塞的——不是異步的!要瞭解到底發生了什麼,需要深入研究fsockopen是怎麼工作。當fsockopen選擇協議時,需要考慮使用哪種socket。這個過程在連接完成前是阻塞的

複習一下,internet的基本協議是TCP。它使電腦之間的信息傳遞可靠並有序。幾乎所有HTTP都運行於TCP上。我們用HTTP來簡化自定義的客戶端使用。

這是TCP Socket創建連接

  • 客戶端發送SYN消息給服務端
  • 服務端返回SYN-ACK消息確認包
  • 客戶端發送最終ACK包及傳送數據
作爲計時的部分,這是傳輸數據之前完成的完整來回,在fsockopen之前這就已經返回。一旦連接開啓,我們可以爲socket寫入數據。通常,需要30-100ms連接到我們的服務器。

TCP連接比較快,罪魁禍首是SSL需要的額外握手。SSL也實現在TCP上。TCP握手後又開始TLS握手

光SSL連接就需要三次握手,更不要說加上創建公共密匙的時間。

瀏覽器的SSL連接可以共享密匙,避免允許訪問的客戶端和服務端重複握手。可是PHP執行的Socket無法共享密匙,我們只能每次都是重新連接。

還可以使用socket_set_nonblock創建“非阻塞”的Socket。不過這是在打開Socket的時候不阻塞,你還是要等待完成才能寫入內容。如果精確考慮打開Socket寫入數據的時間,頁面加載會慢約100ms。

總結起來:

  • Socket可以在有權限限制的PHP上運行
  • fscokopen是阻塞的,即使不阻塞Socket也需要等待再寫入數據
  • SSL連接明顯減慢連接,因爲額外的握手和加密過程
  • 打開連接使頁面延遲100ms

二,寫日誌文件

如果你沒有其他系統權限的時候,Sokets是非常棒的方案。這兒我們介紹一種在性能上更好的方法,那就是把所有事件以日誌方式寫到文件。這個日誌文件可以被工作進程或者cron做"帶外"處理。

基於文件方法的優點是具有最小的API對外請求。當php代碼發出track 或者identify請求的時候,通過這種方法工作進程可以同時處理100個事件的請求,而不是僅一個請求。

這種方法的另外一個優點是php進程可以相對更快的記錄文件,一個寫操作往往只需要幾毫秒。當php打開一個文件句柄的時候,用fwrite進行追加寫是很簡單的操作。由於純php不具有“共享內存隊列”機制,在這日誌文件實際上和“共享內存隊列”具有異曲同工的效果。

爲了讀日誌文件,我利用analytics-pythonlibrary庫寫了一個python上傳腳本。爲了防止日誌文件太大,腳本自動進行更名操作。可以動態的寫php文件,還可以寫內存中的文件句柄,在老請求創建的地方,新請求會創建一個新的日誌文件。

這種方法沒有太多的邏輯,只要開發者多寫點cron任務,並且通過PyPI分別安裝我們的python庫。(這兩段感覺是在給他們的python庫做公告,既然python那麼好,用php幹嘛,bs)

方法總結(關鍵點):

  • 寫文件較快,系統資源開銷少。
  • 需要消耗磁盤空間,要求守護進程對文件有寫權限。
  • 必須運行工作進程處理帶外的記錄消息。

三:調用Curl過程

還有一個可選擇的方法,我們可以通過exec 操作curl工具來發出請求。curl請求纔可以做爲獨立進程一部分來完成,允許php代碼繼續執行,而不會阻塞socket連接。

這種方法的性能介於前面兩種方法之間,比soket方法快,比寫文件的方法花費更少的系統資源。

操作 forkd curl 方法,最簡單的例子如下:

01 <?php
02 private function request($url$payload) {
03  
04   $cmd "curl -X POST -H 'Content-Type: application/json'";
05   $cmd.= " -d '" $payload "' " "'" $url "'";
06  
07   if (!$this->debug()) {
08     $cmd .= " > /dev/null 2>&1 &";
09   }
10  
11   exec($cmd$output$exit);
12   return $exit == 0;
13 }
14 ?>

如果運行在生產模式,我們不希望等着fork進程的消息輸出。所以代碼中加添了"> /dev/null 2>&1 &"讓進程正確的執行 ,而把任何可能輸出都丟棄掉。

同樣功能的shell腳本如下:

1 curl -X POST -H 'Content-Type: application/json' \
2   -d '{"batch":[{"secret":"testsecret","userId":"some_user",
3 "event":"PHP Fork Queued Event","properties":null,"timestamp":
4 "2013-01-30T14:34:50-08:00","context":{"library":"analytics-php"},
5 "action":"track"}],"secret":"testsecret"}' \
6   'https://api.segment.io/v1/import' > /dev/null 2>&1 &

腳本花費了大概1秒多一點的時間,佔用大約4k的的常駐內存。而curl進程用了 標準SSL 300毫秒完成請求,exce調用立刻相應php程序。這使得服務頁面能很快相應用戶。

筆者用一臺一般水平的機器試驗,這種方法curl可以每秒響應100個左右https請求,而沒有任何的內存開銷。如果不用SSL,響應的請求會更多。

不用等待輸入,Fork一個進程非常快。

curl花費了和socket同樣時間響應一個請求,但是這個外帶的過程。

調用curl需要僅僅普通的unix基礎。

Fork發起一個簡單的請求,只需要幾毫秒的時間,但是大量的同步調用(forks)會導致系統變慢。

使用析構函數減少出棧請求

雖然不是一個異步請求的方法,但是我們可以用析構函數幫助我們進行批量API請求。

爲了減少請求的數量,我們首先將他們放在內存中,然後對他們進行批處理。如果不適用運行時擴展,他們只能在一個單一的PHP腳本中運行。要做到這一點,我們首先初始化一個隊列,在程序腳本運行結束時,將所有隊列請求批量發送出去。

01 <?php
02 class Analytics_SomeConsumer {
03  
04   public function __construct() {
05     $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
06     socket_set_nonblock($this->socket);
07     socket_connect($this->socket, $this->host, $this->port);
08     $this->queue = array();
09   }
10  
11   public function __destruct() {
12     $payload = json_encode($this->queue);
13     # ... // wait for socket to be writeable
14     socket_write($this->socket, $payload);
15     socket_close($this->socket);
16   }
17  
18   public function track($item) {
19     array_push($this->queue, $item);
20   }
21 ?>

隊列中的對象創建後,當它被銷燬時將隊列進行刷新,這樣保證了隊列在每次請求時只刷新一次。

另外,當PHP解釋器忙着來渲染頁面而我們等待實際寫入套接字時,我們可以以非阻塞的方式在構造函數中創建套接字,然後寫入析構函數,這樣可以預留更多的時間來建立連接。

抉擇?

最完美的方法是用純php實現,而不是調用其他進程,這也是響應請求保守做法。我們更趨向於開發者最方便,不會把精力分散在其他地方。

實際中,這往往是不可觸及的。基於處理的問題的大小,以及系統的限制,以上每種方法都是有缺點和限制。由於簡單的方法不可能滿足實際中的用戶狀況,我們創建不同的適配器以支持不同用戶的不同需求。

我們以調用curl方法做爲基礎,調用一個進程不會導致重大的頁面性能負擔,同時他還支持擴展到每屬主每秒處理多請求。請求的數量通過usinglimits.conf嚴格限制。

高併發用戶或者擁有高系統權限的用戶可以實用日誌文件系統。系統權限受限的用戶(虛擬空間等)可以使用sockets方法。

最後,需要你去了解一下實際中你能擁有的系統限制和系統的負載情況。這些都最終決定你選擇更合適的方法。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章