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/promise 和 amphp/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 的異步編程 該怎麼選擇