8156e2997a
* Fix waitFor() should not exit earlier last callback will be scheduled. * Tune tests to use less memory. * Fix `testutils`. There is no more last poll() needed. * Update chronos/internal/asyncfutures.nim --------- Co-authored-by: Jacek Sieka <jacek@status.im> |
||
---|---|---|
.github/workflows | ||
chronos | ||
tests | ||
.appveyor.yml | ||
.gitignore | ||
.travis.yml | ||
LICENSE-APACHEv2 | ||
LICENSE-MIT | ||
README.md | ||
chronos.nim | ||
chronos.nimble | ||
config.nims |
README.md
Chronos - An efficient library for asynchronous programming
Introduction
Chronos is an efficient async/await framework for Nim. Features include:
- Asynchronous socket and process I/O
- HTTP server with SSL/TLS support out of the box (no OpenSSL needed)
- Synchronization primitivies like queues, events and locks
- Cancellation
- Efficient dispatch pipeline with excellent multi-platform support
- Exception effect support (see exception effects)
Installation
You can use Nim's official package manager Nimble to install Chronos:
nimble install chronos
or add a dependency to your .nimble
file:
requires "chronos"
Projects using chronos
- libp2p - Peer-to-Peer networking stack implemented in many languages
- presto - REST API framework
- Scorper - Web framework
- 2DeFi - Decentralised file system
- websock - WebSocket library with lots of features
chronos
is available in the Nim Playground
Submit a PR to add yours!
Documentation
Concepts
Chronos implements the async/await paradigm in a self-contained library using the macro and closure iterator transformation features provided by Nim.
The 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()
. Each 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()
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"
waitFor()
is defined like this:
proc waitFor*[T](fut: Future[T]): T =
while not(fut.finished()):
poll()
return fut.read()
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]"
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()
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
interrupt execution of the async
procedure. The exception is placed in the
Future.error
field while changing the status of the Future
to Failed
and callbacks are scheduled.
When a future is awaited, the exception is re-raised, traversing the async
execution chain until handled.
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"
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
Because chronos
ensures that all exceptions are re-routed to the Future
,
poll
will not itself raise exceptions.
poll
may still panic / raise Defect
if such are raised in user code due to
undefined behavior.
Checked exceptions
By specifying a raises
list to an async procedure, you can check which
exceptions can be raised by it:
proc p1(): Future[void] {.async: (raises: [IOError]).} =
assert not (compiles do: raise newException(ValueError, "uh-uh"))
raise newException(IOError, "works") # Or any child of IOError
proc p2(): Future[void] {.async, (raises: [IOError]).} =
await p1() # Works, because await knows that p1
# can only raise IOError
Under the hood, the return type of p1
will be rewritten to an internal type
which will convey raises informations to await
.
The Exception
type
Exceptions deriving from Exception
are not caught by default as these may
include Defect
and other forms undefined or uncatchable behavior.
Because exception effect tracking is turned on for async
functions, this may
sometimes lead to compile errors around forward declarations, methods and
closures as Nim conservatively asssumes that any Exception
might be raised
from those.
Make sure to excplicitly annotate these with {.raises.}
:
# Forward declarations need to explicitly include a raises list:
proc myfunction() {.raises: [ValueError].}
# ... as do `proc` types
type MyClosure = proc() {.raises: [ValueError].}
proc myfunction() =
raise (ref ValueError)(msg: "Implementation here")
let closure: MyClosure = myfunction
For compatibility, async
functions can be instructed to handle Exception
as
well, specifying handleException: true
. Exception
that is not a Defect
and
not a CatchableError
will then be caught and remapped to
AsyncExceptionError
:
proc raiseException() {.async: (handleException: true, raises: [AsyncExceptionError]).} =
raise (ref Exception)(msg: "Raising Exception is UB")
proc callRaiseException() {.async: (raises: []).} =
try:
raiseException()
except AsyncExceptionError as exc:
# The original Exception is available from the `parent` field
echo exc.parent.msg
This mode can be enabled globally with -d:chronosHandleException
as a help
when porting code to chronos
but should generally be avoided as global
configuration settings may interfere with libraries that use chronos
leading
to unexpected behavior.
Raw functions
Raw functions are those that interact with chronos
via the Future
type but
whose body does not go through the async transformation.
Such functions are created by adding raw: true
to the async
parameters:
proc rawAsync(): Future[void] {.async: (raw: true).} =
let future = newFuture[void]("rawAsync")
future.complete()
return future
Raw functions must not raise exceptions directly - they are implicitly declared
as raises: []
- instead they should store exceptions in the returned Future
:
proc rawFailure(): Future[void] {.async: (raw: true).} =
let future = newFuture[void]("rawAsync")
future.fail((ref ValueError)(msg: "Oh no!"))
return future
Raw functions can also use checked exceptions:
proc rawAsyncRaises(): Future[void] {.async: (raw: true, raises: [IOError]).} =
let fut = newFuture[void]()
assert not (compiles do: fut.fail((ref ValueError)(msg: "uh-uh")))
fut.fail((ref IOError)(msg: "IO"))
return fut
Callbacks and closures
Callback/closure types are declared using the async
annotation as usual:
type MyCallback = proc(): Future[void] {.async.}
proc runCallback(cb: MyCallback) {.async: (raises: []).} =
try:
await cb()
except CatchableError:
discard # handle errors as usual
When calling a callback, it is important to remember that the given function may raise and exceptions need to be handled.
Checked exceptions can be used to limit the exceptions that a callback can raise:
type MyEasyCallback = proc: Future[void] {.async: (raises: []).}
proc runCallback(cb: MyEasyCallback) {.async: (raises: [])} =
await cb()
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.
Cancellation support
Any running Future
can be cancelled. This can be used for timeouts,
to let a user cancel a running task, to start multiple futures in parallel
and cancel them as soon as one finishes, etc.
import chronos/apps/http/httpclient
proc cancellationExample() {.async.} =
# Simple cancellation
let future = sleepAsync(10.minutes)
future.cancelSoon()
# `cancelSoon` will not wait for the cancellation
# to be finished, so the Future could still be
# pending at this point.
# Wait for cancellation
let future2 = sleepAsync(10.minutes)
await future2.cancelAndWait()
# Using `cancelAndWait`, we know that future2 isn't
# pending anymore. However, it could have completed
# before cancellation happened (in which case, it
# will hold a value)
# Race between futures
proc retrievePage(uri: string): Future[string] {.async.} =
let httpSession = HttpSessionRef.new()
try:
let resp = await httpSession.fetch(parseUri(uri))
return bytesToString(resp.data)
finally:
# be sure to always close the session
# `finally` will run also during cancellation -
# `noCancel` ensures that `closeWait` doesn't get cancelled
await noCancel(httpSession.closeWait())
let
futs =
@[
retrievePage("https://duckduckgo.com/?q=chronos"),
retrievePage("https://www.google.fr/search?q=chronos")
]
let finishedFut = await one(futs)
for fut in futs:
if not fut.finished:
fut.cancelSoon()
echo "Result: ", await finishedFut
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 ordering of events 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!"
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 and handled (for instance, freeing some resources)
Multiple async backend support
Thanks to its powerful macro support, Nim allows async
/await
to be
implemented in libraries with only minimal support from the language - as such,
multiple async
libraries exist, including chronos
and asyncdispatch
, and
more may come to be developed in the futures.
Libraries built on top of async
/await
may wish to support multiple async
backends - the best way to do so is to create separate modules for each backend
that may be imported side-by-side - see nim-metrics
for an example.
An alternative way is to select backend using a global compile flag - this
method makes it diffucult to compose applications that use both backends as may
happen with transitive dependencies, but may be appropriate in some cases -
libraries choosing this path should call the flag asyncBackend
, allowing
applications to choose the backend with -d:asyncBackend=<backend_name>
.
Known async
backends include:
chronos
- this library (-d:asyncBackend=chronos
)asyncdispatch
the standard libraryasyncdispatch
module (-d:asyncBackend=asyncdispatch
)none
--d:asyncBackend=none
- disableasync
support completely
none
can be used when a library supports both a synchronous and
asynchronous API, to disable the latter.
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.
TODO
- Pipe/Subprocess Transports.
- Multithreading Stream/Datagram servers
Contributing
When submitting pull requests, please add test cases for any new features or fixes and make sure nimble test
is still able to execute the entire test suite successfully.
chronos
follows the Status Nim Style Guide.
Other resources
License
Licensed and distributed under either of
- MIT license: LICENSE-MIT or http://opensource.org/licenses/MIT
or
- Apache License, Version 2.0, (LICENSE-APACHEv2 or http://www.apache.org/licenses/LICENSE-2.0)
at your option. These files may not be copied, modified, or distributed except according to those terms.