Concepts

Async/await is a programming model that relies on cooperative multitasking to coordinate the concurrent execution of procedures, using event notifications from the operating system or other treads to resume execution.

Code execution happens in a loop that alternates between making progress on tasks and handling events.

The dispatcher

The event handler loop is called a "dispatcher" and a single instance per thread is created, as soon as one is needed.

Scheduling is done by calling async procedures that return Future objects - each time a procedure is unable to make further progress, for example because it's waiting for some data to arrive, it hands control back to the dispatcher which ensures that the procedure is resumed when ready.

A single thread, and thus a single dispatcher, is typically able to handle thousands of concurrent in-progress requests.

The Future type

Future objects encapsulate the outcome of executing an async procedure. The Future may be pending meaning that the outcome is not yet known or finished meaning that the return value is available, the operation failed with an exception or was cancelled.

Inside an async procedure, you can await the outcome of another async procedure - if the Future representing that operation is still pending, a callback representing where to resume execution will be added to it and the dispatcher will be given back control to deal with other tasks.

When a Future is finished, all its callbacks are scheduled to be run by the dispatcher, thus continuing any operations that were waiting for an outcome.

The poll call

To trigger the processing step of the dispatcher, we need to call poll() - either directly or through a wrapper like runForever() or waitFor().

Each call to poll handles any file descriptors, timers and callbacks that are ready to be processed.

Using waitFor, the result of a single asynchronous operation can be obtained:

proc myApp() {.async.} =
  echo "Waiting for a second..."
  await sleepAsync(1.seconds)
  echo "done!"

waitFor myApp()

It is also possible to keep running the event loop forever using runForever:

proc myApp() {.async.} =
  while true:
    await sleepAsync(1.seconds)
    echo "A bit more than a second passed!"

let future = myApp()
runForever()

Such an application never terminates, thus it is rare that applications are structured this way.

Both `waitFor` and `runForever` call `poll` which offers fine-grained
control over the event loop steps.

Nested calls to `poll` - directly or indirectly via `waitFor` and `runForever`
are not allowed.

Cancellation

Any pending Future can be cancelled. This can be used for timeouts, to start multiple parallel operations and cancel the rest as soon as one finishes, to initiate the orderely shutdown of an application etc.

## Simple cancellation example

import chronos

proc someTask() {.async.} = await sleepAsync(10.minutes)

proc cancellationExample() {.async.} =
  # Start a task but don't wait for it to finish
  let future = someTask()
  future.cancelSoon()
  # `cancelSoon` schedules but does not wait for the future to get cancelled -
  # it might still be pending here

  let future2 = someTask() # Start another task concurrently
  await future2.cancelAndWait()
  # Using `cancelAndWait`, we can be sure that `future2` is either
  # complete, failed or cancelled at this point. `future` could still be
  # pending!
  assert future2.finished()

waitFor(cancellationExample())

Even if cancellation is initiated, it is not guaranteed that the operation gets cancelled - the future might still be completed or fail depending on the order of events in the dispatcher and the specifics of the operation.

If the future indeed gets cancelled, await will raise a CancelledError as is likely to happen in the following example:

proc c1 {.async.} =
  echo "Before sleep"
  try:
    await sleepAsync(10.minutes)
    echo "After sleep" # not reach due to cancellation
  except CancelledError as exc:
    echo "We got cancelled!"
    # `CancelledError` is typically re-raised to notify the caller that the
    # operation is being cancelled
    raise exc

proc c2 {.async.} =
  await c1()
  echo "Never reached, since the CancelledError got re-raised"

let work = c2()
waitFor(work.cancelAndWait())

The CancelledError will now travel up the stack like any other exception. It can be caught for instance to free some resources and is then typically re-raised for the whole chain operations to get cancelled.

Alternatively, the cancellation request can be translated to a regular outcome of the operation - for example, a read operation might return an empty result.

Cancelling an already-finished Future has no effect, as the following example of downloading two web pages concurrently shows:

## Make two http requests concurrently and output the one that wins

import chronos
import ./httpget

proc twoGets() {.async.} =
  let
    futs = @[
      # Both pages will start downloading concurrently...
      httpget.retrievePage("https://duckduckgo.com/?q=chronos"),
      httpget.retrievePage("https://www.google.fr/search?q=chronos")
    ]

  # Wait for at least one request to finish..
  let winner = await one(futs)
  # ..and cancel the others since we won't need them
  for fut in futs:
    # Trying to cancel an already-finished future is harmless
    fut.cancelSoon()

  # An exception could be raised here if the winning request failed!
  echo "Result: ", winner.read()

waitFor(twoGets())

Ownership

When calling a procedure that returns a Future, ownership of that Future is shared between the callee that created it and the caller that waits for it to be finished.

The Future can be thought of as a single-item channel between a producer and a consumer. The producer creates the Future and is responsible for completing or failing it while the caller waits for completion and may cancel it.

Although it is technically possible, callers must not complete or fail futures and callees or other intermediate observers must not cancel them as this may lead to panics and shutdown (ie if the future is completed twice or a cancalletion is not handled by the original caller).

noCancel

Certain operations must not be cancelled for semantic reasons. Common scenarios include closeWait that releases a resources irrevocably and composed operations whose individual steps should be performed together or not at all.

In such cases, the noCancel modifier to await can be used to temporarily disable cancellation propagation, allowing the operation to complete even if the caller initiates a cancellation request:

proc deepSleep(dur: Duration) {.async.} =
  # `noCancel` prevents any cancellation request by the caller of `deepSleep`
  # from reaching `sleepAsync` - even if `deepSleep` is cancelled, its future
  # will not complete until the sleep finishes.
  await noCancel sleepAsync(dur)

let future = deepSleep(10.minutes)

# This will take ~10 minutes even if we try to cancel the call to `deepSleep`!
await cancelAndWait(future)

join

The join modifier to await allows cancelling an async procedure without propagating the cancellation to the awaited operation. This is useful when await:ing a Future for monitoring purposes, ie when a procedure is not the owner of the future that's being await:ed.

One situation where this happens is when implementing the "observer" pattern, where a helper monitors an operation it did not initiate:

var tick: Future[void]
proc ticker() {.async.} =
  while true:
    tick = sleepAsync(1.second)
    await tick
    echo "tick!"

proc tocker() {.async.} =
  # This operation does not own or implement the operation behind `tick`,
  # so it should not cancel it when `tocker` is cancelled
  await join tick
  echo "tock!"

let
  fut = ticker() # `ticker` is now looping and most likely waiting for `tick`
  fut2 = tocker() # both `ticker` and `tocker` are waiting for `tick`

# We don't want `tocker` to cancel a future that was created in `ticker`
waitFor fut2.cancelAndWait()

waitFor fut # keeps printing `tick!` every second.

Compile-time configuration

chronos contains several compile-time configuration options enabling stricter compile-time checks and debugging helpers whose runtime cost may be significant.

Strictness options generally will become default in future chronos releases and allow adapting existing code without changing the new version - see the config.nim module for more information.