Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

__await__ magic method and async/await #3540

Merged
merged 10 commits into from
Oct 12, 2020
Merged

Conversation

kvc0
Copy link

@kvc0 kvc0 commented Oct 11, 2020

This change:

  • adds __await__() support as the backing implementation hook for CircuitPython async/await noun/verb pair.
  • enables async/await on lots of boards.
  • removes all special cases of if sys.implementation.name in ('micropython', 'circuitpython'): from micropython async tests. These tests now behave like CPython in CircuitPython.

By using __await__() rather than __iter__() (uPython uses iter), CircuitPython is more amenable to library interoperation with CPython. Users (and crucially, library developers!) can implement non-blocking APIs for complex devices in pure Python without writing state machines and saddling their application code with polling machines.

This is syntax-only. Without a task scheduler, one can use this language infrastructure via the send() method on the coroutine-generators though that is not the intended usage pattern.

Today there is an example task scheduler implementation based on a standard python asyncio usage pattern. It is different from asyncio in material ways but it provides an easy way to schedule recurring tasks and asynchronously drive disparate tasks without a loop() method. https://github.com/WarriorOfWire/tasko

This has used practically on my personal projects for months but has not achieved wide adoption through my personal repository. The error text is typically close to what CPython outputs and most cases of invalid syntax are reported similarly. It is expected that additional errata exist but the discovery and prioritization of resolution of the same should be shared going forward.

kvc0 and others added 8 commits October 10, 2020 15:38
This adds the `async def` and `await` verbs to valid CircuitPython syntax using the Micropython implementation.

Consider:
```
>>> class Awaitable:
...     def __iter__(self):
...         for i in range(3):
...             print('awaiting', i)
...             yield
...         return 42
...
>>> async def wait_for_it():
...     a = Awaitable()
...     result = await a
...     return result
...
>>> task = wait_for_it()
>>> next(task)
awaiting 0
>>> next(task)
awaiting 1
>>> next(task)
awaiting 2
>>> next(task)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  StopIteration: 42
>>>
```

and more excitingly:
```
>>> async def it_awaits_a_subtask():
...     value = await wait_for_it()
...     print('twice as good', value * 2)
...
>>> task = it_awaits_a_subtask()
>>> next(task)
awaiting 0
>>> next(task)
awaiting 1
>>> next(task)
awaiting 2
>>> next(task)
twice as good 84
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  StopIteration:
```

Note that this is just syntax plumbing, not an all-encompassing implementation of an asynchronous task scheduler or asynchronous hardware apis.
  uasyncio might be a good module to bring in, or something else - but the standard Python syntax does not _strictly require_ deeper hardware
  support.
Micropython implements the await verb via the __iter__ function rather than __await__.  It's okay.

The syntax being present will enable users to write clean and expressive multi-step state machines that are written serially and interleaved
  according to the rules provided by those users.

Given that this does not include an all-encompassing C scheduler, this is expected to be an advanced functionality until the community settles
  on the future of deep hardware support for async/await in CircuitPython.  Users will implement yield-based schedulers and tasks wrapping
  synchronous hardware APIs with polling to avoid blocking, while their application business logic gets simple `await` statements.
Some examples of improved compliance with CPython that currently
have divergent behavior in CircuitPython are listed below:

* yield from is not allowed in async methods
```
>>> async def f():
...     yield from 'abc'
...
Traceback (most recent call last):
  File "<stdin>", line 2, in f
SyntaxError: 'yield from' inside async function
```

* await only works on awaitable expressions
```
>>> async def f():
...     await 'not awaitable'
...
>>> f().send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in f
AttributeError: 'str' object has no attribute '__await__'
```

* only __await__()able expressions are awaitable
Okay this one actually does not work in circuitpython at all today.
This is how CPython works though and pretending __await__ does not
exist will only bite users who write both.
```
>>> class c:
...     pass
...
>>> def f(self):
...     yield
...     yield
...     return 'f to pay respects'
...
>>> c.__await__ = f  # could just as easily have put it on the class but this shows how it's wired
>>> async def g():
...     awaitable_thing = c()
...     partial = await awaitable_thing
...     return 'press ' + partial
...
>>> q = g()
>>> q.send(None)
>>> q.send(None)
>>> q.send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: press f to pay respects
```
Copy link
Member

@jepler jepler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, I'm very excited. Particularly I'm happy to see the identical code that can be written in Python3 and in CircuitPython with these changes.

ports/atmel-samd/boards/arduino_mkrzero/mpconfigboard.mk Outdated Show resolved Hide resolved
py/circuitpy_mpconfig.mk Show resolved Hide resolved
py/circuitpy_mpconfig.mk Outdated Show resolved Hide resolved
py/compile.c Outdated Show resolved Hide resolved
Copy link
Member

@jepler jepler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, this looks much better. Requesting Scott's review too.

@jepler jepler requested a review from tannewt October 12, 2020 20:59
Copy link
Member

@tannewt tannewt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool! Thank you for all of this work.

Have you thought about what hardware and library APIs could benefit from this?

@tannewt tannewt merged commit bb046f9 into adafruit:main Oct 12, 2020
@kvc0
Copy link
Author

kvc0 commented Oct 12, 2020

Very cool! Thank you for all of this work.

Have you thought about what hardware and library APIs could benefit from this?

Yes, I can't wait wait to make a context manager for communication resources, e.g., SPI.
Devices like BME680 take wall clock time to generate their results. I want to see library developers produce APIs that accept a context manager that provides access to a bus object. That context manager would bundle a chip select pin and the SPI bus together and provide the bus when nothing else happens to be using it at the particular moment.

class BME680:
  [...]
  async def read_sensor(self, async_bus_provider):
    # TODO: Add a context manager for `self` (a mutex) to make sure we're only doing 1 sensor thing at a time
    async with async_bus_provider as bus:
      self.start_measurement(bus)  # Completes quickly or you could make this async as well (e.g., for doing DMA before releasing the bus provider)
    # Release bus while the BME680 does its thing.  We'll check back later.
    # Other devices can use the SPI bus via the context manager.
    await loop.sleep(time_it_takes_for_bme680_to_sense)
    async with async_bus_provider as bus:
      sensor_state = self.read_measurement(bus)
    return sensor_state

Adding library support for this pattern allows synchronization to happen at the point of demand rather than at a top level workflow. Users of this api can do like sensor_data = await bme.read_sensor( bus_provider_for_cs14 ) and while the BME680 is doing that, their other stuff (like displayio or other I2C sensors or even other SPI devices) is all free to execute as soon as the processor has cycles.

In my own user interface application I see frequent sleeps for 5.8 milliseconds as I have a 100hz loop checking my rotary button for input. In the future I would like to see interrupt-based sleep state in addition to this polling model - though the polling model is already a huge improvement in ergonomics for complex applications.
The interrupt usage would look like

interrupt_handle = Interrupt( some_pin )
task = a_coroutine_function
loop.register_interrupt( interrupt_handle, a_function )

The event loop would watch for the pin to be interrupted and add an instance of a_coroutine_function to the active coroutines to be run later. The interrupt would be handled in C and would simply (I think) increment a counter (In no case would a python stack be introduced to an interrupt's execution context). TBD how to efficiently indicate to a sleeping event loop that it should wake (perhaps inserting a WakeException that the loop could handle), but in the interim the event loop could poll its Interrupts on some schedule. Without interrupts, polling is still pretty easy to do with await event_loop.sleep(however_long_you_need_to)

@siddacious
Copy link

@WarriorOfWire Thanks! This is great to see. I'm excited to start using it :)

That said, any pointers on reading material on how to use it would be appreciated as while I'm somewhat familiar with the concepts, I'm not quite sure where to start.

@kvc0
Copy link
Author

kvc0 commented Nov 3, 2020

@siddacious yeah, if you just want to write async/await code you should take a look at tasko https://github.com/WarriorOfWire/tasko

If you want to implement your own event loop from scratch then grab a coffee and follow along with David Beazley https://youtu.be/Y4Gt3Xjd7G8

In fact, watching that video might be the best recommendation on the topic I have in general.

@siddacious
Copy link

@WarriorOfWire perfect, thanks :)

@ErlendFj
Copy link

I am on a steep learning curve on CircuitPython and now I need to understand how to get async/await to work. First obstacle is I get: no module named 'asyncio'.
I am on 6.3.0. Any hints to where I can read up on this?

@dhalbert
Copy link
Collaborator

@ErlendFj asyncio is not yet available, though we are looking at event and concurrency support in more depth for 8.0.0. However, async/await is turned on for most boards, and there are several libraries already available. See this forum post: https://forums.adafruit.com/viewtopic.php?f=60&t=183355

@ErlendFj
Copy link

ErlendFj commented Sep 26, 2021 via email

@ErlendFj
Copy link

ErlendFj commented Oct 1, 2021

Hi,
I am trying out the asynccp sheduler, but I cannot figure out how to exchange values with the running tasks. I would be grateful for any hints.

#=================================================================
# Asynccp functions
#=================================================================

class App:
    def __init__(self):
        self.text_ID = ''             #result from task
        self.now_audio = 'Hei.wav'    #input to task        How to make these 'global'
        self.now_colour = BLACK       #input to task

    async def scan_NFC(self):
      uid = pn532.read_passive_target(timeout=0.1)
      print(".", end="")
      if uid is not None:
        text_ID = ''
        for e in uid:
          text_ID += '' + str(e)
        print(text_ID)

    async def play_audio(self):
        print("s", end="")
        if self.now_audio != '':  
           audio.play(audiocore.WaveFile(open(now_audio, "rb")))

    async def toggle_colour(self):
        print("t", end="")
        if self.now_colour is BLACK:
          pixels[0] = BLUE
          pixels.show()
          self.now_colour = BLUE
        elif self.now_colour is BLUE: 
          pixels[0] = BLACK
          pixels.show()
          self.now_colour = BLACK

def run():
    app = App()

    asynccp.schedule(frequency=1, coroutine_function=app.scan_NFC)
    asynccp.schedule(frequency=0.2, coroutine_function=app.play_audio)
    asynccp.schedule(frequency=0.1, coroutine_function=app.toggle_colour)
    asynccp.run()

if __name__ == '__main__':
    run()

@ErlendFj
Copy link

ErlendFj commented Oct 1, 2021

sorry - the insert code did not work as wanted

@jepler
Copy link
Member

jepler commented Oct 1, 2021

I corrected the code embed. However, a better place to seek help using CircuitPython is our Discord in #help-with-circuitpython or the Adafruit Forum. Since you're using a third party library (asynccp) you may also wish to inquire in whatever support forum they suggest.

@kvc0
Copy link
Author

kvc0 commented Oct 2, 2021

I'd suggest you should separate your app data from your app actions. Something like this:

class AppState:
    def __init__(self):
        self.now_audio = 'Hei.wav'
        self.now_colour = BLACK

async def scan_NFC():
  # This is an unfortunate expression. Is there not a start_read_passive_target() and is_read_passive_target_done()? Because then you could actually read from this and not block for up to 100 milliseconds like you're doing here.
  uid = pn532.read_passive_target(timeout=0.1)
  print(".", end="")
  if uid is not None:
    text_ID = ''  # This wasn't storing to app_state so I trimmed it from app_state. Don't keep useless state!
    for e in uid:
      text_ID += '' + str(e)
    print(text_ID)

async def play_audio(app_state):
    print("s", end="")
    if self.now_audio != '':  
       audio.play(audiocore.WaveFile(open(app_state.now_audio, "rb")))

async def toggle_colour(app_state):
    print("t", end="")
    if app_state.now_colour is BLACK:
      pixels[0] = BLUE
      pixels.show()
      app_state.now_colour = BLUE
    elif app_state.now_colour is BLUE: 
      pixels[0] = BLACK
      pixels.show()
      app_state.now_colour = BLACK

def run():
    app_state = AppState()

    asynccp.schedule(frequency=1, coroutine_function=app.scan_NFC, app_state)
    asynccp.schedule(frequency=0.2, coroutine_function=app.play_audio, app_state)
    asynccp.schedule(frequency=0.1, coroutine_function=app.toggle_colour, app_state)
    asynccp.run()

if __name__ == '__main__':
    run()

I definitely echo @jepler's advice about looking to the CircuitPython discord or Adafruit Forums for help writing circuitpython code!

@ErlendFj
Copy link

ErlendFj commented Oct 2, 2021

@jepler, @WarriorOfWire, thanks for your help. I shall go to discord, but since asynccp is not part of basic circuitpython I thought i'd risk a question here. I struggle to get my head around class/objects - in this instance I don't understand how the state variables work. I have got the code up and running through, and the different functions 'communicate' with each other by means og global variables. I'm sure that is the bad way to do it, but so far as I do not understand better, that is the way I have to do it. Agree about the pn532.read_passive_target(timeout=0.1) behaving as a blocker. I have squeezed the time down to 0.05s though. To get a start_read & is_read option I will have to figure out commands directly to the NFC chip. Apologize for misusing git for solving my problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants