淺談非堵塞程序的理解
- 2019 年 12 月 15 日
- 筆記
這篇文章,主要講講非堵塞編程帶給程序的意義。
在我們談到今天的主題之前,先來做一點基礎知識的補充。
什麼是I/O
我們的計算機系統架構簡易可看成如下,I/O接口連接其他硬件如:網卡、鍵盤鼠標、磁盤等。
I代表Input,輸入數據。 O代表Output,輸出數據。

當程序需要發送網絡請求或者從磁盤中讀取文件等IO操作時 CPU發出指令,然後信號經過總線到達網卡或者磁盤 然後拿到數據,再經過總線到達主存中,CPU繼續對主存中的數據進行操作。
CPU的執行速率:主頻 比如3GHz = 一秒鐘有30億個時鐘脈衝,執行一條指令一般只需要幾個時鐘脈衝。也就是一秒可以執行的指令經常是以億計算的。
以網絡請求為例(磁盤IO也是一樣的原理),當CPU發出指令之後,想要得到結果需要經過很長的等待(比如網絡延遲經常是幾十ms時間,CPU都過了多少千萬個時鐘脈衝了)
同步、異步、堵塞、非堵塞的概念
相信看這篇文章的你也不是第一次看到這種概念,在很多文章中經常會以購物等場景做例子。
這裡只做一個簡單的介紹:
同步、異步
分為一組概念; 堵塞、非堵塞
分為一組概念;
(同步、異步):關注的是:數據的接收方式 (堵塞、非堵塞):關注的是:是否等待結果返回
這是兩個分組(因為它們的關注點不同) 但是往往同步跟堵塞
是一起的,異步跟非堵塞
是一起的。
如果我們需要同步接收數據,肯定要讓當前程序暫停,等待數據返回再做處理。 如果我們選擇了異步接收數據,程序還堵塞的話那就沒什麼意義了,所以非堵塞模式,一般會返回發送調用請求的結果,然後程序繼續執行,直到結果準備好了,再通過回調函數等方式觸發程序做處理。
堵塞IO存在的不足
如果是堵塞IO的話,那麼當前的進程會暫停
執行,直到拿到數據才會繼續執行。
文件鎖堵塞
以PHP中自帶的Session為例的文件鎖
Session以生成文件儲存的,如果同一個用戶同時發起多個請求,先獲取文件鎖的請求可以執行,後面的拿不到文件鎖,所以一直堵塞等待,假設前面的請求過了10s才執行完,後續的請求是要10s後才開始
執行。
socket堵塞
寫過tcp服務器的應該都會遇到這個問題
我們可以監聽機器的某個端口,當有請求連接進來的時候,我們可以accept這個連接,然後讀取客戶端發過來的數據、發送數據回客戶端等處理。
<?php $socket = stream_socket_server("tcp://0.0.0.0:8000", $errno, $errstr); if (!$socket) { echo "$errstr ($errno)<br />n"; } else { // 循環接收客戶端的連接 while ($conn = stream_socket_accept($socket)) { $data = fread($conn, 8192); // 讀取客戶端發送過來的數據 讀不到就一直堵塞着 fwrite($conn, "hello worldn"); // 發送hello world fclose($conn); } fclose($socket); }
以上代碼實現了一個建議的TCP服務器,但是因為沒有解決堵塞IO的問題,所以只能處理一個客戶端的請求。
- 當A連接進來,accept到,然後開始fread從緩衝區讀取數據。 堵塞住了,進程執行暫停,等待數據結果。
- 此時B連接進來,因為進程已經被堵塞住,所以無法被accept,更無法讀取、發送數據。
- A客戶端發送了數據,進程恢復執行,開始讀取,然後輸出。
- 然後才能accept B客戶端(哪怕在此之前B已經發了很多數據,也只能從這個時候開始處理)。
非堵塞IO
為了讓我們的網絡服務器可以服務多個客戶端,我們需要將程序改造為非堵塞的。
我們可以簡單實現為:
- 當A連接進來了,accept起來,存到一個列表中。
- 繼續等待監聽,B連接進來了,accpet起來,存到一個列表中。
- 多開一個線程,不斷輪詢連接列表,判斷連接是否有發送數據過來,有的話就執行操作(比如發送數據、關閉連接)
- 在PHP中默認沒有線程操作,並且accept操作是堵塞的,但是可以設置
超時時間
所以我們可以讓程序每等待0.1s連接進來,然後就去輪詢一次連接列表,讀取數據然後操作。
<?php $socket = stream_socket_server("tcp://0.0.0.0:8000", $errno, $errstr); if (!$socket) { echo "$errstr ($errno)<br />n"; } else { $conns = [];// 全局連接 while (true){ $conn = @stream_socket_accept($socket, 0.1); // 0.1沒有連接進來就不堵塞等待了 先檢測有沒有客戶端發數據 if($conn!== false){ $conns[] = $conn; stream_set_blocking($conn, false); } foreach ($conns as $key=>$item) { $data = fread($item, 8192); if ($data !== ''){ fwrite($item, "hello"); fclose($item); unset($conns[$key]); } } } fclose($socket); }
以上的I/O模型是同步非堵塞
,當客戶端連接數比較多的時候,以上代碼還是有很大的問題。
我們還可以將對客戶端的操作邏輯進行異步執行(因為我們的實際業務邏輯肯定不只是輸出hello這麼簡單,還要數據庫操作等等)
將對客戶連接的操作邏輯異步分離的話,但是accept連接還是堵塞同步的,因此可見,程序同步、異步、堵塞、非堵塞是相對的,需要按功能點和模塊來分析。
我們也可以依賴擴展,比如Event
等,實現異步非堵塞
模型。 當有客戶連接、斷開、讀寫數據時,底層擴展會通過我們設置的回調函數觸發,而不需要我們在程序代碼中accpet、read(堵塞或者輪詢)
可以參考簡單的demo。
這不是完整的demo,並且需要安裝擴展,大家了解一下使用的方式即可 有興趣可以繼續深入學習Event擴展的使用
class MyListenerConnection { private $bev, $base; public function __destruct() { $this->bev->free(); } // 新鏈接進來 並且監聽 這個時候就設置鏈接的事件回調 public function __construct($base, $fd) { $this->base = $base; $this->bev = new EventBufferEvent($base, $fd, EventBufferEvent::OPT_CLOSE_ON_FREE); // 設置回調事件 $this->bev->setCallbacks( array($this, "echoReadCallback"), array($this, "writeCallback"), array($this, "echoEventCallback"), NULL ); if (!$this->bev->enable(Event::READ)) { echo "Failed to enable READn"; return; } } // 讀回調 public function echoReadCallback($bev, $ctx) { // 在這裡處理 handleRequest $bev->input就是客戶端發送的數據 $bev->output->addBuffer($bev->input); // $bev->output設置內容就是會發送給客戶端的數據 這裡原樣返回 } // 寫回調 是輸出之後才回調的 而不是在輸出之前 public function writeCallback($bev, $ctx){ // 釋放監聽 斷開連接 $bev->free(); } // 除了讀寫之外其他事件的回調 public function echoEventCallback($bev, $events, $ctx) { if ($events & EventBufferEvent::ERROR) { echo "Error from buffereventn"; } if ($events & (EventBufferEvent::EOF | EventBufferEvent::ERROR)) { $this->__destruct(); } } }
通過這種方式,我們寫一個網絡服務器就很簡單了,只需要給事件設置回調事件,由底層維護客戶端連接的可讀寫狀態, 這種模型是I/O復用里的epoll
模型。
總結
通過上面文件鎖、幾種TCP服務器的寫法,我們可以理解到堵塞和非堵塞程序之間的區別了。
再做一下小小的總結。
- 同步和異步是指
決定結果返回的接收方式
- 堵塞和非堵塞是指
是否需要等待結果返回
- 如果發生磁盤IO等操作,因為CPU執行速率和總線信號傳遞、磁盤速率的不對等,CPU如果堵塞等待讀取結果,就不能最大化地利用機器資源。
- 非堵塞程序,可以提高機器的利用率,可以提高並發支持。
- 常見的I/O模型有:阻塞式I/O;非阻塞式I/O;I/O復用(select和poll);異步I/O;