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 不是异步语言为什么因为实现异步不仅仅是启动非阻塞处理还需要有机制来管理这些等待时间。这就引入了协程的概念。协程是一种可以被挂起、之后恢复的函数。协程与 Fiber2013 年 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/promiseguzzle/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 则没有完全实现 Promise3.0 版本中没有then()但它实现了另一种机制——Futures设计用于在基于生成器或 Fiber 的协程中通过await()等待。因此一边是 Promise 链式管理另一边是面向协程的管理。如果你用过 JavaScript 的 PromiseReactPHP 可能更容易上手否则 Amp 的协程方式代码可读性更好更接近我们习惯的同步 PHP 写法。但无论选择 ReactPHP 还是 Amp都需要 EventLoop。ReactPHP 提供react/event-loop包Amp 推荐使用revolt/event-loop——这是 Amp 团队发起的项目旨在围绕现代事件循环标准统一 PHP 异步生态。Revolt 可通过适配器与 ReactPHP 互操作。怎么选