Async And Callbacks¶
Info
FastAPI has a brief tutorial about async
that you might find interesting: https://fastapi.tiangolo.com/async/
PyTauri/Tauri runtime model:
- The main thread is used for Tauri App's window/webview event loop
- Tauri (Rust part) uses tokio multi-threaded async runtime to execute asynchronous code like IPC commands
- PyTauri uses anyio.from_thread.BlockingPortal async runtime in a separate thread to execute asynchronous code like IPC commands
You might notice that some Tauri/PyTauri APIs use synchronous callbacks, such as:
These synchronous callbacks execute either in the main thread or in Tauri's tokio multi-threaded async runtime. However, one thing is certain: they will never execute in PyTauri's anyio async runtime.
Unlike the tokio runtime in Rust, Python's async runtimes (anyio
/asyncio
/trio
) are not thread-safe.
Thread safety references
This means you cannot directly use Python's async APIs (like anyio.create_task_group) and async synchronization primitives (like anyio.Event) in these synchronous callbacks (i.e., in another thread): pytauri/pytauri#132.
Fortunately, anyio provides some methods to achieve this, such as BlockingPortal.call, etc: https://anyio.readthedocs.io/en/stable/threads.html#calling-asynchronous-code-from-an-external-thread.
However, using this approach directly is not very ergonomic. Therefore, pytauri
provides some utility tools to simplify this process since v0.7
.
Async Tools¶
The pytauri_utils.async_tools module provides some tools to simplify the process of using async APIs in synchronous callbacks/contexts.
Tip
pytauri_utils
is distributed as part of the pytauri
package on PyPI.
Therefore, running pip install pytauri
will also install it.
First, let's add AsyncTools to the App State:
from typing import Annotated
from anyio.from_thread import start_blocking_portal
from pytauri import (
Commands,
ImplManager,
Manager,
State,
builder_factory,
context_factory,
)
from pytauri_utils.async_tools import AsyncTools
commands = Commands()
def main() -> int:
with (
start_blocking_portal("asyncio") as portal, # or `trio`
AsyncTools(portal) as async_tools, # ⭐ initialize AsyncTools
):
app = builder_factory().build(
context=context_factory(),
invoke_handler=commands.generate_handler(portal),
)
# ⭐ Add `AsyncTools` to app state
Manager.manage(app, async_tools)
exit_code = app.run_return()
return exit_code
# ⭐ Access `AsyncTools` from app state
def access_async_tools_via_api(manager: ImplManager) -> AsyncTools:
async_tools = Manager.state(manager, AsyncTools)
return async_tools
# ⭐ Access `AsyncTools` from app state in a command
@commands.command()
async def access_async_via_command_injection(
async_tools: Annotated[AsyncTools, State()],
) -> None:
print(async_tools)
Then we can access it through Manager.state API or command state injection
.
Run Async Code in Sync Callbacks¶
The AsyncTools.to_sync method allows you to convert an async function to a sync function, so you can use it in/as synchronous callbacks.
from typing import Annotated
from anyio import create_memory_object_stream
from pytauri import AppHandle, Commands, Event, Listener, State
from pytauri_utils.async_tools import AsyncTools
commands = Commands()
@commands.command()
async def command_handler(
app_handle: AppHandle, async_tools: Annotated[AsyncTools, State()]
) -> None:
send_stream, receive_stream = create_memory_object_stream[str]()
# ⭐ Convert an asynchronous function to a synchronous one
@async_tools.to_sync
async def listener(event: Event) -> None:
async with send_stream:
await send_stream.send(event.payload)
# ⭐ Then we can use it as synchronous callback
Listener.once(app_handle, "foo-event", listener)
async with receive_stream:
print("Received: ", await receive_stream.receive())
Tip
Alternatively, you can use BlockingPortal.call or BlockingPortal.start_task_soon directly, as it has better performance (no need to instantiate a new function).
See following AsyncTools.portal.
Run Blocking Code in Async Context¶
Ref: https://anyio.readthedocs.io/en/stable/threads.html#
Practical asynchronous applications occasionally need to run network, file or computationally expensive operations. Such operations would normally block the asynchronous event loop, leading to performance issues. The solution is to run such code in worker threads. Using worker threads lets the event loop continue running other tasks while the worker thread runs the blocking call.
The AsyncTools.to_async method allows you to convert a sync function to an async function, so you won't block the async event loop.
from time import sleep
from typing import Annotated
from pytauri import Commands, State
from pytauri_utils.async_tools import AsyncTools
commands = Commands()
@commands.command()
async def command_handler(async_tools: Annotated[AsyncTools, State()]) -> None:
# ⭐ Convert a synchronous blocking function to an asynchronous one
@async_tools.to_async
def some_blocking_task(secs: int) -> None:
print("Running a blocking task...")
sleep(secs)
# ⭐ Run blocking task in a separate worker thread
await some_blocking_task(1)
Tip
This method exists more as a symmetric counterpart to to_sync
.
In practical applications, you might prefer to use anyio.to_thread.run_sync, as it has better performance (no need to instantiate a new function).
Run Async Code in Background¶
You can use the TaskGroup.start_soon method to run an async function in the background.
from datetime import datetime
from typing import Annotated
from anyio import sleep
from pydantic import RootModel
from pytauri import AppHandle, Commands, Emitter, State
from pytauri_utils.async_tools import AsyncTools
commands = Commands()
DatetimeModel = RootModel[datetime]
async def task_in_background(app_handle: AppHandle) -> None:
while True:
Emitter.emit(app_handle, "foo-event", DatetimeModel(datetime.now()))
await sleep(1)
@commands.command()
async def command_handler(
app_handle: AppHandle, async_tools: Annotated[AsyncTools, State()]
) -> None:
# ⭐ Run a task in the background
async_tools.task_group.start_soon(task_in_background, app_handle)
Access Raw BlockingPortal
¶
You can access the BlockingPortal
you passed when instantiating AsyncTools
through AsyncTools.portal.