# Chronos
Chronos is an efficient async/await (opens new window) framework for Nim. Features include:
- Efficient dispatch pipeline for asynchronous execution
- HTTP server with SSL/TLS support out of the box (no OpenSSL needed)
- Cancellation support
- Synchronization primitivies like queues, events and locks
- FIFO processing order of dispatch queue
- Minimal exception effect support (see exception effects)
# Installation
You can use Nim's official package manager Nimble to install Chronos:
nimble install https://github.com/status-im/nim-chronos.git
or add a dependency to your .nimble
file:
requires "chronos"
# Projects using chronos
- libp2p (opens new window) - Peer-to-Peer networking stack implemented in many languages
- Looper (opens new window) - Web framework
- 2DeFi (opens new window) - Decentralised file system
chronos
is available in the Nim Playground (opens new window)
Submit a PR to add yours!
# Documentation
# Concepts
Chronos implements the async/await paradigm in a self-contained library, using macros, with no specific helpers from the compiler.
Our event loop is called a "dispatcher" and a single instance per thread is created, as soon as one is needed.
To trigger a dispatcher's processing step, we need to call poll()
- either
directly or through a wrapper like runForever()
or waitFor()
. This step
handles any file descriptors, timers and callbacks that are ready to be
processed.
Future
objects encapsulate the result of an async procedure, upon successful
completion, and a list of callbacks to be scheduled after any type of
completion - be that success, failure or cancellation.
(These explicit callbacks are rarely used outside Chronos, being replaced by
implicit ones generated by async procedure execution and await
chaining.)
Async procedures (those using the {.async.}
pragma) return Future
objects.
Inside an async procedure, you can await
the future returned by another async
procedure. At this point, control will be handled to the event loop until that
future is completed.
Future completion is tested with Future.finished()
and is defined as success,
failure or cancellation. This means that a future is either pending or completed.
To differentiate between completion states, we have Future.failed()
and
Future.cancelled()
.
# Dispatcher
You can run the "dispatcher" event loop forever, with runForever()
which is defined as:
proc runForever*() =
while true:
poll()
2
3
You can also run it until a certain future is completed, with waitFor()
which
will also call Future.read()
on it:
proc p(): Future[int] {.async.} =
await sleepAsync(100.milliseconds)
return 1
echo waitFor p() # prints "1"
2
3
4
5
waitFor()
is defined like this:
proc waitFor*[T](fut: Future[T]): T =
while not(fut.finished()):
poll()
return fut.read()
2
3
4
# Async procedures and methods
The {.async.}
pragma will transform a procedure (or a method) returning a
specialised Future
type into a closure iterator. If there is no return type
specified, a Future[void]
is returned.
proc p() {.async.} =
await sleepAsync(100.milliseconds)
echo p().type # prints "Future[system.void]"
2
3
4
Whenever await
is encountered inside an async procedure, control is passed
back to the dispatcher for as many steps as it's necessary for the awaited
future to complete successfully, fail or be cancelled. await
calls the
equivalent of Future.read()
on the completed future and returns the
encapsulated value.
proc p1() {.async.} =
await sleepAsync(1.seconds)
proc p2() {.async.} =
await sleepAsync(1.seconds)
proc p3() {.async.} =
let
fut1 = p1()
fut2 = p2()
# Just by executing the async procs, both resulting futures entered the
# dispatcher's queue and their "clocks" started ticking.
await fut1
await fut2
# Only one second passed while awaiting them both, not two.
waitFor p3()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Don't let await
's behaviour of giving back control to the dispatcher surprise
you. If an async procedure modifies global state, and you can't predict when it
will start executing, the only way to avoid that state changing underneath your
feet, in a certain section, is to not use await
in it.
# Error handling
Exceptions inheriting from CatchableError
are caught by hidden try
blocks
and placed in the Future.error
field, changing the future's status to
Failed
.
When a future is awaited, that exception is re-raised, only to be caught again
by a hidden try
block in the calling async procedure. That's how these
exceptions move up the async chain.
A failed future's callbacks will still be scheduled, but it's not possible to resume execution from the point an exception was raised.
proc p1() {.async.} =
await sleepAsync(1.seconds)
raise newException(ValueError, "ValueError inherits from CatchableError")
proc p2() {.async.} =
await sleepAsync(1.seconds)
proc p3() {.async.} =
let
fut1 = p1()
fut2 = p2()
await fut1
echo "unreachable code here"
await fut2
# `waitFor()` would call `Future.read()` unconditionally, which would raise the
# exception in `Future.error`.
let fut3 = p3()
while not(fut3.finished()):
poll()
echo "fut3.state = ", fut3.state # "Failed"
if fut3.failed():
echo "p3() failed: ", fut3.error.name, ": ", fut3.error.msg
# prints "p3() failed: ValueError: ValueError inherits from CatchableError"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
You can put the await
in a try
block, to deal with that exception sooner:
proc p3() {.async.} =
let
fut1 = p1()
fut2 = p2()
try:
await fut1
except CachableError:
echo "p1() failed: ", fut1.error.name, ": ", fut1.error.msg
echo "reachable code here"
await fut2
2
3
4
5
6
7
8
9
10
Chronos does not allow that future continuations and other callbacks raise
CatchableError
- as such, calls to poll
will never raise exceptions caused
originating from tasks on the dispatcher queue. It is however possible that
Defect
that happen in tasks bubble up through poll
as these are not caught
by the transformation.
# Platform independence
Several functions in chronos
are backed by the operating system, such as
waiting for network events, creating files and sockets etc. The specific
exceptions that are raised by the OS is platform-dependent, thus such functions
are declared as raising CatchableError
but will in general raise something
more specific. In particular, it's possible that some functions that are
annotated as raising CatchableError
only raise on some platforms - in order
to work on all platforms, calling code must assume that they will raise even
when they don't seem to do so on one platform.
# Exception effects
chronos
currently offers minimal support for exception effects and raises
annotations. In general, during the async
transformation, a generic
except CatchableError
handler is added around the entire function being
transformed, in order to catch any exceptions and transfer them to the Future
.
Because of this, the effect system thinks no exceptions are "leaking" because in
fact, exception handling is deferred to when the future is being read.
Effectively, this means that while code can be compiled with
{.push raises: [Defect]}
, the intended effect propagation and checking is
disabled for async
functions.
To enable checking exception effects in async
code, enable strict mode with
-d:chronosStrictException
.
In the strict mode, async
functions are checked such that they only raise
CatchableError
and thus must make sure to explicitly specify exception
effects on forward declarations, callbacks and methods using
{.raises: [CatchableError].}
(or more strict) annotations.
# TODO
- Pipe/Subprocess Transports.
- Multithreading Stream/Datagram servers