PHP中on回調的實現(十六節)

  • 2020 年 2 月 19 日
  • 筆記

各位好,我是老李。和老李一同完成《PHP網絡編程》,雖然我知道實際上從頭到尾可能只有我一個人在搞。我告訴你們一定要好好在家好好學習、遠程工作,不要折騰地自己最後連班都沒法上了,要好好學習、要不斷學習、要終身學習。

上個章節我送了大家一篇番外:

同步異步阻塞非阻塞,了解一下?(十三節)

今天這篇是和上篇番外緊密結合的,因為我答應大家了,要通過今天這一篇中的代碼表演一波兒啥叫阻塞、啥叫非阻塞、啥叫異步非阻塞…這年月,聽到的異步非阻塞次數太TM多了,似乎每個高IO的程序都離不開這個組合詞!

這個詞語,席捲八荒,說出去拉風又囂張

所以呢,今天我們搞一個非常有意思的科研方向,那就是Workerman里的那種on是咋實現的。作為一個24k的泥腿子,php-fpm才是星光大道,複製粘貼是拿手兵器,composer install是撒手鐧,CURD一把梭,PHP里的一大坨函數幾乎都是[ 同步阻塞 ],複製粘貼起來毫無後顧之憂,上來就是干,最後在在業務里隨手搞兩個sleep( one ),以後優化響應速度就是這麼輕鬆簡單,So easy!哪裡不會點哪裡~

但是用Workerman或者Nodejs,on是一定避免不了的,天生麗質的[ 異步非阻塞 ]註定會讓程序寫法變成這樣。因為調用方(研究僧)自己不會主動獲取數據,靠的是被調用方(阿梅)的通知,所以調用方(研究僧)就只能靠on('某事件')這種方式來實現業務邏輯。

那麼,大聲的告訴我!!!如果我們基於select IO復用或者epoll IO復用搞一個[ 異步非阻塞 ]的程序,純PHP的on該如何實現?老李手把手教你分兩步基於Libevent-epoll搞坨代碼整清楚[ 異步非阻塞 ](epoll按照定義嚴格意義上表達應該是同步非阻塞),第一步先整明白[ 阻塞 ]與[ 非阻塞 ],第二步去整[ 異步 ]


阻塞/非阻塞

來,之前select那一節的代碼,複製粘貼過來:

<?php  $host = '0.0.0.0';  $port = 6666;  // 創建了一個listen-socket  $listen_socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );  socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEADDR, 1 );  socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEPORT, 1 );  socket_bind( $listen_socket, $host, $port );  socket_listen( $listen_socket );  while ( true ) {    // socket_accept會阻塞,你可以理解為listen-socket為阻塞IO    // 雖然被包在了while循環里,但是不會打空炮不斷執行    $connection_socket = socket_accept( $listen_socket );    socket_recv( $connection_socket, $recv_content, 8, MSG_WAITALL );    echo $recv_content.PHP_EOL;    socket_close( $connection_socket );  }

上面這個demo里的$listen_socket就是阻塞的,所以當socket_accept()執行的時候會被阻塞,如果你有興趣想驗證一下的話也很簡單,你在socket_accept()後面隨便echo個內容就行了,while不會打空炮的… …

然後,我們做一個騷操作:通過socket_set_nonblock()函數將$listen_socket變成非阻塞IO。變成非阻塞的意思就是當socket_accpet()調用的時候,如果沒有新的客戶端連接,程序不會等待而是繼續往下執行!

<?php  $host = '0.0.0.0';  $port = 6666;  $listen_socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );  socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEADDR, 1 );  socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEPORT, 1 );  // 變成非阻塞IO,就比上面的demo代碼多了這一行  // 但是運行起來,結果大大的不同  socket_set_nonblock( $listen_socket );  socket_bind( $listen_socket, $host, $port );  socket_listen( $listen_socket );  while ( true ) {    $connection_socket = socket_accept( $listen_socket );    socket_recv( $connection_socket, $recv_content, 8, MSG_WAITALL );    echo $recv_content.PHP_EOL;    socket_close( $connection_socket );  }

此時程序運行起來結果卻是大大的不同:因為你會發現while循環開始打空炮了而且還會報Notice級別的錯誤,你的電腦屏幕瞬間會被這種錯誤文案打滿,如果你電腦配置足夠低的話,順便送一次免費重啟也不是不可能…

這裡無論你用為了規避這種非阻塞導致的錯誤,有一種餿主意就是在socket_accept()函數前面加上一個@符號,而我們作為高端人士怎麼能夠容忍這種沙雕寫法,必須要要向優雅看齊!優雅如Lavarel(不知道有沒有拼錯…)。我們只需要需要對socket_accpet()的寫法xue微做個小調整即可,這是一個小小的騷操作,然而一騷起來就無法無天:

while ( true ) {    if ( false !== ( $connection_socket = socket_accept( $listen_socket ) ) ) {      socket_recv( $connection_socket, $recv_content, 8, MSG_WAITALL );      echo $recv_content.PHP_EOL;      socket_close( $connection_socket );    }  }

怎麼樣?看起來複雜了一些牛逼了一些,而且確實不報那個Notice了…所以你以為這就是完美的非阻塞了么?真是年輕人…

這段程序跑起來幾分鐘後,你的筆記本是不是開始臉紅渾身發燙了?你貼上去仔細偷聽一下,是不是還能聽到筆記本開始喘着低沉的粗氣了?黝黑而又堅硬的筆記本那滾燙的肌膚,讓你實在忍不住了,大手又猛又粗暴地掀開了鍵盤上那一層薄薄的本就可有可無的覆蓋物,你的呼吸也開始低沉而急促了,大腦已經停止了正常理性的思考,有些人甚至已經停下了手裡的針線活在瀏覽器里打開了一個新的標籤頁並依次輸入:www.91********… …

呵,男人~~

你都不問問你本子為啥會發熱扇風?看下CPU佔用率感受一下?

造成這樣的原因是什麼,一是我前面文章里解釋過,二是這篇文章前面也說過了,三是我提醒你結合下非阻塞我舉的例子,如果這樣你還想不清楚…

這個$listen_socket變成非阻塞IO本是好事,但是非阻塞導致while循環不斷打空炮,如果有客戶端請求連接還好,但是沒有的時候TA就這麼一直打空炮,你想想掛了空檔猛踩油門,難受不?那能不毀車?所以非阻塞的最佳應用場景是什麼,就是當真有客戶端來連接的時候,再在這個非阻塞的$listen_socket上發生accept,說白了[ 非阻塞 ]要結合[ 異步事件 ],這樣就避免了打空炮行為同時還能保證[ 非阻塞 ]。


異步

先說好了這裡的異步並不是指符合APUE書中定義的那個[ 異步 ],而是指上層代碼整體流程的異步,更不是指AIO。這裡結合下Select IO復用來簡單實現一下初步的流程:

<?php  $host = '0.0.0.0';  $port = 6666;  $listen_socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );  socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEADDR, 1 );  socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEPORT, 1 );  socket_bind( $listen_socket, $host, $port );  socket_listen( $listen_socket );  socket_set_nonblock( $listen_socket );  $client = array( $listen_socket );  while ( true ) {      $read      = $client;      $write     = array();      $exception = array();      $ret       = socket_select( $read, $write, $exception, NULL );      if ( $ret <= 0 ) {          continue;      }      // 就是說,如果 listen-socket 中有事件,listen-socket能有啥事件:就是用新的客戶端來了      if ( in_array( $listen_socket, $read ) ) {          $connection_socket = socket_accept( $listen_socket );          if ( !$connection_socket ) {              continue;          }          $client[] = $connection_socket;          $key    = array_search( $listen_socket, $read );          unset( $read[ $key ] );      }      // 對於其他socket      foreach( $read as $read_key => $read_fd ) {          socket_recv( $read_fd, $recv_content, 1024, 0 );          if ( !$recv_content ) {              unset( $client[ $read_key ] );              socket_close( $read_fd );              continue;          }          echo $recv_content;          unset( $client[ $read_key ] );          socket_shutdown( $read_fd );          socket_close( $read_fd );      }  }

至於Select IO的用法和詳解不是本章重點,重點是:

  • 首先你先看下你CPU還打空炮不?
  • 其次是如何基於上面代碼改造成比較優雅的on~
<?php  class Server {      private $host = '0.0.0.0';      private $port = 6666;      private $listen_socket = null;      private $client_array  = array();      private $closure_array = array();      public function init() {          $listen_socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );          socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEADDR, 1 );          socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEPORT, 1 );          socket_bind( $listen_socket, $this->host, $this->port );          socket_listen( $listen_socket );          socket_set_nonblock( $listen_socket );          $this->client = array( $listen_socket );          $this->listen_socket = $listen_socket;      }      // 這個函數就相當於註冊回調函數...      public function on( $event_name, Closure $func ) {          $this->closure_array[ $event_name ] = $func;      }      public function run() {          while ( true ) {              $read      = $this->client;              $write     = array();              $exception = array();              $ret       = socket_select( $read, $write, $exception, NULL );              if ( $ret <= 0 ) {                  continue;              }              // 就是說,如果 listen-socket 中有事件,listen-socket能有啥事件:就是用新的客戶端來了              if ( in_array( $this->listen_socket, $read ) ) {                  $connection_socket = socket_accept( $this->listen_socket );                  if ( !$connection_socket ) {                      continue;                  }                  $this->client[] = $connection_socket;                  $key    = array_search( $this->listen_socket, $read );                  unset( $read[ $key ] );                  // connect時:觸發                  $func = isset( $this->closure_array['connect'] ) ? $this->closure_array['connect'] : false ;                  if ( false !== $func ) {                      call_user_func_array( $func, array() );                  }              }              // 對於其他socket              foreach( $read as $read_key => $read_fd ) {                  socket_recv( $read_fd, $recv_content, 1024, 0 );                  if ( !$recv_content ) {                      unset( $this->client[ $read_key ] );                      socket_close( $read_fd );                      continue;                  }                  // 收到消息時:觸發                  $func = isset( $this->closure_array['message'] ) ? $this->closure_array['message'] : false ;                  if ( false !== $func ) {                      call_user_func_array( $func, array( $recv_content ) );                  }                  unset( $this->client[ $read_key ] );                  socket_shutdown( $read_fd );                  socket_close( $read_fd );              }          }      }  }  $server = new Server();  $server->init();  // 這裡通過on函數來註冊  // 這裡就是利用了PHP里的Closure,其實下面function就是Closure  $server->on( 'connect', function() {      echo "觸發connect".PHP_EOL;  } );  $server->on( 'message', function( $data ) {      echo "觸發message,收到數據:".$data.PHP_EOL;  } );  $server->run();

用telnet客串客戶端感受一下?

有些泥腿子們可能之前用過Workerman,Workerman的回調函數方式是$server->onConnect()這種風格的,而我們用的是和Swoole、NodeJS那種靠攏的$server->on( 'action' )風格的,無論用的哪種方式都不重要,因為這些都是上層的表現風格而已,重要的是什麼:

  • 一、你的PHP基礎知識里是否給了Closure一席之地
  • 二、你是否知道call_user_func()以及call_user_func_array()

上述兩點是實現PHP版本異步回調用法的基石。