await-generator Build Status Codecov

A library to use async/await in PHP using generators.

Documentation

await-generator is a wrapper to convert a traditional callback-async function into async/await functions.

Why await-generator?

The callback-async function requires passing and creating many onComplete callables throughout the code, making the code very unreadable, known as the “callback hell”. The async/await approach allows code to be written linearly and in normal language control structures (e.g. if, for, return), as if the code was not written async.

An example of callback hell vs await-generator:

Can I maintain backward compatibility?

As a wrapper, the whole Await can be used as a callback-async function, and the ultimate async functions can also be callback-async functions, but the logic between can be purely written in async/await style. Therefore, the entry API can still be callback-async style, and no changes are required in your library methods that accept callback-async calling.

How to migrate to async/await pattern easily?

The following steps are recommended:

Best/Idiomatic practices

yield vs yield from

The straightforward approach to calling another generator function is to yield from that function, but await-generator cannot distinguish the yield statements from the current function and the called function. To have separate scopes for both generator functions such that state-sensitive statements like Await::ALL work correctly, the generator should be yielded directly.

Return type hints

Always add the return type hint generator functions with Generator. PHP is a very “PoWeRfUl” language that automatically detects whether a function is a generator function by searching the presence of the yield token in the code, so if the developer someday removes all yield lines for whatever reason (e.g. behavioural changes), the function is no longer a generator function. To detect this kind of bugs as soon as possible (and also to allow IDEs to report errors), always declare the Generator type hint.

Empty generator function

As mentioned above, a PHP function is only a generator function when it contains a yield token. But a function may still want to return a generator without having yield for many reasons, such as interface implementation or API consistency. This StackOverflow question discusses a handful of approaches to produce an empty generator.

In await-generator, for the sake of consistency, the idiomatic way to create an immediate-return generator is to add a false && yield; line at the beginning of the function. It is more concise than if(false) yield; (because some code styles mandate line breaks behind if statements), and it has superstitiously better performance than yield from [];. false && is an obvious implication that the following line is dead code, and is rarely used in other occasions, so the expression false && yield; is idiomatic to imply “let’s make sure this is a generator function”. It is reasonable to include this line even in functions that already contain other yield statements.

yield Await::ONCE

The syntax to produce a generator from a callback function consists of two lines:

callback_function(yield, yield Await::REJECT);
yield Await::ONCE;

To make code more concise, it is idiomatic to use the following instead:

yield callback_function(yield, yield Await::REJECT) => Await::ONCE;

Since await-generator ignores the yielded key for Await::ONCE, the following two snippets have identical effect. However, some IDEs might not like this since callback_function() most likely returns void and is invalid to use in the yielded key.

Example with libasynql

Sequential await

Task: Execute select query query1; for each result row, execute insert query query2 with the name column as name from the previous result. Execute queries one by one; don’t start the second insert query before the first insert query completes.

Without await-generator:

$done = function() {
  $this->getLogger()->info("Done!");
};
$onError = function(SqlError $error) {
  $this->getLogger()->logException($error);
};
$this->connector->executeSelect("query1", [], function(array $rows) use($done, $onError) {
  $i = 0;
  $next = function() use($next, $done, $onError, &$i) {
    $this->connector->executeInsert("query2", ["name" => $rows[$i++]["name"]], isset($rows[$i]) ? $next : $done, $onError);
  };
  $next();
}, $onError);

With await-generator:

function asyncSelect(string $query, array $args) : Generator {
  $this->connector->executeSelect($query, $args, yield, yield Await::REJECT);
  return yield Await::ONCE;
}
function asyncInsert(string $query, array $args) : Generator {
  $this->connector->executeInsert($query, $args, yield, yield Await::REJECT);
  return yield Await::ONCE;
}
$done = function() {
  $this->getLogger()->info("Done!");
};
$onError = function(SqlError $error) {
  $this->getLogger()->logException($error);
};
Await::f2c(function() {
  $rows = yield $this->asyncSelect("query1", []);
  foreach($rows as $row) {
    yield $this->asyncInsert("query2", ["name" => $row["name"]]);
  }
}, $done, $onError);

Although the first example has shorter code, you can see that the looping logic (the $next function) is very complicated.

Simultaneous await

Task: same as above, except all insert queries are executed simultaneously

Without await-generator:

$done = function() {
  $this->getLogger()->info("Done!");
};
$onError = function(SqlError $error) {
  $this->getLogger()->logException($error);
};
$this->connector->executeSelect("query1", [], function(array $rows) use($done, $onError) {
  $i = count($rows);
  foreach($rows as $row) {
    $this->connector->executeInsert("query2", ["name" => $row["name"]], function() use($done, &$i) {
      $i--;
      if($i === 0) $done();
    }, $onError);
  }
}, $onError);

With await-generator:

function asyncSelect(string $query, array $args) : Generator {
  $this->connector->executeSelect($query, $args, yield, yield Await::REJECT);
  return yield Await::ONCE;
}

$done = function() {
  $this->getLogger()->info("Done!");
};
$onError = function(SqlError $error) {
  $this->getLogger()->logException($error);
};
Await::f2c(function() {
  $rows = yield $this->asyncSelect("query1", []);
  foreach($rows as $row) {
    $this->connector->executeInsert("query2", ["name" => $row["name"]], yield, yield Await::REJECT);
  }
  yield Await::ALL;
}, $done, $onError);