Skip to content

Commit 80aa19f

Browse files
authored
Merge pull request #15 from WyriHaximus-labs/fibers
Add Fiber-based `async` and `await` functions
2 parents 28d9584 + 145ed6a commit 80aa19f

File tree

8 files changed

+290
-72
lines changed

8 files changed

+290
-72
lines changed

README.md

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,8 @@ $result = React\Async\await($promise);
6363
```
6464

6565
This function will only return after the given `$promise` has settled, i.e.
66-
either fulfilled or rejected.
67-
68-
While the promise is pending, this function will assume control over the event
69-
loop. Internally, it will `run()` the [default loop](https://github.com/reactphp/event-loop#loop)
70-
until the promise settles and then calls `stop()` to terminate execution of the
71-
loop. This means this function is more suited for short-lived promise executions
72-
when using promise-based APIs is not feasible. For long-running applications,
73-
using promise-based APIs by leveraging chained `then()` calls is usually preferable.
66+
either fulfilled or rejected. While the promise is pending, this function will
67+
suspend the fiber it's called from until the promise is settled.
7468

7569
Once the promise is fulfilled, this function will return whatever the promise
7670
resolved to.

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
"phpunit/phpunit": "^9.3"
3535
},
3636
"autoload": {
37+
"psr-4": {
38+
"React\\Async\\": "src/"
39+
},
3740
"files": [
3841
"src/functions_include.php"
3942
]

src/FiberFactory.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace React\Async;
4+
5+
/**
6+
* This factory its only purpose is interoperability. Where with
7+
* event loops one could simply wrap another event loop. But with fibers
8+
* that has become impossible and as such we provide this factory and the
9+
* FiberInterface.
10+
*
11+
* Usage is not documented and as such not supported and might chang without
12+
* notice. Use at your own risk.
13+
*
14+
* @internal
15+
*/
16+
final class FiberFactory
17+
{
18+
private static ?\Closure $factory = null;
19+
20+
public static function create(): FiberInterface
21+
{
22+
return (self::factory())();
23+
}
24+
25+
public static function factory(\Closure $factory = null): \Closure
26+
{
27+
if ($factory !== null) {
28+
self::$factory = $factory;
29+
}
30+
31+
return self::$factory ?? static fn (): FiberInterface => new SimpleFiber();
32+
}
33+
}

src/FiberInterface.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace React\Async;
4+
5+
/**
6+
* This interface its only purpose is interoperability. Where with
7+
* event loops one could simply wrap another event loop. But with fibers
8+
* that has become impossible and as such we provide this interface and the
9+
* FiberFactory.
10+
*
11+
* Usage is not documented and as such not supported and might chang without
12+
* notice. Use at your own risk.
13+
*
14+
* @internal
15+
*/
16+
interface FiberInterface
17+
{
18+
public function resume(mixed $value): void;
19+
20+
public function throw(mixed $throwable): void;
21+
22+
public function suspend(): mixed;
23+
}

src/SimpleFiber.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace React\Async;
4+
5+
use React\EventLoop\Loop;
6+
7+
/**
8+
* @internal
9+
*/
10+
final class SimpleFiber implements FiberInterface
11+
{
12+
private static ?\Fiber $scheduler = null;
13+
private ?\Fiber $fiber = null;
14+
15+
public function __construct()
16+
{
17+
$this->fiber = \Fiber::getCurrent();
18+
}
19+
20+
public function resume(mixed $value): void
21+
{
22+
if ($this->fiber === null) {
23+
Loop::futureTick(static fn() => \Fiber::suspend(static fn() => $value));
24+
return;
25+
}
26+
27+
Loop::futureTick(fn() => $this->fiber->resume($value));
28+
}
29+
30+
public function throw(mixed $throwable): void
31+
{
32+
if (!$throwable instanceof \Throwable) {
33+
$throwable = new \UnexpectedValueException(
34+
'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable))
35+
);
36+
}
37+
38+
if ($this->fiber === null) {
39+
Loop::futureTick(static fn() => \Fiber::suspend(static fn() => throw $throwable));
40+
return;
41+
}
42+
43+
Loop::futureTick(fn() => $this->fiber->throw($throwable));
44+
}
45+
46+
public function suspend(): mixed
47+
{
48+
if ($this->fiber === null) {
49+
if (self::$scheduler === null || self::$scheduler->isTerminated()) {
50+
self::$scheduler = new \Fiber(static fn() => Loop::run());
51+
// Run event loop to completion on shutdown.
52+
\register_shutdown_function(static function (): void {
53+
if (self::$scheduler->isSuspended()) {
54+
self::$scheduler->resume();
55+
}
56+
});
57+
}
58+
59+
return (self::$scheduler->isStarted() ? self::$scheduler->resume() : self::$scheduler->start())();
60+
}
61+
62+
return \Fiber::suspend();
63+
}
64+
}

src/functions.php

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,36 @@
55
use React\EventLoop\Loop;
66
use React\Promise\CancellablePromiseInterface;
77
use React\Promise\Deferred;
8+
use React\Promise\Promise;
89
use React\Promise\PromiseInterface;
910
use function React\Promise\reject;
1011
use function React\Promise\resolve;
1112

13+
/**
14+
* Execute an async Fiber-based function to "await" promises.
15+
*
16+
* @param callable(mixed ...$args):mixed $function
17+
* @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is
18+
* @return PromiseInterface<mixed>
19+
* @since 4.0.0
20+
* @see coroutine()
21+
*/
22+
function async(callable $function, mixed ...$args): PromiseInterface
23+
{
24+
return new Promise(function (callable $resolve, callable $reject) use ($function, $args): void {
25+
$fiber = new \Fiber(function () use ($resolve, $reject, $function, $args): void {
26+
try {
27+
$resolve($function(...$args));
28+
} catch (\Throwable $exception) {
29+
$reject($exception);
30+
}
31+
});
32+
33+
Loop::futureTick(static fn() => $fiber->start());
34+
});
35+
}
36+
37+
1238
/**
1339
* Block waiting for the given `$promise` to be fulfilled.
1440
*
@@ -52,48 +78,20 @@
5278
*/
5379
function await(PromiseInterface $promise): mixed
5480
{
55-
$wait = true;
56-
$resolved = null;
57-
$exception = null;
58-
$rejected = false;
81+
$fiber = FiberFactory::create();
5982

6083
$promise->then(
61-
function ($c) use (&$resolved, &$wait) {
62-
$resolved = $c;
63-
$wait = false;
64-
Loop::stop();
84+
function (mixed $value) use (&$resolved, $fiber): void {
85+
$fiber->resume($value);
6586
},
66-
function ($error) use (&$exception, &$rejected, &$wait) {
67-
$exception = $error;
68-
$rejected = true;
69-
$wait = false;
70-
Loop::stop();
87+
function (mixed $throwable) use (&$resolved, $fiber): void {
88+
$fiber->throw($throwable);
7189
}
7290
);
7391

74-
// Explicitly overwrite argument with null value. This ensure that this
75-
// argument does not show up in the stack trace in PHP 7+ only.
76-
$promise = null;
77-
78-
while ($wait) {
79-
Loop::run();
80-
}
81-
82-
if ($rejected) {
83-
// promise is rejected with an unexpected value (Promise API v1 or v2 only)
84-
if (!$exception instanceof \Throwable) {
85-
$exception = new \UnexpectedValueException(
86-
'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception))
87-
);
88-
}
89-
90-
throw $exception;
91-
}
92-
93-
return $resolved;
92+
return $fiber->suspend();
9493
}
9594

96-
9795
/**
9896
* Execute a Generator-based coroutine to "await" promises.
9997
*

tests/AsyncTest.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
namespace React\Tests\Async;
4+
5+
use React;
6+
use React\EventLoop\Loop;
7+
use React\Promise\Promise;
8+
use function React\Async\async;
9+
use function React\Async\await;
10+
use function React\Promise\all;
11+
12+
class AsyncTest extends TestCase
13+
{
14+
public function testAsyncReturnsPendingPromise()
15+
{
16+
$promise = async(function () {
17+
return 42;
18+
});
19+
20+
$promise->then($this->expectCallableNever(), $this->expectCallableNever());
21+
}
22+
23+
public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturns()
24+
{
25+
$promise = async(function () {
26+
return 42;
27+
});
28+
29+
$value = await($promise);
30+
31+
$this->assertEquals(42, $value);
32+
}
33+
34+
public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrows()
35+
{
36+
$promise = async(function () {
37+
throw new \RuntimeException('Foo', 42);
38+
});
39+
40+
$this->expectException(\RuntimeException::class);
41+
$this->expectExceptionMessage('Foo');
42+
$this->expectExceptionCode(42);
43+
await($promise);
44+
}
45+
46+
public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingPromise()
47+
{
48+
$promise = async(function () {
49+
$promise = new Promise(function ($resolve) {
50+
Loop::addTimer(0.001, fn () => $resolve(42));
51+
});
52+
53+
return await($promise);
54+
});
55+
56+
$value = await($promise);
57+
58+
$this->assertEquals(42, $value);
59+
}
60+
61+
public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingTwoConcurrentPromises()
62+
{
63+
$promise1 = async(function () {
64+
$promise = new Promise(function ($resolve) {
65+
Loop::addTimer(0.11, fn () => $resolve(21));
66+
});
67+
68+
return await($promise);
69+
});
70+
71+
$promise2 = async(function () {
72+
$promise = new Promise(function ($resolve) {
73+
Loop::addTimer(0.11, fn () => $resolve(42));
74+
});
75+
76+
return await($promise);
77+
});
78+
79+
$time = microtime(true);
80+
$values = await(all([$promise1, $promise2]));
81+
$time = microtime(true) - $time;
82+
83+
$this->assertEquals([21, 42], $values);
84+
$this->assertGreaterThan(0.1, $time);
85+
$this->assertLessThan(0.12, $time);
86+
}
87+
}

0 commit comments

Comments
 (0)