Rewriting Code with Async Await Syntax - Perl

An illustration on using Async Await Syntax to rewrite code

Things to consider when rewriting code to use the new async/await syntax provided by Future::AsyncAwait.

The Future class provides basic asynchronous future functionality, and also a number of handy building-blocks for building larger code structures into meaningful programs. With the addition of the async/await syntax provided by Future::AsyncAwait, several of these code structures are no longer necessary, because equivalent code can be written in a style much closer to traditional "straight-line" synchronous Perl code. In addition, other benefits of using Future::AsyncAwait and futures mean that it is often desirable to rewrite code to using these new forms where possible. In this series of articles I aim to cover many of the situations that will be encountered when performing such a rewrite, and suggest how code may be changed to make use of the new syntax while preserving the existing behaviour.

For this first part, we'll look at uses of Future for sequencing multiple different code blocks to happen one after the other.


->then chaining

Probably the most common (and among the simplest) examples of future-based code is the use of the ->then method to schedule further blocks of code to run once the first as completed. These can be rewritten into using the await keyword to wait for the first expression inside an async sub, and then simply putting more code afterwards. If there are more ->then chains following that, then more await expressions can be used.

# previously
sub example {
  return FIRST()->then( sub {
    code in the middle;
    SECOND();
  })->then( sub {
    more code goes here;
    THIRD();
  });
}

# becomes
async sub example {
  await FIRST();
  code in the middle;
  await SECOND();
  more code goes here;
  await THIRD();
}

Then chaining in async code

Of particular note is to remember to await even in the final expression of the function, where the call to THIRD() is expected to return a future instance. If this isn't done then callers of example will receive a future which will complete as soon as SECOND() finishes, and its result will itself be the future instance that THIRD() returned - a double-nested future. The caller likely didn't want that result.

As well as being neater and more readable, containing less of the "machinery noise" from the ->then method calls, the fact that all of the expressions now appear within the same lexical scope of the body of the function allows them to use the same lexical variables. This means you no longer need to "hoist" variable declarations out of the chain if you need to capture values in one block to be used in a subsequent one.

# previously
sub example {
  my $thing;
  GET_THING()->then(sub {
    ($thing) = @_;
    do_something_else();
  })->then(sub {
    USE_THING($thing);
  });
}

# becomes
async sub example {
  my $thing = await GET_THING();
  await do_something_else();
  await USE_THING($thing);
}

Async code for ->then chaining

->else chaining

As future instances can complete with either a successful or a failed result, many examples will use the ->else method to attach some error-handling code. When using async/await syntax, a failed future causes an exception to be thrown from the await keyword, so we can intercept that using the try/catch syntax provided by Syntax::Keyword::Try.

(It is important to use Syntax::Keyword::Try rather than Try::Tiny, as the latter implements its syntax using anonymous inner functions, which confuse the async/await suspend-and-resume mechanism, whereas the former uses inline code blocks as part of the containing function, allowing async/await to work correctly).

A common use of ->else is to handle a failure by yielding some sort of other "default" value, in place of the value that the failing function would have returned.

# previously
sub example {
  return TRY_A_THING()->else(sub {
    my ($message) = @_;
    warn "Failed to do the thing - $message";
    return Future->done($DEFAULT);
  });
}

# becomes
use Syntax::Keyword::Try;
async sub {
  try {
    return await TRY_A_THING();
  } catch {
    my $message = $@;
    warn "Failed to do the thing - $message";
    return $DEFAULT;
  }
}

Example of Async code

Note here that return is able to cause the entire containing function to return that result. Sometimes, there would be more code after the ->else block - perhaps chained by another ->then. In this case we have to capture the result of the try/catch into a variable, for later inspection.

# previously
sub example {
  return TRY_GET_RESOURCE()->else(sub {
    return Future->done($DEFAULT_RESOURCE);
  })->then(sub {
    my ($resource) = @_;
    return USE_RESOURCE($resource);
  });
}

# becomes
use Syntax::Keyword::Try;
async sub example {
  my $resource;
  try {
    $resource = await TRY_GET_RESOURCE();
  } catch {
    $resource = $DEFAULT_RESOURCE;
  }
  await USE_RESOURCE($resource);
}

Example of Async code

Another use for ->else is to capture and re-throw a failure, but annotating it somehow to provide more detail. We can handle this by capturing the exception as before, inspecting the details out of it, and rethrowing another one. We can use Future::Exception->throw to throw an exception containing the additional details from a failed future:

# previously
sub example {
  my ($user) = @_;
  
  return HTTP_GET("https://example.org/info/$user")->else(sub {
    my ($message, $category, @details) = @_;
    return Future->fail(
      "Unable to get user info for $user - $message",
      $category, @details
    );
  });
}

# becomes
use Syntax::Keyword::Try;
async sub example {
  my ($user) = @_;
  
  try {
    return await HTTP_GET("https://example.org/info/$user");
  } catch {
    my $e = $@;
    my $message = $e->message;
    Future::Exception->throw(
      "Unable to get user info for $user - $message",
      $e->category, $e->details
    );
  }

An example of else code in async code

->transform

The ->transform method provides a convenience for converting the result into a different form, by use of a code block giving a transformation function. Because an await operator can appear anywhere within an expression, the relevant code can just be written more directly:

# previously
sub example {
  my ($uid) = @_;
  
  return GET_USER_INFO($uid)->transform(
    done => sub {
      return { uid => $uid, info => $_[0] };
    },
  );
}

# becomes
async sub example {
  my ($uid) = @_;
  
  return { uid => $uid, info => await GET_USER_INFO($uid) };
}

An example of transform code for async await

Immediate Return Values

Sometimes a function doesn't actually perform any asynchronous work, but simply returns a value in an immediate future using Future->done, perhaps for API or consistency reasons. In this case because an async sub always returns its value by a Future, we can simply turn the function into an async sub and return the value directly - Future::AsyncAwait will handle the rest:

# previously
sub example {
  return Future->done(\%GLOBAL_SETTINGS);
}

# becomes
async sub example {
  return \%GLOBAL_SETTINGS;
}

Sub example for async await

In this case, just because we have an async sub there is no requirement to actually call await anywhere within it. If we don't, the function will run synchronously and yield its result in an immediate future.

Similarly, sometimes a function will want to return an immediate failure by using Future->fail. Any exception thrown in the body of an async sub is turned into a failed future, but if we want to apply the category name and additional details, we have to use Future::Exception->throw, as already seen above.

# previously
sub example {
  my ($req) = @_;
  
  return Future->fail("Invalid user ID", req => $req)
    unless is_valid($req->{uid});
    
  ...
}

# becomes
async sub example {
  my ($req) = @_;
  
  Future::Exception->throw("Invalid user ID", req => $req)
    unless is_valid($req->{uid});
    
  ...
}

An example of future fail in async await


In part 2 we'll take a look at the various iteration forms provided by Future::Utils::repeat, and in part 3 we'll finish off by looking at the conditionals and concurrency provided by needs_all or similar.

Note: This post has been ported from https://tech.binary.com/ (our old tech blog). Author of this post is https://metacpan.org/author/PEVANS