博客 / 詳情

返回

PHP 的異步編程 該怎麼選擇

PHP 的異步編程 該怎麼選擇

PHP 的傳統執行模型是同步的,這意味着代碼按照語句出現的順序逐條執行。這本身並非問題,因為同步思維往往更為簡單。

當要求 PHP 開發者實現 SQL 分頁展示時,他們通常會先執行一條統計總數的查詢,再執行第二條查詢獲取當前頁的數據。總記錄數對於生成分頁鏈接(首頁、下一頁、末頁等)是必需的。

當 SQL 服務器處理第一條計數查詢時,PHP 服務器處於等待狀態,收到響應後才執行第二條查詢。

當然,存在一次性獲取兩種信息的方法,但那不是本文的主題,請保持專注。

從這個分頁示例中,我們可以看到潛在的優化空間:在 SQL 服務器處理第一條查詢的同時啓動第二條查詢。但要注意,在拿到計數結果之前我們不會顯示分頁鏈接,因此即使計數查詢先完成,也需要等待另一條查詢的結果。

由此可見,異步操作的管理不僅限於並行執行任務,還包括管理響應的處理順序。

存在許多需要異步執行代碼的場景,這通常與 I/O 操作相關:HTTP 請求、數據庫訪問、文件讀寫或啓動外部進程。

PHP 是異步的嗎?

要判斷 PHP 是否"異步",首先需要理解"異步"的含義。異步指的是:不同時發生。當某項操作耗時時,與其等待完成,不如先去做其他事情,等操作完成後再回來繼續。因此,異步的核心在於操作是非阻塞的。

人們常常混淆異步和並行。

打個比方:異步如同一位廚師將鍋接滿水放在灶台上開火,趁水燒開的工夫去切蔬菜。等蔬菜切好、水也燒開,就開始烹飪。

並行則是兩位廚師:一位切蔬菜的同時,另一位負責燒水。蔬菜切好、水也燒開後,由第一位廚師負責烹飪。

並行節省了時間,因為切蔬菜與燒水準備是同時進行的。但兩種模式下,水燒開的過程中都可以去做其他事情。

具體而言,我們的"廚師"就是機器的 CPU/GPU。

PHP 的異步能力

從 2002 年 PHP 4.3 發佈起,一項重要功能被引入:Streams。通過 stream_set_blocking()stream_select() 函數,PHP 進入了異步編程時代。

$h = fopen(__FILE__, 'r');
stream_set_blocking($h, false);
$content = '';
while (!feof($h)) {
    $read = array($h);
    $write = $except = null;
    // 檢查是否有可讀內容,最多等待 1000 微秒
    // 永遠不要設為 0,否則會導致 CPU 過度佔用
    $ready = stream_select($read, $write, $except, 1000);

    if ($ready === 0) {
        // 沒有可讀內容,稍作等待
        // 或者去做其他事情...
        usleep(1000);
        continue;
    }
    $chunk = fgets($h, 1024);
    if ($chunk !== false) {
        $content .= $chunk;
    }
}

fclose($h);

echo $content;

注意,這段示例代碼刻意簡化,未處理錯誤等情況。

usleep(1000) 的位置,可以執行其他操作,比如讀取另一個文件,甚至向其他服務器發起 HTTP 請求。不過,如果你的文件系統很快,可能不會進入等待時間。這種技術更適合處理慢速文件系統或其他類型的 I/O 操作。

23 年前 PHP 就已支持異步編程,然而幾年前人們還説 PHP 不是異步語言,為什麼?

因為實現異步不僅僅是啓動非阻塞處理,還需要有機制來管理這些等待時間。

這就引入了協程的概念。協程是一種可以被掛起、之後恢復的函數。

協程與 Fiber

2013 年 6 月,PHP 5.5 引入生成器(Generators)後,開發者開始將其改造為協程使用。

$generator = (function() {
    $count = 3;
    echo "開始\n";
    while(true) {
        yield; // 掛起函數(生成器)
        echo "有結果了嗎?\n";
        $count--;
        if ($count === 0) {
            return; // 收到結果,停止
        }
    }
})();

$generator->current(); // 啓動處理
do {
    echo "做其他事情\n";
    $generator->next(); // 恢復函數執行(從 yield 處繼續)
} while ($generator->valid()); // 函數是否結束?
echo "結束\n";

PHP 8.1 的發佈標誌着 PHP 向異步編程邁出了重要一步,引入了 Fiber 作為真正的協程技術基礎。

$fiber = new Fiber(function() {
    $count = 3;
    echo "開始\n";
    while(true) {
        Fiber::suspend(); // 掛起 fiber
        echo "有結果了嗎?\n";
        $count--;
        if ($count === 0) {
            return; // 收到結果,停止
        }
    }
});

$fiber->start(); // 啓動處理
do {
    echo "做其他事情\n";
    $fiber->resume(); // 恢復 fiber 執行
} while (!$fiber->isTerminated()); // fiber 是否結束?
echo "結束\n";

你會發現代碼與使用生成器時幾乎沒什麼變化。

雖然 PHP 從 4.3 版本就具備底層異步能力,但 PHP 8.1 引入的 Fiber 標誌着一個轉折點。Fiber 提供了原生且強大的異步編程工具,使其變得更加自然。

Event Loop

既然我們已經知道如何中斷協程並執行非阻塞處理,接下來需要管理多個並行任務,因為單個異步處理的意義不大。

談到並行,人們常會想到線程——線程提供進程間的自然隔離,並能利用多核 CPU,這對計算密集型任務非常有吸引力。

然而,並行、特別是多線程的實現更為複雜,調試更困難,還存在死鎖和內存併發訪問的風險。

正是出於這些原因,Web 領域更傾向於使用另一種模式:EventLoop。Web 場景的特點是併發連接數可能非常高。

EventLoop 是一個無限循環,它監聽事件隊列(如結果到達),並以串行方式逐個處理。

我們將待處理的任務加入這個隊列,然後啓動循環。

問題是如何告知 EventLoop 如何處理任務的結果?很簡單,我們指定一個回調函數,當結果可用時 EventLoop 會調用它。

注意:下面代碼中的 EventLoop 是虛構的,但代表了大多數 EventLoop 的工作方式。

$loop = EventLoop::get();
$loop->addReadStream('file.txt', function(string $data) {
    echo "讀取到的數據:{$data}";
});
echo "啓動 EventLoop\n";
$loop->run();

這段代碼的預期輸出:

啓動 EventLoop
讀取到的數據:<file.txt 的內容>

同時讀取兩個文件的情況:

$loop = EventLoop::get();
$loop->addReadStream('/dev/cdrom/file1.txt', function(string $data) {
    echo "數據 1 已讀取:{$data}";
});
$loop->addReadStream('/dev/fb0/file2.txt', function(string $data) {
    echo "數據 2 已讀取:{$data}";
});
echo "啓動 EventLoop\n";
$loop->run();

根據存儲介質的性能,輸出可能是:

啓動 EventLoop
數據 2 已讀取:<軟盤數據>
數據 1 已讀取:<光盤數據>

Promise

當需要鏈式執行異步操作時,就會陷入回調地獄(或末日金字塔):回調函數層層嵌套。

$loop = EventLoop::get();
$loop->addReadStream('file.txt', function(string $data) {
    EventLoop::get()->defer(function() use ($data) {
        return compressData($data);
    }, function ($compressedData) {
        EventLoop::get()->addWriteStream(
            'http://foo', 
            $compressedData, 
            function (Response $response) {
                echo "數據已發送\n";
            });
    });
});
echo "啓動 EventLoop\n";
$loop->run();

如果再加上錯誤處理,代碼會更加複雜難讀。

為了改善可讀性和更好地管理異步,Promise(承諾)的概念值得考慮。

Promise 的概念於 80 年代在 Multilisp 等語言中引入,但真正流行是在 2009 年,Dojo、Q、jQuery.Deferred 等 JavaScript 庫率先實現了它。

Promise 是什麼?它是一個包含處理結果(當前或未來)的對象。打個比方:

"我不會立即給你處理結果,但我承諾稍後會在這個對象裏給你。"

示例代碼:

$promise = new Promise(function ($resolve, $reject) {
    echo "啓動 Promise\n";
    $resolve("Hello, world!");
});

運行這段代碼會看到 "啓動 Promise",但 "Hello, world!" 在哪裏?為什麼要調用 $resolve()

實際上,需要使用 then() 方法配合回調函數:

$promise = new Promise(function ($resolve, $reject) {
    echo "啓動 Promise\n";
    $resolve("Hello, world!");
});

$promise->then(
    function ($value) {
        echo "Promise 結果:$value\n";
    }
);

輸出:

啓動 Promise
Promise 結果:Hello, world!

如果 Promise 沒有被解決(resolve),什麼都不會發生,只會顯示啓動信息。

具體來説,當 Promise 被解決時,then() 中的回調會被執行。這種情況可能發生在 Promise 內部包含協程時——協程經過長時間處理收到結果後調用 $resolve()

配合 EventLoop 的完整示例:

$loop = EventLoop::get();

$promise = new Promise(function ($resolve, $reject) use ($loop) {
    echo "啓動 Promise\n";
    $loop->addTimer(1, function () use ($resolve) {
        echo "解決 Promise\n";
        $resolve("Hello, World!");
    });
});

$promise->then(
    function ($value) {
        echo "結果:$value\n";
    }
);

$loop->run();

這段代碼使用異步定時器在 1 秒後解決 Promise。輸出:

啓動 Promise
解決 Promise
結果:Hello, World!

Promise 的價值體現在哪裏?回到回調地獄的問題。使用 Promise 後,代碼可以這樣寫:

readFileAsync('file.txt')
    ->then(function ($data) {
        return compressDataAsync($data);
    })
    ->then(function ($compressedData) {
        return sendDataAsync('http://foo', $compressedData);
    })
    ->catch(function ($error) {
        echo "錯誤:{$error}\n";
    });

readFileAsync() 返回一個使用 EventLoop 的 Promise,在獲得結果時解決。
compressDataAsync()sendDataAsync() 同樣返回 Promise。

catch() 用於處理鏈中任何環節的錯誤。現在我們不再是嵌套回調,而是回調鏈。

你也可以在回調中返回值,這個值會被轉換為立即解決的 Promise。如果不返回任何內容,相當於返回一個值為 NULL 的已解決 Promise。

如果需要在各階段處理錯誤,then() 方法接受第二個參數作為拒絕(錯誤)時的回調:

readFileAsync('file.txt')
    ->then(
        function ($data) {
            return compressDataAsync($data);
        },
        function ($error) {
            echo "文件讀取錯誤:{$error}\n";
        }
    )
    ->then(function ($compressedData) {
        return sendDataAsync('http://foo', $compressedData);
    })
    ->catch(function ($error) {
        echo "錯誤:{$error}\n";
    });

需要注意的是,如果錯誤回調返回了值(或沒有 return),後續的 then() 會收到一個已解決的 Promise。因此需要返回一個錯誤狀態的 Promise 或拋出異常。

這是 then(onResolve, onReject) 中處理錯誤的常見陷阱之一——需要在後續所有 then() 中處理錯誤。上面的代碼中,sendDataAsync() 會收到包含 NULL 的 $compressedData

包選型建議

在 Packagist 上搜索 "promise" 會發現有 4 個包較為突出。

Guzzle/promises 和 php-http/promise

guzzle/promises 的下載量遙遙領先,很大程度上是因為它被流行的 HTTP 客户端 guzzle/guzzle 直接使用。

如果你已經在使用 Guzzle,可能無需選擇其他包,因為它已經相當完善。

但 Guzzle/Promises 最初是為處理異步 HTTP 請求設計的,使用內部不暴露的 EventLoop,這使得集成其他類型的 I/O(如 Mysqli 異步查詢或進程)更加困難。

php-http/promise 情況類似,同樣專注於 HTTP 請求。

ReactPHP 和 Amp

剩下的兩個重要選擇是 react/promiseamphp/amp

ReactPHP 提供了簡單且高性能的 JavaScript Promises/A+ 標準實現(Promise 最初是 JavaScript 語言中涌現的標準,沒告訴過你吧?)。

Amp 則沒有完全實現 Promise:3.0 版本中沒有 then(),但它實現了另一種機制——Futures,設計用於在基於生成器或 Fiber 的協程中通過 await() 等待。

因此,一邊是 Promise 鏈式管理,另一邊是面向協程的管理。

如果你用過 JavaScript 的 Promise,ReactPHP 可能更容易上手;否則 Amp 的協程方式代碼可讀性更好,更接近我們習慣的"同步" PHP 寫法。

但無論選擇 ReactPHP 還是 Amp,都需要 EventLoop。

ReactPHP 提供 react/event-loop 包,Amp 推薦使用 revolt/event-loop——這是 Amp 團隊發起的項目,旨在圍繞現代事件循環標準統一 PHP 異步生態。Revolt 可通過適配器與 ReactPHP 互操作。

怎麼選?

如果你想使用 Promise 模式,毫無疑問應該選擇 react/promise

另一方面,Amp 提供了一種不同的寫法,對某些人來説可能更"自然",建議你兩種都試試看哪個更適合。

對於 EventLoop,建議選擇 Revolt,其統一生態的願景在中期來看可能會帶來回報。

還有一個參考因素:Amp v3 使用 PHP 8.1 的 Fiber,而 ReactPHP 可以在 PHP 7.1 上運行。

PHP 的異步編程 該怎麼選擇

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.