Skip to content

Commit

Permalink
Add new delay() function to delay program execution
Browse files Browse the repository at this point in the history
  • Loading branch information
clue committed Dec 6, 2022
1 parent 34c49a1 commit 018b640
Show file tree
Hide file tree
Showing 3 changed files with 325 additions and 9 deletions.
112 changes: 108 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ an event loop, it can be used with this library.
* [async()](#async)
* [await()](#await)
* [coroutine()](#coroutine)
* [delay()](#delay)
* [parallel()](#parallel)
* [series()](#series)
* [waterfall()](#waterfall)
Expand Down Expand Up @@ -202,17 +203,16 @@ $promise->then(function (int $bytes) {
The returned promise is implemented in such a way that it can be cancelled
when it is still pending. Cancelling a pending promise will cancel any awaited
promises inside that fiber or any nested fibers. As such, the following example
will only output `ab` and cancel the pending [`sleep()`](https://reactphp.org/promise-timer/#sleep).
will only output `ab` and cancel the pending [`delay()`](#delay).
The [`await()`](#await) calls in this example would throw a `RuntimeException`
from the cancelled [`sleep()`](https://reactphp.org/promise-timer/#sleep) call
that bubbles up through the fibers.
from the cancelled [`delay()`](#delay) call that bubbles up through the fibers.

```php
$promise = async(static function (): int {
echo 'a';
await(async(static function (): void {
echo 'b';
await(React\Promise\Timer\sleep(2));
delay(2);
echo 'c';
})());
echo 'd';
Expand Down Expand Up @@ -392,6 +392,110 @@ $promise->then(function (int $bytes) {
});
```

## delay()

The `delay(float $seconds): void` function can be used to
delay program execution for duration given in `$seconds`.

```php
React\Async\delay($seconds);
```

This function will only return after the given number of `$seconds` have
elapsed. If there are no other events attached to this loop, it will behave
similar to PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php).

```php
echo 'a';
React\Async\delay(1.0);
echo 'b';

// prints "a" at t=0.0s
// prints "b" at t=1.0s
```

Unlike PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php),
this function may not necessarily halt execution of the entire process thread.
Instead, it allows the event loop to run any other events attached to the
same loop until the delay returns:

```php
echo 'a';
Loop::addTimer(1.0, function () {
echo 'b';
});
React\Async\delay(3.0);
echo 'c';

// prints "a" at t=0.0s
// prints "b" at t=1.0s
// prints "c" at t=3.0s
```

This behavior is especially useful if you want to delay the program execution
of a particular routine, such as when building a simple polling or retry
mechanism:

```php
try {
something();
} catch (Throwable) {
// in case of error, retry after a short delay
React\Async\delay(1.0);
something();
}
```

Because this function only returns after some time has passed, it can be
considered *blocking* from the perspective of the calling code. You can avoid
this blocking behavior by wrapping it in an [`async()` function](#async) call.
Everything inside this function will still be blocked, but everything outside
this function can be executed asynchronously without blocking:

```php
Loop::addTimer(0.5, React\Async\async(function () {
echo 'a';
React\Async\delay(1.0);
echo 'c';
}));

Loop::addTimer(1.0, function () {
echo 'b';
});

// prints "a" at t=0.5s
// prints "b" at t=1.0s
// prints "c" at t=1.5s
```

See also the [`async()` function](#async) for more details.

Internally, the `$seconds` argument will be used as a timer for the loop so that
it keeps running until this timer triggers. This implies that if you pass a
really small (or negative) value, it will still start a timer and will thus
trigger at the earliest possible time in the future.

The function is implemented in such as way that it can cancelled when it is
running inside an [`async()` function](#async). Cancelling the resulting
promise will clean up any pending timers and throw a `RuntimeException` from
the pending delay which in turn would reject the resulting promise.

```php
$promise = async(function () {
echo 'a';
delay(3.0);
echo 'b';
});

Loop::addTimer(2.0, function () use ($promise) {
$promise->cancel();
});

// prints "a" at t=0.0s
// rejects $promise at t=2.0
// never prints "b"
```

### parallel()

The `parallel(iterable<callable():PromiseInterface<mixed,Exception>> $tasks): PromiseInterface<array<mixed>,Exception>` function can be used
Expand Down
131 changes: 126 additions & 5 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace React\Async;

use React\EventLoop\Loop;
use React\EventLoop\TimerInterface;
use React\Promise\Deferred;
use React\Promise\Promise;
use React\Promise\PromiseInterface;
Expand Down Expand Up @@ -153,17 +155,16 @@
* The returned promise is implemented in such a way that it can be cancelled
* when it is still pending. Cancelling a pending promise will cancel any awaited
* promises inside that fiber or any nested fibers. As such, the following example
* will only output `ab` and cancel the pending [`sleep()`](https://reactphp.org/promise-timer/#sleep).
* will only output `ab` and cancel the pending [`delay()`](#delay).
* The [`await()`](#await) calls in this example would throw a `RuntimeException`
* from the cancelled [`sleep()`](https://reactphp.org/promise-timer/#sleep) call
* that bubbles up through the fibers.
* from the cancelled [`delay()`](#delay) call that bubbles up through the fibers.
*
* ```php
* $promise = async(static function (): int {
* echo 'a';
* await(async(static function (): void {
* echo 'b';
* await(React\Promise\Timer\sleep(2));
* delay(2);
* echo 'c';
* })());
* echo 'd';
Expand Down Expand Up @@ -215,7 +216,6 @@ function async(callable $function): callable
};
}


/**
* Block waiting for the given `$promise` to be fulfilled.
*
Expand Down Expand Up @@ -352,6 +352,127 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL
return $fiber->suspend();
}

/**
* Delay program execution for duration given in `$seconds`.
*
* ```php
* React\Async\delay($seconds);
* ```
*
* This function will only return after the given number of `$seconds` have
* elapsed. If there are no other events attached to this loop, it will behave
* similar to PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php).
*
* ```php
* echo 'a';
* React\Async\delay(1.0);
* echo 'b';
*
* // prints "a" at t=0.0s
* // prints "b" at t=1.0s
* ```
*
* Unlike PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php),
* this function may not necessarily halt execution of the entire process thread.
* Instead, it allows the event loop to run any other events attached to the
* same loop until the delay returns:
*
* ```php
* echo 'a';
* Loop::addTimer(1.0, function () {
* echo 'b';
* });
* React\Async\delay(3.0);
* echo 'c';
*
* // prints "a" at t=0.0s
* // prints "b" at t=1.0s
* // prints "c" at t=3.0s
* ```
*
* This behavior is especially useful if you want to delay the program execution
* of a particular routine, such as when building a simple polling or retry
* mechanism:
*
* ```php
* try {
* something();
* } catch (Throwable) {
* // in case of error, retry after a short delay
* React\Async\delay(1.0);
* something();
* }
* ```
*
* Because this function only returns after some time has passed, it can be
* considered *blocking* from the perspective of the calling code. You can avoid
* this blocking behavior by wrapping it in an [`async()` function](#async) call.
* Everything inside this function will still be blocked, but everything outside
* this function can be executed asynchronously without blocking:
*
* ```php
* Loop::addTimer(0.5, React\Async\async(function () {
* echo 'a';
* React\Async\delay(1.0);
* echo 'c';
* }));
*
* Loop::addTimer(1.0, function () {
* echo 'b';
* });
*
* // prints "a" at t=0.5s
* // prints "b" at t=1.0s
* // prints "c" at t=1.5s
* ```
*
* See also the [`async()` function](#async) for more details.
*
* Internally, the `$seconds` argument will be used as a timer for the loop so that
* it keeps running until this timer triggers. This implies that if you pass a
* really small (or negative) value, it will still start a timer and will thus
* trigger at the earliest possible time in the future.
*
* The function is implemented in such as way that it can cancelled when it is
* running inside an [`async()` function](#async). Cancelling the resulting
* promise will clean up any pending timers and throw a `RuntimeException` from
* the pending delay which in turn would reject the resulting promise.
*
* ```php
* $promise = async(function () {
* echo 'a';
* delay(3.0);
* echo 'b';
* });
*
* Loop::addTimer(2.0, function () use ($promise) {
* $promise->cancel();
* });
*
* // prints "a" at t=0.0s
* // rejects $promise at t=2.0
* // never prints "b"
* ```
*
* @return void
* @throws \RuntimeException when the function is cancelled inside an `async()` function
* @see async()
* @uses await()
*/
function delay(float $seconds): void
{
/** @var ?TimerInterface $timer */
$timer = null;

await(new Promise(function (callable $resolve) use ($seconds, &$timer): void {
$timer = Loop::addTimer($seconds, fn() => $resolve(null));
}, function () use (&$timer): void {
assert($timer instanceof TimerInterface);
Loop::cancelTimer($timer);
throw new \RuntimeException('Delay cancelled');
}));
}

/**
* Execute a Generator-based coroutine to "await" promises.
*
Expand Down
91 changes: 91 additions & 0 deletions tests/DelayTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace React\Tests\Async;

use PHPUnit\Framework\TestCase;
use React\EventLoop\Loop;
use function React\Async\async;
use function React\Async\await;
use function React\Async\delay;

class DelayTest extends TestCase
{
public function testDelayBlocksForGivenPeriod()
{
$time = microtime(true);
delay(0.02);
$time = microtime(true) - $time;

$this->assertGreaterThan(0.01, $time);
$this->assertLessThan(0.03, $time);
}

public function testDelaySmallPeriodBlocksForCloseToZeroSeconds()
{
$time = microtime(true);
delay(0.000001);
$time = microtime(true) - $time;

$this->assertLessThan(0.01, $time);
}

public function testDelayNegativePeriodBlocksForCloseToZeroSeconds()
{
$time = microtime(true);
delay(-1);
$time = microtime(true) - $time;

$this->assertLessThan(0.01, $time);
}

public function testAwaitAsyncDelayBlocksForGivenPeriod()
{
$promise = async(function () {
delay(0.02);
})();

$time = microtime(true);
await($promise);
$time = microtime(true) - $time;

$this->assertGreaterThan(0.01, $time);
$this->assertLessThan(0.03, $time);
}

public function testAwaitAsyncDelayCancelledImmediatelyStopsTimerAndBlocksForCloseToZeroSeconds()
{
$promise = async(function () {
delay(1.0);
})();
$promise->cancel();

$time = microtime(true);
try {
await($promise);
} catch (\RuntimeException $e) {
$this->assertEquals('Delay cancelled', $e->getMessage());
}
$time = microtime(true) - $time;

$this->assertLessThan(0.03, $time);
}

public function testAwaitAsyncDelayCancelledAfterSmallPeriodStopsTimerAndBlocksUntilCancelled()
{
$promise = async(function () {
delay(1.0);
})();
Loop::addTimer(0.02, fn() => $promise->cancel());

$time = microtime(true);
try {
await($promise);
} catch (\RuntimeException $e) {
$this->assertEquals('Delay cancelled', $e->getMessage());
}
$time = microtime(true) - $time;

$this->assertGreaterThan(0.01, $time);
$this->assertLessThan(0.03, $time);
}
}

0 comments on commit 018b640

Please sign in to comment.