這才是 PHP 高效能框架 Workerman 的立命之本

Yxh_blogs發表於2024-07-29

大家好,我是碼農先森。

在這個大家都崇尚高效能的時代,程式設計師的談笑間句句都離不開高效能,彷彿嘴角邊不掛著「高效能」三個字都會顯得自己很 Low,其中眾所皆知的 Nginx 就是高效能的代表。有些朋友可能連什麼是高效能都不一定理解,其實高效能就是單位時間內能處理更多的客戶端請求,如果要問具體能處理多少請求,這個就要結合軟硬體條件來評估了,感興趣的朋友可以在定性的條件下使用壓力測試工具對自己的程式進行測試。

大家都知道 PHP-FPM 是 PHP 的程序管理器,每一次來自 Ngixn 轉發過來的客戶端請求,都會交由一個 PHP-FPM 子程序進行處理,在同一時刻一個子程序只能處理一個客戶端請求,如果想要同一時刻能處理多個請求,那麼就需要啟動多個子程序,當遇到秒殺搶購這種瞬間大量請求的場景時,PHP-FPM 對請求處理的模式顯然無法滿足需求。在這種情況下,我們只能使用 Workerman 或 Swoole 這種 PHP 的高效能通訊框架,來解決類似特殊場景下的併發問題,不過這次我分享的內容主要是 Workerman。

如標題所提到的 Workerman 立命之本,那什麼是其立命之本呢?我認為是 IO 多路複用的 epoll 利器,epoll 是高效能程式的根基,解決 C10K 問題的尚方寶劍。接下來我會剖析 epoll 在 Workerman 原始碼中的使用,不過在這之前我們需要先學習下 PHP 中如何將 Socket 與 Event 結合使用的案例。這裡的 Event 可以理解為是對 epoll 的高度封裝,底層採用的就是 epoll 利器。

看了這段程式碼,有助於你理解 Workerman 原始碼,因為這段程式碼就是提煉了 Workerman 對事件迴圈的實現原理。stream_socket_server 函式把建立、繫結、監聽一併實現了,讓程式碼顯得更加簡潔,不像之前的 socket_create、socket_bind、socket_listen 搞了三個步驟略顯繁瑣。因為使用了事件迴圈,所以需要對 Socket 設定成非阻塞模式,只有當有讀或寫的通知時才會呼叫相應的回撥函式。還有一點需要額外注意的,需要針對客戶端 Socket 建立的 Event 需要定義成靜態變數或全域性變數,不然無法持久化連線到記憶體,會造成客戶端無法建立連線傳輸資料,我看到網上很多人都踩到了這個坑上。最後啟動事件迴圈 EventLoop 自此開啟了 Socket 監聽和事件迴圈雙操作。

<?php

// 建立 TCP 伺服器套接字
$server = stream_socket_server("tcp://0.0.0.0:8080", $errno, $error);
echo "正在監聽 8080 埠...". PHP_EOL; 

// 設定為非阻塞,在 $server 物件沒有資料可以讀取或寫入時不會阻塞其執行
stream_set_blocking($server, 0);

// 建立事件基礎物件
$event_base = new EventBase();

// 建立事件監聽服務端 Socket 可讀事件
$event = new Event($event_base, $server, Event::READ | Event::PERSIST, function ($server) use ($event_base) {
    // 獲取新的連線,由於設定了非阻塞模式,那麼這裡即使沒有新的連線,也不會一直阻塞在這
    $client = @stream_socket_accept($server, 0);
    if ($client) {
        echo "客戶端(" . $client . ")連線建立". PHP_EOL; 

        // 針對客戶端過來的連線,也要設定成非阻塞模式
        stream_set_blocking($client, 0);

        // 客戶端連線建立監聽可讀事件
        // 這裡需要特別注意:客戶端事件需要定義成靜態變數或全域性變數
        static $client_event;
        $client_event = new Event($event_base, $client, Event::READ | Event::PERSIST, function ($client) {
            // 從客戶端連線中讀取資料,每次只讀取 1024 位元組資料
            $buffer = fread($client, 1024);

            // 如果沒有讀取到資料或者客戶端已經不是資源控制代碼,則關閉客戶端連線
            if ($buffer == false || !is_resource($client)) {
                // 關閉客戶端連線
                fclose($client);
                echo "客戶端(" . $client . ")連線關閉" . PHP_EOL; 
                return;
            }
            echo "收到客戶端(" . $client . ")資料: $buffer" . PHP_EOL;

            // 回寫資料給客戶端
            $msg = "HTTP/1.0 200 OK\r\nContent-Length: 10\r\n\r\nServerOK\r\n";
            fwrite($client, $msg);
        }, $client);
        $client_event->add();
    }
}, $server);

// 新增事件
$event->add();

// 執行事件迴圈
$event_base->loop();

使用 CURL 工具訪問 http://127.0.0.1:8080 便能正確返回結果 ServerOK 這表明事件迴圈可以進入正常執行狀態。

[manongsen@root php_event]$ curl -i http://127.0.0.1:8080
HTTP/1.0 200 OK
Content-Length: 10

ServerOK

看懂了上面那段程式碼之後,接下來的內容就會更順利了。下面這段程式碼是引至 Workerman 的示例,透過 Worker 類構造了一個 HTTP 服務。onMessage 引數定義了一個回撥函式,當有事件通知時,會回撥到此處,之後就是使用者自行實現後續的處理邏輯了。runAll 函式會整體啟動整個服務,其中包括程序的建立、事件的迴圈等。

<?php

// 引用 Worker 類
use Workerman\Worker;

// 自動載入 Composer
require_once __DIR__ . '/vendor/autoload.php';

// 定義 HTTP 服務並監聽 8081 埠
$http_worker = new Worker('http://0.0.0.0:8081');

// 定義回撥函式
$http_worker->onMessage = function ($connection, $request) {
    //$request->get();
    //$request->post();
    //$request->header();
    //$request->cookie();
    //$request->session();
    //$request->uri();
    //$request->path();
    //$request->method();

    // Send data to client
    $connection->send("Hello World");
};

// 啟動服務
Worker::runAll();

在 Worker.php 檔案的 2367 行,使用 stream_socket_server 函式建立了服務端 Socket 並且繫結、監聽了 8081 埠。

// workerman/Worker.php:2367
$this->_mainSocket = \stream_socket_server($local_socket, $errno, $errmsg, $flags, $this->_context);

在 Worker.php 檔案的 2394 行,使用 stream_set_blocking 函式將 服務端 Socket 設定成非阻塞模式。

// workerman/Worker.php:2394
\stream_set_blocking($this->_mainSocket, false);

在 Worker.php 檔案的 2417 行,將服務端的 _mainSocket 新增到事件循序中,並且設定回撥函式為 acceptConnection 。

// workerman/Worker.php:2417
static::$globalEvent->add($this->_mainSocket, EventInterface::EV_READ, array($this, 'acceptConnection'));

在 Worker.php 檔案的 2561 行,使用 stream_socket_accept 接收到來自客戶端的連線 $new_socket ,其中這個操作是在 acceptConnection 回到函式中所進行的。

// workerman/Worker.php:2561
$new_socket = \stream_socket_accept($socket, 0, $remote_address);

在 TcpConnection.php 檔案的 285 行,使用 stream_set_blocking 函式將客戶端的 _socket 設定成非阻塞模式,這裡的 _socket 和上面的 new_socket 是同一個。

// workerman/Connection/TcpConnection.php:285
\stream_set_blocking($this->_socket, 0);

在 TcpConnection.php 檔案的 290 行,將客戶端的 _socket 新增到事件迴圈中,並且設定其的回撥函式為 baseRead 。

// workerman/Connection/TcpConnection.php:290
Worker::$globalEvent->add($this->_socket, EventInterface::EV_READ, array($this, 'baseRead'));

在 Worker.php 檔案的 1638 行,啟動事件迴圈。

// workerman/Worker.php:1638
static::$globalEvent->loop();

啟動事件迴圈後,當有客戶端連線時便可以讀取資料了。因此在 TcpConnection.php 檔案的 583 行,使用 fread 函式讀取客戶端 $socket 的資料。

// workerman/Connection/TcpConnection.php:583
$buffer = @\fread($socket, self::READ_BUFFER_SIZE);

在 TcpConnection.php 檔案的 647 行,使用 parser::decode 函式將上面讀取到的 buffer 資料解析成 $request 物件,還有 $this 表示的是 $connection 物件,這個 $this->onMessage 是最開始使用者自定義的回撥函式。最終透過 call_user_func 函式,將 $connection、$request 引數回撥到 onMessage 方法。

// workerman/Connection/TcpConnection.php:647
\call_user_func($this->onMessage, $this, $parser::decode($one_request_buffer, $this));

最後我們使用 CURL 工具呼叫一下 http://127.0.0.1:8081 透過返回的資料,可以看出正確的回撥到了 onMessage 函式。

[manongsen@root workerman]$ curl -i http://127.0.0.1:8081
HTTP/1.1 200 OK
Server: workerman
Connection: keep-alive
Content-Type: text/html;charset=utf-8
Content-Length: 13

Hello World

看到這裡相信你已經對 Workerman 原始碼中的事件迴圈有些瞭解了,如果有時間最好能夠實踐下最開始的那段案例程式碼,然後再結合著看 Workerman 的原始碼會頗有收穫。Workerman 的高效能是站在了巨人 epoll 的肩膀上來實現,沒有了 epoll 則啥也不是。這裡再重申一下 PHP 中的 Event 是對 epoll 的封裝,epoll 是 Linux 的底層技術。我們在日常的程式設計中是不會直接接觸到 epoll 的,最後迴歸一下主題 epoll 技術才是 Workerman 的立命之本。

感謝大家閱讀,個人觀點僅供參考,歡迎在評論區發表不同觀點。


歡迎關注、分享、點贊、收藏、在看,我是微信公眾號「碼農先森」作者。

相關文章