探索 PHP 中的协程:生成器和纤程
在讨论异步或非阻塞代码时,“协程”这个词经常出现,但它究竟是什么意思呢?在本文中,我们将探讨协程的概念,并了解 PHP 如何通过生成器和纤程来支持它们。无论是构建管道、CLI 工具,还是准备深入研究并发,理解协程都是至关重要的第一步。
什么是协程?
协程是一个函数。然而,普通函数会从上到下持续运行直至完成,而协程可以自行暂停/挂起并恢复。它每次挂起时都可以返回一个值,恢复时也可以接收一个值。当协程处于挂起状态且尚未完成时,它将保持当前状态。
暂停和恢复
协程一旦执行,就会开始执行其任务。在执行过程中,协程可以自行暂停,将控制权移交给其余代码。这意味着执行的暂停只能在协程内部发起。它必须主动释放控制权。
此后,协程的命运将掌握在其余代码手中。它无法自行恢复。必须通过恢复指令明确地将控制权交还给协程。在此之前,它处于等待状态,等待其余代码运行。
注意:其他代码可以继续运行,不再调用该协程,使其处于挂起状态。一旦其他代码运行完毕,程序就会结束。它无需等待协程运行完毕。
返回值及接收值
当协程暂停执行时,它可以提供一个值来执行。这使得暂停类似于 return
语句。由于协程可以多次恢复和暂停,因此它可以返回多个值。
协程在恢复时也可以接收一个值。恢复后,协程将立即获得该值并对其进行操作。这使得协程具有双向性。
虽然在协程中可以返回和接收值,但这不是必需的。协程可以直接暂停执行,而无需执行任何其他操作。
保持其状态
调用常规函数时,函数执行完成后,所有用于保存值的内部参数都会从内存中释放。你可以多次调用一个函数,但第一次调用时的值在第二次调用函数时将无法使用。
由于协程是一个可以暂停和恢复的函数,因此在协程执行完成之前,协程作用域内的值仍然可用。当协程重新获得控制权并恢复执行时,它仍然可以引用这些变量。只有当协程执行完成时,其内部变量才会从内存中释放。
协程类型
协程有多种类型。它们可以是对称的,也可以是非对称的;可以是无栈的,也可以是有栈的。
非对称 vs. 对称协程
当涉及到暂停其执行时,正如我们现在所知,控制必须由协程释放。非对称协程只能将控制权释放回调用协程的代码。对称协程可以选择将控制权释放给谁,既可以是其他协程也可以是原始调用者。
让我们想象一下,协程正在玩一场烫手山芋游戏。
在非对称游戏中,总是由主程序将土豆传递给协程,协程只能将土豆抛回调用者,而不能将其抛给另一个协程。
在对称游戏中,协程可以相互传递土豆,决定下一个是谁。主程序将开始和结束游戏,但不必控制每一次传递。
注意:即使在对称协程的情况下,在某些时候,也必须将控制释放回原始调用代码,以便程序完成。否则,它将无限期地悬挂。
Stackless vs. Stackful 协程
最后,有无栈和有栈协程。区别在于他们被允许在哪里暂停执行。
无栈协程只能其最外层的函数控制。虽然它可以调用其他(嵌套)函数,但不能从其中挂起。
另一方面,有栈协程可以从嵌套函数中挂起。它将在那里释放控制权,当它恢复时,它将从那个确切的位置恢复。
因此,有栈协程协程更灵活,因为它们允许在辅助函数内暂停。这可以减少冗长,并将重点放在核心逻辑上。
PHP 中如何实现协程?
使用生成器的协程
PHP 中首次引入协程是在 5.5 版本中以生成器的形式出现的。
现在,大多数开发人员都知道生成器是内存保存迭代器。但正如我们将看到的,它们不仅仅是迭代器。
以下是一个生成一些值的生成器示例。
function exampleGenerator(): Generator {
echo "Started";
$value = 4;
yield 1;
yield;
yield 3;
yield $value;
}
$generator = exampleGenerator();
那么,为什么这个生成器也是协程呢?让我们来看看到目前为止我们讨论过的条件。
它可以停止运行
当我们调用 $generator=exampleGenerator()
时,它只返回 Generator
实例。其中的代码尚未启动。当在生成器上调用任何函数时,生成器将启动。
例如,如果我们调用 $result=$generator->current()
,代码的第一部分将运行。它将打印 Started
,将 $value
参数设置为4,然后暂停执行并返回 yield
控制。
它可以返回值
执行暂停时,生成器同时返回了值 1
。当我们打印 $result
值可以看到这个结果。
它可以恢复运行
生成器暂停时,我们可以在调用代码中做任何事。比如,撰写邮件、写入日志,或者休眠(sleep
)一段时间。我们也可以调用 $generator→next()
恢复生成器。然后,生成器会继续执行其代码直至遇到下一次暂停或者 yield
关键字。如果我们再次调用 $generator->current()
,它将返回 null
值,因为下一个 yield
关键字没有指定返回值。它只是简单地暂停执行。
它可以记住状态
如果我们再调用两次 $generator->next()
,它会来到 yield $value
行。为了证明它已经记住了 $value
参数的早期状态,我们可以检查 $generator->current()
并打印,其结果将为值 4
。
注意:当使用 Generator 作为
foreach($generator as $key => $result)
的迭代器时,它内部也会使用这些方法。在调用$generator->rewind()
一次后,它将在每次迭代中调用这些方法:
$generator->valid()
- 确保它有更多的值$generator->key()
- 返回它的键$key
$generator->current()
- 返回$result
值- foreach 循环运行体
$generator->next()
- 恢复运行到下一个yield
如果你想尝试一下,这是到目前为止的完整代码:
function exampleGenerator(): Generator {
echo "Started";
$value = 4;
yield 1;
yield;
yield 3;
yield $value;
}
$generator = exampleGenerator();
$result = $generator->current();
var_dump($result); // int(1)
// Other code, send email, log, sleep...
$generator->next(); // Restart coroutine
var_dump($generator->current()); // NULL
$generator->next(); // Current value = 3
$generator->next();
var_dump($generator->current()); // int(4)
它可以接收值 (?!)
是的,它可以接收值。不过上面的示例不能,但生成器通常可以在恢复时接收值。要让生成器接收值,必须使用 $generator->send($value)
调用。那么,这个值在哪里进入协程呢?就在最后一次 yield
之后。或者更好的是,“之前”。让我举个例子来解释一下:
function coroutine(): Generator {
$received = yield 'Hello from the Coroutine';
yield "Received: ". $received;
}
$coroutine = coroutine();
$result = $coroutine->current();
var_dump($result); // Hello from the Coroutine
$next = $coroutine->send('Greetings from the code');
var_dump($next); // Received: Greetings from the code.
在这个例子中,生成器将通过 current()
方法启动并立即暂停。在暂停期间,生成器返回的值将是 Hello from the Coroutine
。
Generator::send()
方法不仅发送值,同时还指示 Generator 恢复执行。此时,生成器将把 $received
的值设置为从 send()
接收到的值,并返回前缀为 Received:
的值。因此,->send()
的结果将是: Received: Greetings from the Coroutine
。
还可以使用 ->throw(\Sthrowable $exception)
将异常抛回给生成器。这就好像是当前 yield
被异常所取代。
因此,总结一下,yield
关键字可以有不同的使用方式:
yield;
暂停执行,不返回值yield $value;
暂停执行并返回$value
$received = yield;
暂停执行,不返回值。同时可以通过$received
接收值,或者接收异常$received = yield $value;
暂停执行并返回$value
,同时可以通过$received
接收值,或者接收异常
它是非对称、无栈的
正如我们所看到的,yield
关键字使函数成为协程,因为它暂停执行并释放控制。但是,它不能将控制权释放给任何其他协程。这就需要类似于 yield to
的语法,而事实上并不存在这种语法。
虽然生成器可以启动(和消耗)其他的生成器,但这在技术上是非对称的,因为它没有释放其控制权。它只启动另一个生成器,同时它仍在运行。这使得生成器成为非对称协程。
最后,生成器不能在嵌套函数内暂停。这是因为函数内的 yield
关键字将使其返回一个 Generator
。因此,当你在嵌套函数中调用 yield
时,它将成为生成器,而不是暂停父函数。这种缺乏调用堆栈意识的情况使生成器成为无栈协程。
使用纤程的协程
如果你和我一样,当 PHP 8.1 引入纤程(Fiber)时,你可能已经了解过并想到“🤯 这不适合我”。说实话,很可能不是。然而,我认为在协程和生成器的背景下,更容易理解和看到它们的用例。
当生成器本身是一个无栈协程时,Fiber 代表了一个工具箱,你可以使用它来实现一个有栈的协程。让我们来看一下我们用 Fiber 编写的一个例子。
$coroutine = new Fiber(function () {
$received = Fiber::suspend('Hello from the Coroutine');
Fiber::suspend('Received: ' . $received);
});
$result = $coroutine->start();
var_dump($result); // Hello from the Coroutine
$next = $coroutine->resume('Hello from the code')
var_dump($next); // Received: Hello from the code
使用 Fiber,协程现在包装在 Fiber
实例中。之后,你需要显式调用 start()
方法启用 Fiber
实例。
注意:所有传递给
start()
方法的参数都将提供给回调函数,就像你调用带参数的生成器函数一样。
暂停和恢复
就像我们前面的生成器示例一样,纤程可以暂停其执行。而生成器使用更多的迭代器函数名,因为它主要是这样使用的,所以 fiber 的方法非常语义化:Fiber::suspend()
以及 $fiber->resume()
。
那么,为什么 ::suspend()
是静态方法,而 ->resume()
则是非静态方法呢?这与调堆栈有关。与生成器不同,Fiber 有自己的调用栈。这意味着暂停可以发生在嵌套函数的深处,而不仅仅是顶层。但是,这些嵌套函数和启动纤程的回调函数都无法访问表示当前运行实例的 $this
变量。
这就是静态 Fiber::suspend()
函数发挥作用的地方。因为纤程知道它的调用堆栈,所以 Fiber::suspend
可以确定它被调用的最近实例,并将其挂起。
同时由于协程不能自行恢复,因此必须从其外部恢复实例。这就是为什么实例有一个公共的非静态方法 resume()
。
返回及接收值或异常
就如生成器,Fiber 可以返回多个值。当你调用 Fiber::suspend($value)
时,你可以提供一个值使之称为返回值。Fiber 也可以接受任何提供给 $fiber->resume($value)
的值。
你也可以抛出 Throwable $exception
异常来恢复纤程。这将允许纤程响应不同类型的通信。通过调用 $fiber->throw($exception)
,你可以发送异常给纤程。在你的回调函数中,应该通过(至少)将当前的 Fiber::suspend()
调用包装在 try-catch
块中来应对这种情况。
$coroutine = new Fiber(function () {
Fiber::suspend('Hello from the fiber.');
try {
$received = Fiber::suspend('Give me something.');
Fiber::suspend(sprintf('You gave me a value of "%s": ', $received));
} catch (\Throwable $e) {
Fiber::suspend(sprintf('You gave me this exception: "%s".', $e->getMessage()));
}
} );
$hello = $coroutine->start();
var_dump($hello); // Hello from the fiber.
$message = $coroutine->resume('Hello from the code');
var_dump($message); // Give me something.
$result = $coroutine->throw( new Exception( 'Exception from the code' ) );
var_dump($result); // You gave me this exception: "Exception from the code"
注意:虽然可以进行局部捕获,但更好的方法是包装整个回调函数体。因为异常总是从外部抛出的,所以回调函数并不控制异常,所以它应该始终期待异常。
嵌入暂停
我们已经看到,生成器不能从嵌套函数中暂停,因为嵌入函数自己将变成生成器。而使用 Fiber 时,可以通过嵌套函数调用来暂停当前的 Fiber。这允许你使用辅助函数,这可以使你的代码更干净、更语义化、更易于理解。
final class Logger {
public function warning(string $message): void {
echo $message;
}
}
function get_logger(): ?Logger {
try {
$logger = Fiber::suspend( 'Waiting for a logger instance...' );
return $logger instanceof Logger ? $logger : null;
} catch (Throwable) {
return null;
}
}
$coroutine = new Fiber(function () {
$message = 'I am a message that needs to be logged.';
$logger = get_logger();
$logger?->warning($message);
});
$message = $coroutine->start();
var_dump($message); // Waiting for a logger instance...
$coroutine->resume(new Logger()); // I am a message that needs to be logged.
本例中,我们可以看到纤程(Fiber)的回调函数并没有显式暂停其执行。不过,get_logger
函数可以。代码在那里暂停了,控制权被移交给了调用者。当调用者使用新的的 Logger
恢复协程时,协程也在嵌套函数中恢复了。它验证了输入,返回了 logger,并继续了协程。这个 Fiber 是有栈协程。
纤程也是非对称的
之前说过纤程也是对称的。但不幸的是,本例并非如此。就像 yield
关键字一样,Fiber::suspend()
方法不允许暂停到特定的协程。它也只能将控制权释放给调用者,使 Fiber 的使用表现为非对称协程。
就像在生成器中一样,在不同的协程上调用 start()
或 resume()
不会暂停当前协程。下例可以证明:
$a = new Fiber(function (Fiber $b) use (&$a) {
echo "starting B\n";
$b->start($a);
});
$b = new Fiber(function (Fiber $a) {
echo "Resuming A\n";
$a->resume(); // FiberError: Cannot resume a fiber that is not suspended
});
$a->start($b);
本例中,$a
启动了 $b
,反过来, $b
试图恢复 $a
。但由于没有移交控制权和暂停,$a
仍在运行,导致出现异常。
然而,仅仅因为纤程是非对称的,并不意味着它们毫无用处或不如生成器。它们的有栈特性和额外的表现力使它们本身成为一个强大的工具。
生成器(Generator)与纤程(Fiber)之比较
既然我们已经探讨了这两种方法,让我们细致比较一下生成器和纤程,看看它们有什么不同。
能力 | Generator | Fiber |
---|---|---|
暂停 /恢复 | ✅ yield , ->next() | ✅ Fiber::suspend() , ->resume() |
返回值 | ✅ 通过 yield $value | ✅ 通过 Fiber::suspend($value) |
接收值 | ✅ 通过 ->send($value) | ✅ 通过 ->resume($value) |
异常处理 | ✅ 通过 ->throw($exception) | ✅ 通过 ->throw($exception) |
对称性 | ❌ 始终恢复到原始调用者 | ❌ 始终恢复到原始调用者 |
堆栈 | ❌ 不能在嵌套函数中暂停 | ✅ 可以在嵌套函数中暂停 |
迭代器集成 | ✅ 实现了 Iterator 接口(适用 foreach ) | ❌ 不可迭代 — 需手动处理 |
协程在 PHP 中有什么用处?
暂停代码执行、具有双向 I/O 和保持其当前状态的能力使协程成为各种用例的良好候选者。让我们来看看一些可能性。
状态处理
因为协程可以在调用之间保留其状态,所以它们可用作状态机、聊天机器人、管道和命令行(CLI)工具。
让我们来看一个简单的命令行工具,它会问一些问题来生成最终输出。
$cli = (function () {
$state = [];
$state['name'] = yield 'What is your name?';
$state['age'] = yield 'How old are you?';
return $state;
})();
$cli->rewind();
while ($cli->valid()) {
$prompt = $cli->current();
echo $prompt . "\n";
$input = trim(fgets(STDIN));
$cli->send($input);
}
$result = $cli->getReturn();
// Display the collected information
echo "\nCollected information:\n";
echo "Name: " . $result['name'] . "\n";
echo "Age: " . $result['age'] . "\n";
此例中,生成器将返回几个问题。我们显示每个问题,并使用 fgets(STDIN)
输入答案。此答案将被发送回生成器,生成器将继续下一个问题。当生成器完成时,我们返回记录的状态。我们可以使用 ->getReturn()
从生成器中获取该值。
注意:你只能在已完成的生成器或者纤程上使用
getReturn
。否则,它将抛出异常。
惰性迭代
能够返回多个值是协程作为懒惰迭代器的伟大之处。因为它返回的数据在迭代开始之前不需要存在,所以它可以节省大量内存。在 PHP 中,生成器非常适合这种工作,因为它们实现了 Iterator
接口并直接插入 foreach
循环。
协同多任务处理(并发)
并发指的是同时运行多个任务。这可以通过并行性来实现,即任务同时在单独的线程上运行,或者通过协作多任务处理来实现,其中任务通过显式地服从控制来轮流运行。
Note: Because coroutines simply yield their control, you need a manager that determines which coroutine runs next. This is often implemented with something called an Event Loop, which will manage and schedule coroutines based on I/O events and timers. Out of the box, PHP does not provide this, but there are various projects like Revolt and ReactPHP that do. We will touch on them in a different post.
想象一下,一位厨师正在准备一顿饭。厨师不是全心烹饪整道菜,然后再清洗所有东西,而是在烹煮和快速清洁任务之间交替进行。虽然这两条路线的结果相同,但交替使用可以创造更清洁的工作环境。
以下是使用 Fiber 的代码的样子。
$cooking = new Fiber(function (array $steps): Meal {
Fiber::suspend('Ready to cook.');
foreach ($steps as $step) {
// Perform step.
Fiber::suspend($step . " finished");
}
return new Meal();
});
$cleaning = new Fiber(function () {
Fiber::suspend('Waiting for something to clean.');
while (true) {
// Cleaning.
Fiber::suspend('Finished cleaning');
}
});
$cooking->start(['chopping', 'mixing', 'cooking', 'plating']); // Ready to cook.
$cleaning->start(); // Waiting for something to clean.
// Alternate between cooking and cleaning.
while (!$cooking->isTerminated()) {
printf("[Cooking] %s\n", $cooking->resume());
printf("[Cleaning] %s\n", $cleaning->resume());
}
$meal = $cooking->getReturn();
这也可能发生在代码中。代码使用内存,通常可以释放内存以保持低内存使用率。特别是当你处理大量数据时,在处理和清理之间不断切换可以大大提高代码的效率。
当你将协同多任务处理与后台处理相结合时,它才真正大放异彩。你可以允许调用代码在后台完成计算时执行其他任务,而不是等待函数完成计算。然后,当计算完成时,函数可以从稍后停止的地方继续。
小结
- 协程是可以暂停和恢复执行同时保留内部状态的函数。
PHP 通过生成器(不对称、无栈)和纤程(非对称、有栈)支持协程。
生成器用于迭代和提示内存效率。
使用纤程进行协同多任务处理和嵌套暂停。
真实场景下的应用包括 CLI 工具、状态机和异步工作流。
协程的实践
现在我们已经探索了协程是什么以及 PHP 如何通过生成器和纤程支持它们,下一步是让它们工作。在下一篇文章中,我们将深入探讨 PHP 中的并发性,探索像 Revolt 这样的事件循环、协程调度器和像 ReactPHP 这样的异步库。我们将看到协程如何融入构建响应式、非阻塞的 PHP 应用的大局中。