淺談非堵塞程序的理解

  • 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;