NIO、BIO、AIO 與 PHP 實現

小白要生髮發表於2020-04-16

前言

最近看到NIO,AIO,Netty,Promise話題很熱,我作為一個phper也想來湊湊熱鬧,湊著湊著發現周圍怎麼都是javaer,jser。那麼PHP能做NIOAIO麼?

什麼BIO、NIO、AIO

BIO 同步阻塞I/O。

有小夥伴又要問了啥叫 同步,啥叫阻塞啊?

同步/非同步 阻塞/非阻塞

同步: 兩個同步任務相互依賴,並且一個任務必須以依賴於另一任務的某種方式執行。 比如在A->B事件模型中,你需要先完成 A 才能執行B。 再換句話說,同步呼叫種被呼叫者未處理完請求之前,呼叫不返回,呼叫者會一直等待結果的返回。

非同步: 兩個非同步的任務完全獨立的,一方的執行不需要等待另外一方的執行。再換句話說,非同步呼叫種一呼叫就返回結果不需要等待結果返回,當結果返回的時候通過回撥函式或者其他方式拿著結果再做相關事情,

阻塞: 阻塞就是發起一個請求,呼叫者一直等待請求結果返回,也就是當前執行緒會被掛起,無法從事其他任務,只有當條件就緒才能繼續。

非阻塞: 非阻塞就是發起一個請求,呼叫者不用一直等著結果返回,可以先去幹其他事情。

以上就是這四個詞彙的解釋,那麼放到計算機IO上,比較接地氣的解釋

BIO (Blocking I/O)

那麼我們拿快遞攬件來舉例,一個快遞公司,有一部分工作是攬件,它的工作模式是隻能一個一個的攬件,你要寄快遞,必須排隊,一個一個的來,這就是 同步 。好不容易輪到你了,你把快遞一扔給他,他還讓給你等著,快遞工作人員說,我們這後面還有些資訊要錄入,快遞要檢查,必須等我們快遞公司檢查完畢後,你才能離開,這叫 阻塞

NIO (No-Blocking I/O)

同步非阻塞的I/O

繼續啊,拿快遞公司舉例。這個快遞公司發現有些使用者在後面排隊,排著排著,太久了就去隔壁快遞公司了,怎麼辦呢?快遞公司想了個辦法,置辦了一個發號器和一批收納盒。來一個客戶,就把快遞放在一個收納盒裡,再給使用者一個編號,此時再來一個使用者,不論前面一個的快遞是否檢查完畢,還是給他一個收納盒,發一個編號。不同客戶之間不排隊,一來就被受理了,這就是 非阻塞。 我們再來看看內部,快遞呢還是一個個地錄入資訊,X光檢查,這樣就是 同步 執行的,等待快遞人員檢查完畢叫號,客戶拿到回執才能離開快遞點。

AIO (Asynchronous I/O)

非同步非阻塞IO

也有Javaer叫他 NIO2,快遞公司攬件又升級了,做了一個快遞櫃,客戶又寄件需求,來了就放入快遞櫃,然後通過手機掃碼關注這個櫃子的動態,客戶就可以離開了,此時服務被受理,並能馬上離開。這就是 非阻塞 。等到快遞人員來攬件時,會將櫃子裡面的寄件一併取走,快遞點集中一起處理這些快遞件,發現有問題的件,不是立即停下手中的活等待客戶來出來,而是放一旁通知客戶來,然後繼續處理下一個快遞,這就是 非同步

非同步 阻塞 IO

同步/非同步 阻塞/非阻塞,這4個名詞,兩兩組和,還有一個就是 非同步/阻塞

那麼我們還是先把例子舉出來吧,還是這個快遞點,來了一批客戶來寄口罩到國外,由於有很大的可能會通不過檢查,所以,快遞點把大家都留了下來。等所有的 寄件 都檢查完了在統一給大家傳送回執單,這就是 阻塞 。快遞人員檢查寄件時,發現問題不是立馬通知客戶來處理,而已放到一邊,繼續處理下一個。 這就是 非同步

偽非同步 IO

這種模式,底層實現是多個 同步阻塞的BIO, 同時執行。

最後總結一下:

阻塞與非阻塞指的的是當不能進行讀寫(網路卡滿時的寫/網路卡空的時候的讀)的時候, I/ O操作立即返回還是阻塞;同步非同步指的是,當資料已經 ready 的時候,讀寫操作是同步讀還是非同步讀,階段不同而已。

區別

非同步/同步在計算機區別

以上是一些舉例,只是幫助大家理解記憶,接下來我們看看計算上的實現。

最初計算機提供的Web服務,採用的是 CGI 協議,就是純正的 BIO 模式。一個cgi程式監聽一個埠,處理完一個請求,才能接收下一個http請求。這就是同步

而客戶的實際體驗式是"非同步"的,那是因為後來優化了,CGI 程式能夠自我fork程式的達到同時響應多個http請求的效果。

注意,我們這裡討論的基礎是 單程式 ,上的 非同步/同步

阻塞/非阻塞在計算機區別

這裡拿購物流程舉例,使用者的下單,需要做如下操作:

  • 商品可售否
  • 庫存數量
  • 使用者餘額
  • 觸發哪些優惠規則
  • 獎券有效性
  • ...

按照一般做法就是一步步驗證,上一個檢查完了,再進行下一個檢查,這就是 阻塞 的方式。

那麼非阻塞方式如何做呢,假設在微服務環境中,商品,庫存,獎券,促銷都是獨立的系統,呼叫商品服務,發起商品可售檢查請求;不等商品服務回覆,繼續呼叫庫存服務,發起商品可售庫存請求;緊接著依次發出...檢查請求,這樣5個檢查專案的請求同時發起,最後,我等他們所有的請求都回復我,再來一起來校驗是否所有的檢查都通過了。就這種發起請求不等響應,就繼續做下一件事的叫 非阻塞

轉載著名來源sifou

PHP 能做什麼

PHP 與 BIO 實現

PHP已經實現啦,這是最基本的好麼。但平時測試時卻感覺是不阻塞啊,好,我們來一起做個實驗,將nginx和php-fpm的程式限制為1個試試。php-fpm就是 多程式的 BIO,現在我們強項改成單程式。

  • 調整Nginx配置

調整 /etc/nginx/nginx.conf 檔案:

## 把nginx worker數量設定為1
worker_processes 1;

好了之後我們通過ps命令檢查下
image.png

  • 調整PHP配置

調整 /etc/php/php-fpm/conf.d/www.conf 檔案:

pm = static

pm.max_children = 1

pm.start_servers = 1

pm.min_spare_servers = 1

pm.max_spare_servers = 1

找到這幾個配置都改為如上數值。

最後的結果如下

image.png

我在index.php程式碼裡面加入第一行就加入了sleep。

<?php
sleep(5);

我們同時開啟兩個網頁,一起訪問試試
image.png

通過Firefox 抓包可以發現,其中一個耗時5s,另一個頁面耗時9.3s,(0.7s誤差是我手速慢了) 這就是 BIO。

image.png

好的,我們再做一個實驗。把以上nginx,php-fpm配置中1改成2.然後我們開啟三個網頁,同時訪問試試看。

image.png

結果是有兩個網頁耗時5s一個是9s,也就是說伺服器同時處理了2個請求,第三個請求等待了4s才被處理。這就是 多執行緒-BIO,一個服務同時接待的客戶數量取決與worker的數量

PHP 與 NIO 實現

我們寫的大部分php-fpm程式碼以及第三方框架都是阻塞的。PHP也是支援非阻塞IO程式設計的。

這裡其他博主也用PHP原生程式碼實現NIO程式設計: PHP回顧之socket程式設計

I/O 多路複用

在這段小Demo中,PHP 實現 NIO 核心兩個函式就是 stream_set_blockingstream_select()
通過以上原始碼,發現原生的NIO實現還是比較繁瑣,不易讀的。同時,我就想問一句了,這個 NIO 就是為了實現一個 socket server 麼,我們來看看Netty 官網。開啟Netty首頁,它是這樣描述自己的

Netty is a NIO client server framework which enables quick and easy development of network applications such as protocol servers and clients. It greatly simplifies and streamlines network programming such as TCP and UDP socket server.

第一句話:Netty是一個 NIO 客戶端 服務框架, 能快速輕鬆地開發協程客戶端。第二句話:簡化了網路程式設計,如建立TCP和UDP套接字服務。

好,重點是什麼?第一句話就是重點——開發 協程客戶端!回到我們業務上,剛剛舉了一個例子,購物到下單,有很多個流程需要做檢查,按照一般的BIO那麼程式時序圖如下:

nio購物流程檢查

從上可以看到,三個檢查依次分開執行。那麼客戶的等待時間是大於,庫存檢查時間加上,產品檢查時間加上,促銷檢查時間 的。

假設, 庫存,產品,促銷是三個微服務,然後購物車服務用 NIO客戶端,與這三個微服務互動,那麼會是怎樣的效果呢:

nio請求時序圖

這裡,我們發起檢查請求時,是按照順序發起的,但不等第一個服務返回檢查結果就開始發起下一個檢查請求。最後三個服務都返回後,綜合結果,返回給使用者。那麼這三個檢查的耗時,就等於一個服務(耗時最長的那個服務)的檢查耗時。大大減少得了購物車服務響應時間。

我看到一些 NettyNodeJSSwoole 等教程 通篇都在講如何實現一個WebSocket服務,TCP服務或者是Http服務。對,這是最基礎的,但 NIO 框架核心優勢在開發一個非阻塞客戶端!這才是它的優勢,這才是和 BIO 程式設計差異化所在.

NIO 客戶端

看到以上兩個時序圖,還是給大家演示一下用PHP原生程式碼實現一個 PHP-BIOPHP Simple NIO Server

建議大家點選連結,把原始碼git clone https://gitee.com/xupaul/php-nio-server 到本地執行一下,再來看截圖更容易理解。

image.png

這三個所依賴的服務響應耗時,我設定為:inventory: 4s, product: 2s, promo:6s

藍色框和黃色框標註了兩個請求,我們主要看引數 noBlocking: true/false 的不同, 第一個是非阻塞方式請求, 可以看到共耗時6s,第二個共耗時12s! (第三個為啥和第二個耗時不一樣——6s這個留給大家去研究)。顯而易見得非阻塞IO的優勢。不過這程式碼結構就不那麼友好了,看到程式碼 nio_server.php 中,有兩種請求方式,阻塞程式碼流程還能看懂檢查完成後就綜合結果返回,而非阻塞方式中,發起三個檢查後程式流程就開始進入到handleMessage,程式碼進入哪個分支,取決於 socket_read 的訊息,不執行起程式來,沒有文件,很難搞懂整個程式流程。

那麼,有沒有什麼什麼方便的php類庫,讓我們編碼更友好一點呢,這裡介紹下 ReactPHP

這裡我用ReactPHP重新實現 nio_server, 程式碼在這裡

這個回撥程式碼寫起來有點 NodeJS 的味道呢,當你的PHP沒啟用 libev 之類的擴充時,ReactPHP內部Loop依然用的 stream_select(), 可以看原始碼 ~/react/event-loop/src/StreamSelectLoop.php@290 .

執行效果如下:
image.png

能同時發起請求這個功能,那還得提一下 curl_multi, 它能同時發起多個curl請求,最後不斷檢查是否所有的curl請求已完成。這只是在發起多個Http curl請求階段做到 非阻塞 執行。

還有個擴充pThreads,能夠實現多執行緒,不過對PHP編譯引數有限制,需要線上程安全的模式下執行。

pThreads 現在已不是PHP官方所推薦使用的擴充了,當然了這種就屬於偽非同步IO範疇了

PHP 與 AIO

PHP 非同步&非阻塞 編碼。

此處, 非阻塞I/O 系統呼叫( nonblocking system call ) 和 非同步I/O系統呼叫 (asychronous system call)的區別是:

  • 一個非阻塞I/O 系統呼叫 read() 操作立即返回的是任何可以立即拿到的資料, 可以是完整的結果, 也可以是不完整的結果, 還可以是一個空值。
  • 而非同步I/O系統呼叫 read() 結果必須是完整的, 但是這個操作完成的通知可以延遲到將來的一個時間點。
<?php

/**
 * 訊息處理
 */
function handleMessage() {
    global $changed, $clients, $cartCheck;
    foreach ($changed as $key => $client) {
        while (true) {
            // read socket data
            $msg = @fread($client, 1024);
//            $msg = 1;
            if ($msg) {
                // application process
            } else {
                if (feof($client)) {
                    // TODO check data eof
                }
                break;
            }

可以看到,在檔案~/nio_server.php 中, 雖然設定了 stream_set_blocking false, 但是在209行的 fread() , 這是在一個迴圈裡讀,這是一個阻塞讀取。這的系統函式的響應速度是受系統IO影響的。

而非同步呼叫中,當有I/O事件時,系統會將資料複製到使用者記憶體中,也就是準備好資料,再通知到使用者程式。

那麼原生PHP顯然是不支援的,這裡呢就要引入PHP擴充,就是 Event,或者 Ev 擴充。這篇部落格主要講 Event

Event 擴充是基於 libevent 庫封裝而來,而 Ev 擴充是基於 libev 庫封裝而來。 通過PHP介面,和C庫的介面就能看到他們之間的聯絡,所以,如果通過PHP文件找不到相關資料可以去,看看C庫的文件。而 Libevent 年久失修,不推薦大家使用。

這裡放上用Event實現的Tcp Server demo

在用Event做這個demo中,我用到了EventBuffer ,讀、寫都和Buffer互動, Buffer資料是使用者態資料,不會等待系統I/O或被阻塞,避免了程式耗時在I/O資料拷貝上。由此PHP 也能實現 AIO 程式,提高CPU利用率。

講到這裡,就會感覺這個PHP的AIO有些牽強了,我這找了其他博主的論點來幫助大家理解,這兩張圖展示了 使用者程式,與核心採用 分阻塞非同步 互動時的異同。

image.png

上面是非阻塞IO,下面是非同步IO。中間的區別就是非阻塞IO的應用,需要不斷的去訪問核心獲取資料(當然了,每一次訪問都是有求必應,能取到資料),但不一定能取完; 而非同步IO的特點就是,你告訴核心取資料,取完整了,我再一起發給應用程式。這就是Linux對非同步IO的定義。

image.png

那麼再看到我們的Demo,這是一個簡單TCP server,一個TCP請求系統是能知道一個資料的包大小的,是否接收完畢,這是傳輸層要做的。而我們的應用層面,是接收到資料還要做合併,分包,以及資料轉碼。 這就和 AIO 資料結果必須是完整的,概率有些出入,(在系統層面顯然是完整的) . 在應用層面呢,一次性收到的不一定是完整的資料,那麼就還需要做額外程式碼來解決合包,分包,沾包。這就是AIO實現Tcp Server的需要問題。

為了解決以上問題,就需要自定義TCP通訊協議。相當於自己開發RPC框架了。

那我們來看看Http呢,在應用層面有明確公開的協議(協議有頭無尾,標明瞭每次請求具體長度),並有豐富的實現。這就是一個非常適合採用AIO程式設計協議。而PHP的Event擴充,恰好有EventHttp實現。

話不多說,先上 Demo

<?php
...

/**
 * event http 請求回撥函式
 * 
 * @param   \EventHttpRequest   $req    Http請求物件
 */
function _http_about($req) {
    echo __METHOD__, PHP_EOL;
    // print request URL
    echo "URI: ", $req->getUri(), PHP_EOL;
    // print request's headers
    echo "Input headers:"; var_dump($req->getInputHeaders());
    echo "\n >> Sending reply ...";
    /**
     * @var \EventBuffer    $buf
     */
    $buf = $req->getOutputBuffer();
    $buf->add("It's about Event http server");
    $req->sendReply(200, "OK", $buf);
    echo "OK\n";
}

這裡是一個回撥函式,入引數就是一個由 EventHttp 封裝的http請求物件。這就滿足了以上 呼叫時非阻塞,資料完全準備好後,再通知回撥——非同步I/O。好,藉助Event,PHP就實現了AIO.

結語

關於效能提升,這就不做壓測了,主要論證PHP實現NIOAIO 的可行性。也實際給大家展示了幾個Demo, 簡單展示瞭如何寫非同步,非阻塞程式。可以看到 非同步程式設計 對大家的要求是比較高的,當需要發起 IO 操作,都要用非阻塞方式呼叫,不然就會阻塞整個程式,而純粹的非同步程式設計就是單程式,阻塞後該服務就不能響應新的請求。同時呢,我們常用PDO,mysqli,Redis這些不得不用的擴充,也只提供了阻塞讀的介面。而當前PHP環境中,可以說“幾乎所有”的第三方框架,都是阻塞編碼,如果你的專案中使用了其他框架,那麼你寫的程式碼沒問題,不保證依賴的第三方框架阻塞方式請求 I\O. 所以,一般 PHP 非同步程式設計,都會採用多程式非同步,讓非同步來提高每個請求的響應速度,如果程式阻塞,就讓其他空閒的程式處理新進入的請求。

以上,希望大家通過文章能瞭解 非同步/同步阻塞/非阻塞區別,以及對PHP非同步非阻塞程式設計。

有問題歡迎提問~

參考

  1. PHP實現非阻塞
  2. PHP回顧之socket程式設計
  3. Cooperative multitasking using coroutines (in PHP!)
  4. IO - 同步,非同步,阻塞,非阻塞
  5. 同步/非同步,阻塞/非阻塞概念深度解析
  6. PHP之高效能I/O框架:Libevent
  7. 網路程式設計(三):從libevent到事件通知機制

相關文章