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

Refactor to go async #41

Merged
merged 22 commits into from
Dec 9, 2024
Merged

Refactor to go async #41

merged 22 commits into from
Dec 9, 2024

Conversation

almarklein
Copy link
Member

@almarklein almarklein commented Nov 28, 2024

Summary

This PR adds support for async callback functions, and support for trio. Major refactoring was needed for this.

Introduction

I was adding support for async event handlers, but that turned into a complete overhaul, moving from a system based on timers and callbacks to async functions. I guess that's what async does. Let's also add a loop for trio, I thought casually 😅 . But this meant that backend canvases and loops must be more loosely coupled, which opened up a rabbit-hole way deeper than I imagined.

To be honest this process was quite a ride, and sometimes I wondered what the hell I was doing. But the end result is pretty darn nice, in my humble opinion. The API has barely changed, but opens up possibilities that can have big implications.

How this affects the API

  • You can put the word async before def your_event_handler(event):.
    • This means we can use the async part of the wgpu API, which is a prerequisite to move to pyodide.
    • Also play nicer with async frameworks, e.g. for a webserver that renders frames on demand.
  • You can use RenderCanvas.select_loop(loop) at the top of your code to run on an alternative loop.
    • The main use-case is to select either asyncio or trio for the glfw backend.
    • But yeah you can also run a Qt canvas on a trio loop if you feel silly today.
  • New rendercanvas.trio.loop object.
  • The trio and asyncio loop have a .run_async() method so the loop can be started in a trio-nesque or asyncio-ish way.
  • For the record, users are not locked into async functions.
    • If you write a qt app you probably don't use any.
    • There still is loop.call_later(), which works with all loop-backends.

What async stuff can people use?

Users writing backend-agnostic code can use sleep and Event. These can be imported from rendercanvas.utils.asyncs. Or they can use sniffio and then import it from either trio, asyncio or rendercanvas._async_adapter.

Users writing code explicitly for asyncio can simpy use all the asyncio stuff. Same for Trio. If a user is using qt, they're probably not interested in async functions.

How this affects the internal code

  • The scheduling loop is now a co-routine, which is much easier to follow than the earlier timer-approach.
  • Same for the loop-task that checks for canvases getting closed.
  • The states (off, ready, active, running) that a loop goes trough is much better defined.
  • Clear cleanup up of remaining tasks when the loop ends.
  • We've more or less adopted trio's elegant vision to async.
  • We now have a small little tiny micro async framework so we can run coroutines on loops like qt.

Changes

  • Add dependency on sniffio (a tiny pure-python lib).
  • Add BaseRenderCanvas.select_loop() class method.
  • Can add "$loop" to the title to show the used loop.
  • Add suport for trio.
  • Remove BaseTimer and backend timer classes.
  • Added BaseCanvasGroup and subclasses for each backend.
  • The EventEmitter is closed when done, clearing the handlers, for improved gc.
  • The _loop.py is completely refactored, scheduler moved out into _scheduler.py
  • Added a lightweight async runner that allows running asynchronous code on any event-loop that has a call_later mechanic.

Considered alternative approaches

A bit detailed, mostly for the record, you can probably skip this part.

Only process events asynchronously

I started with the idea, that when the scheduler process events, it will will do so in a co-routine that is then scheduled to run in an event-loop. At the end of the co-routine it would continue the scheduling-loop using a callback.

  • When in asyncio, we could do asyncio.get_current_loop().create_task(..).
  • When in qt/wx, we could do asyncio.new_event_loop().run_until_complete(..).
  • When in trio ... this cannot work in trio, because you cannot simply spawn a new task.

If we decide to forget about trio, this can be made to work. But the code becomes an odd mix of async code and callbacks, which does not feel very elegant. Plus the fact that this cannot work with trio felt to me like a sign that this approach may be flawed.

Allow some sort of async initialization step

If we provide an initialization event that supports async callbacks, these can be used to get the wgpu device object asynchronously. For the rest of the lifetime, one should simply not using any async wgpu methods. This approach suffers from many of the same problems as the above. And if we have one event that supports async callbacks, why not all?

An alternative would be to use timets/ callbacks to wait for the future/promise of request_adapter() to resolve. Yuk.

A global loop proxy

To be able to use the GlfwRenderCanvas with both the asyncio and the trio loop, the loop needed to be less tight to the canvas class. One approach I tried is to have a global object. All canvases "use" that proxy, and the proxy defers to whatever is the current loop.

This was problematic for a few reasons. For one, the global object made testing a bit harder. It also raised questions like whether the active loop can change while there were canvases running. The biggest problem is related to interactive settings. Imagine being in Jupyter, or in an IDE that has asyncio running. I don't want users to need to call loop.run() there. But then we cannot know (for certain) what loop the user wants to use.

The CanvasGroup that I eventually implemented solves the problem, by setting a default canvas (for each canvas backend), and allowing the user to select a different loop, but only when there are no live canvases in the group.

@almarklein almarklein marked this pull request as ready for review December 6, 2024 13:42
@Korijn
Copy link
Contributor

Korijn commented Dec 7, 2024

I have to say you did something really special here!

@almarklein almarklein merged commit 16e5079 into main Dec 9, 2024
13 checks passed
@almarklein almarklein deleted the async branch December 9, 2024 12:08
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.

2 participants