Using Event
instances rather than callbacks in asyncio
device drivers can
simplify their design and standardise their APIs. It can also simplify
application logic.
This document assumes familiarity with asyncio
. See official docs and
unofficial tutorial.
- An alternative to callbacks in asyncio code
- Rationale
- Device driver design
- Primitives Facilitating Event-based application logic
4.1 WaitAny Wait on any of a group of event-like objects
4.2 WaitAll Wait on all of a group of event-like objects
4.3 Nesting - Event based programming
5.1 Use of Delay_ms A retriggerable delay
5.2 Long and very long button press
5.3 Application example - ELO class Convert a coroutine or task to an event-like object.
- Drivers Minimal Event-based drivers
7.1 ESwitch Debounced switch
7.2 EButton Debounced pushbutton with double and long press events
Callbacks have two merits. They are familiar, and they enable an interface which allows an asynchronous application to be accessed by synchronous code. GUI frameworks such as micro-gui form a classic example: the callback interface may be accessed by synchronous or asynchronous code.
For the programmer of asynchronous applications, callbacks are largely unnecessary and their use can lead to bugs.
The idiomatic way to write an asynchronous function that responds to external events is one where the function pauses while waiting on the event:
async def handle_messages(input_stream):
while True:
msg = await input_stream.readline()
await handle_data(msg)
Callbacks are not a natural fit in this model. Viewing the declaration of a
synchronous function, it is not evident how the function gets called or in what
context the code runs. Is it an ISR? Is it called from another thread or core?
Or is it a callback running in a asyncio
context? You cannot tell without
trawling the code. By contrast, a routine such as the above example is a self
contained process whose context and intended behaviour are evident.
The following steps can facilitate the use of asynchronous functions:
- Design device drivers to expose one or more bound
Event
objects. Alternatively design the driver interface to be that of anEvent
. - Design program logic to operate on objects with an
Event
interface.
The first simplifies the design of drivers and standardises their interface.
Users only need to know the names of the bound Event
instances. By contast
there is no standard way to specify callbacks, to define the passing of
callback arguments or to define how to retrieve their return values.
There are other ways to define an API without callbacks, notably the stream
mechanism and the use of asynchronous iterators with async for
. This doc
discusses the Event
based approach which is ideal for sporadic occurrences
such as responding to user input.
Consider a device driver Sensor
which has a bound Event
object .ready
.
An application might run a task of form:
async def process_sensor():
while True:
await sensor.ready.wait()
sensor.ready.clear()
# Read and process sensor data
Note that the action taken might be to run a callback or to launch a task:
async def process_sensor():
while True:
await sensor.ready.wait()
sensor.ready.clear()
result = callback(args)
asyncio.create_task(sensor_coro(args))
An Event
interface allows callback-based code and makes straightforward the
passing of arguments and retrieval of return values. However it also enables a
progrmming style that largely eliminates callbacks. Note that all you need to
know to access this driver interface is the name of the bound Event
.
This doc aims to demostrate that the event based approach can simplify application logic by eliminating the need for callbacks.
The design of asyncio
V3 and its Event
class enables this approach
because:
- A task waiting on an
Event
is put on a queue where it consumes no CPU cycles until the event is triggered. - The design of
asyncio
can support large numbers of tasks (hundreds) on a typical microcontroller. Proliferation of tasks is not a problem, especially where they are small and spend most of the time paused waiting on queues.
This contrasts with other schedulers (such as asyncio
V2) where there was no
built-in Event
class; typical Event
implementations used
polling and were convenience objects
rather than performance solutions.
The Event
class .clear
method provides additional flexibility relative to
callbacks:
- An
Event
can be cleared immediately after being set; if multiple tasks are waiting on.wait()
, all will resume running. - Alternatively the
Event
may be cleared later. The timing of clearing theEvent
determines its behaviour if, at the time when theEvent
is set, a task with anawait event.wait()
statement has not yet reached it. If execution reaches.wait()
before theEvent
is cleared, it will not pause. If theEvent
is cleared, it will pause until it is set again.
This document introduces the idea of an event-like object (ELO). This is an
object which may be used in place of an Event
in program code. An ELO must
expose a .wait
asynchronous method which will pause until an event occurs.
Additionally it can include .clear
and/or .set
. A device driver may become
an ELO by implementing .wait
or by subclassing Event
or ThreadSafeFlag
.
Alternatively a driver may expose one or more bound Event
or ELO instances.
ELO examples are:
Object | wait | clear | set | comments |
---|---|---|---|---|
Event | Y | Y | Y | |
ThreadSafeFlag | Y | N | Y | Self-clearing |
Message | Y | Y | Y | Subclass of above |
Delay_ms | Y | Y | Y | Self-setting |
WaitAll | Y | Y | N | See below |
WaitAny | Y | Y | N | |
ELO instances | Y | N | N |
The ELO
class converts coroutines or Task
instances to event-like objects,
allowing them to be included in the arguments of event based primitives.
Drivers exposing Event
instances include:
- ESwitch Micro debounced interface to a switch.
- EButton Micro debounced interface to a pushbutton.
- Switch Similar but interfaces also expose callbacks.
- Pushbutton
Applying Events
to typical logic problems requires two new primitives:
WaitAny
and WaitAll
. Each is an ELO. These primitives may be cancelled or
subject to a timeout with asyncio.wait_for()
, although judicious use of
Delay_ms
offers greater flexibility than wait_for
.
The constructor takes an iterable of ELO's. Its .wait
method pauses until the
first of the ELO's is set; the method returns the object that triggered it,
enabling the application code to determine the reason for its triggering.
The last ELO to trigger a WaitAny
instance may also be retrieved by issuing
the instance's .event()
method.
from primitives import WaitAny
async def foo(elo1, elo2):
evt = await WaitAny((elo1, elo2)).wait()
if evt is elo1:
# Handle elo1
WaitAny
has a clear
method which issues .clear()
to all passed ELO's with
a .clear
method.
The constructor takes an iterable of ELO's. Its .wait
method pauses until all
of the ELO's is set.
WaitAll
has a clear
method which issues .clear()
to all passed ELO's with
a .clear
method.
The fact that these primitives are ELO's enables nesting:
await WaitAll((event1, event2, WaitAny(event3, event4))).wait()
This will pause until event1
and event2
and either event3
or event4
have
been set.
The Delay_ms class
is an ELO and can be used as an alternative to asyncio.wait_for
: it has the
advantage that it can be retriggered. It can also be stopped or its duration
changed dynamically. In the following sample task_a
waits on an Event
but
it also aborts if task_b
stops running for any reason:
from primitives import Delay_ms, WaitAny
delay = Delay_ms(duration=1000)
async def task_b():
while True:
delay.trigger() # Keep task_a alive
# do some work
await asyncio.sleep_ms(0)
async def task_a(evt): # Called with an event to wait on
while True:
cause = await WaitAny((evt, delay)).wait()
if cause is delay: # task_b has ended
delay.clear() # Clear the Event
return # Abandon the task
# Event has occurred
evt.clear()
# Do some work
await asyncio.sleep_ms(0)
A user had a need to distinguish short, fairly long, and very long presses of a
pushbutton. There was no requirement to detect double clicks, so the minimal
ESwitch
driver was used.
This solution does not attempt to disambiguate the press events: if a very long press occurs, the short press code will run, followed by the "fairly long" code, and then much later by the "very long" code. Disambiguating implies first waiting for button release and then determining which application code to run: in the application this delay was unacceptable.
async def main():
btn = ESwitch(Pin('X17', Pin.IN, Pin.PULL_UP), lopen=0)
ntim = Delay_ms(duration = 1000) # Fairly long press
ltim = Delay_ms(duration = 8000) # Very long press
while True:
ltim.stop() # Stop any running timers and clear their event
ntim.stop()
await btn.close.wait()
btn.close.clear()
ntim.trigger() # Button pressed, start timers, await release
ltim.trigger() # Run any press code
ev = await WaitAny((btn.open, ntim)).wait()
if ev is btn.open:
# Run "short press" application code
else: # ev is ntim: Fairly long timer timed out
# Run "fairly long" application code
# then check for very long press
ev = await WaitAny((btn.open, ltim)).wait()
if ev is ltim: # Long timer timed out
# Run "very long" application code
# We have not cleared the .open Event, so if the switch is already open
# there will be no delay below. Otherwise we await realease.
# Must await release otherwise the event is cleared before release
# occurs, setting the release event before the next press event.
await btn.open.wait()
btn.open.clear()
Disambiguated version. Wait for button release and decide what to do based on which timers are still running:
async def main():
btn = ESwitch(Pin('X17', Pin.IN, Pin.PULL_UP), lopen=0)
ntim = Delay_ms(duration=1000) # Fairly long press
ltim = Delay_ms(duration=8000) # Very long press
while True:
ltim.stop() # Stop any running timers and clear their event
ntim.stop()
await btn.close.wait()
btn.close.clear()
ntim.trigger() # Button pressed, start timers, await release
ltim.trigger() # Run any press code
await btn.open.wait()
btn.open.clear()
# Button released: check for any running timers
if not ltim(): # Very long press timer timed out before button was released
# Run "Very long" code
elif not ntim():
# Run "Fairly long" code
else:
# Both timers running: run "short press" code
A measuring instrument is started by pressing a button. The measurement normally runs for five seconds. If the sensor does not detect anything, the test runs until it does, however it is abandoned if nothing has been detected after a minute. While running, extra button presses are ignored. During a normal five second run, extra detections from the sensor are ignored.
This can readily be coded using callbacks and synchronous or asynchronous code, however the outcome is likely to have a fair amount of ad hoc logic.
This event based solution is arguably clearer to read:
from primitives import EButton, WaitAll, Delay_ms
btn = EButton(args) # Has Events for press, release, double, long
bp = btn.press
sn = Sensor(args) # Assumed to have an Event interface.
tm = Delay_ms(duration=5_000) # Exposes .wait and .clear only.
events = (sn, tm)
async def foo():
while True:
bp.clear() # Ignore prior button press
await bp.wait() # Button pressed
events.clear() # Ignore events that were set prior to this moment
tm.trigger() # Start 5 second timer
try:
await asyncio.wait_for(WaitAll(events).wait(), 60)
except asyncio.TimeoutError:
print("No reading from sensor")
else:
# Normal outcome, process readings
This converts a task to an "event-like object", enabling tasks to be included in
WaitAll
and WaitAny
arguments. An ELO
instance is a wrapper for a Task
instance and its lifetime is that of its Task
. The constructor can take a
coroutine or a task as its first argument; in the former case the coro is
converted to a Task
.
coro
This may be a coroutine or aTask
instance.*args
Positional args for a coroutine (ignored if aTask
is passed).**kwargs
Keyword args for a coroutine (ignored if aTask
is passed).
If a coro is passed it is immediately converted to a Task
and scheduled for
execution.
wait
Pauses until theTask
is complete or is cancelled. In the latter case no exception is thrown.
__call__
Returns the instance'sTask
. If the instance'sTask
was cancelled theCancelledError
exception is returned. The function call operator allows a running task to be accessed, e.g. for cancellation. It also enables return values to be retrieved.
In most use cases an ELO
instance is a throw-away object which allows a coro
to participate in an event-based primitive:
evt = asyncio.Event()
async def my_coro(t):
await asyncio.wait(t)
async def foo(): # Puase until the event has been triggered and coro has completed
await WaitAll((evt, ELO(my_coro, 5))).wait() # Note argument passing
A task may return a result on completion. This may be accessed by awaiting the
ELO
instance's Task
. A reference to the Task
may be acquired with function
call syntax. The following code fragment illustrates usage. It assumes that
task
has already been created, and that my_coro
is a coroutine taking an
integer arg. There is an EButton
instance ebutton
and execution pauses until
tasks have run to completion and the button has been pressed.
async def foo():
elos = (ELO(my_coro, 5), ELO(task))
events = (ebutton.press,)
await WaitAll(elos + events).wait()
for e in elos: # Retrieve results from each task
r = await e() # Works even though task has already completed
print(r)
This works because it is valid to await
a task which has already completed.
The await
returns immediately with the result. If WaitAny
were used an ELO
instance might contain a running task. In this case the line
r = await e()
would pause before returning the result.
The Task
in ELO
instance elo
may be retrieved by issuing elo()
. For
example the following will subject an ELO
instance to a timeout:
async def elo_timeout(elo, t):
await asyncio.sleep(t)
elo().cancel() # Retrieve the Task and cancel it
async def foo():
elo = ELO(my_coro, 5)
asyncio.create_task(elo_timeout(2))
await WaitAll((elo, ebutton.press)).wait() # Until button press and ELO either finished or timed out
If the ELO
task is cancelled, .wait
terminates; the exception is retained.
Thus WaitAll
or WaitAny
behaves as if the task had terminated normally. A
subsequent call to elo()
will return the exception. In an application
where the task might return a result or be cancelled, the following may be used:
async def foo():
elos = (ELO(my_coro, 5), ELO(task))
events = (ebutton.press,)
await WaitAll(elos + events).wait()
for e in elos: # Check each task
t = e()
if isinstance(t, asyncio.CancelledError):
# Handle exception
else: # Retrieve results
r = await t # Works even though task has already completed
print(r)
The following device drivers provide an Event
based interface for switches and
pushbuttons.
This is now documented here.
This is now documented here.
Documentation for Keyboard
, SwArray
and RingbufQueue
has also moved to
primtives.
The primitives or drivers referenced here do not use polling with the following exceptions:
- Switch and pushbutton drivers. These poll the
Pin
instance for electrical reasons described below. ThreadSafeFlag
and subclassMessage
: these use the stream mechanism.
Other drivers and primitives are designed such that paused tasks are waiting on queues and are therefore using no CPU cycles.
This reference states that bouncing contacts can assume invalid logic
levels for a period. It is a reasonable assumption that Pin.value()
always
returns 0 or 1: the drivers are designed to cope with any sequence of such
readings. By contrast, the behaviour of IRQ's under such conditions may be
abnormal. It would be hard to prove that IRQ's could never be missed, across
all platforms and input conditions.
Pin polling aims to use minimal resources, the main overhead being asyncio
's
task switching overhead: typically about 250 μs. The default polling interval
is 50 ms giving an overhead of ~0.5%.