Async¶
pyoz.asyncFn turns a Zig function into a Python function that returns an
asyncio.Future. It is built on Zig 0.16's std.Io.
const std = @import("std");
const pyoz = @import("PyOZ");
fn fetch_sum(io: std.Io, delay_ms: i64, a: i64, b: i64) !i64 {
try io.sleep(.fromMilliseconds(delay_ms), .awake); // cancellation point
return a + b;
}
fn greet(arena: std.mem.Allocator, name: []const u8) ![]const u8 {
return std.fmt.allocPrint(arena, "hello, {s}!", .{name});
}
pub const Module = pyoz.module(.{
.name = "mymod",
.funcs = &.{
pyoz.func("fetch_sum", pyoz.asyncFn(fetch_sum), "Sleep, then add"),
pyoz.func("greet", pyoz.asyncFn(greet), "Build a greeting"),
},
});
import asyncio, mymod
async def main():
print(await mymod.fetch_sum(100, 2, 3)) # 5
print(await asyncio.gather(*(mymod.greet(n) for n in "abc")))
async with asyncio.timeout(0.05): # cancels the Zig task
await mymod.fetch_sum(10_000, 0, 0)
asyncio.run(main())
How it works¶
- Each call runs
fon its ownstd.Iotask, without the GIL and without an attached Python thread state, so it runs in parallel with Python code and with other tasks on every CPython build (regular, free-threaded, ABI3). - The returned future is hot: work starts immediately, as with
asyncio.create_task. Call async functions from inside a coroutine (a running event loop is required). - Cancellation propagates into Zig. Cancelling the Python task
(
task.cancel(),asyncio.timeout,wait_for, ...) cancels the Zig task: its nextIocall (sleep, file, network,io.checkCancel()) returnserror.Canceled. Long CPU loops should callio.checkCancel()periodically. - The event loop never blocks on Zig. Workers never touch Python while finishing: completed jobs are pushed onto a lock-free per-loop queue and resolved in batches on the loop thread.
Parameters and results¶
Optional leading parameters, in this order:
| Parameter | Provides |
|---|---|
std.Io |
The runtime's Io (also available anywhere as pyoz.io()) |
std.mem.Allocator |
A per-call arena, freed after the result is converted |
Python-visible arguments (up to 8) are copied when the function is called, so later changes to the Python objects do not affect the running task:
- integers, floats, bools, enums and optionals of those
[]const u8(duplicated into the per-call arena)- structs by value with no pointer fields, including PyOZ classes
(
fn f(p: Point)),pyoz.Complexand the datetime types
Types that could alias Python-owned memory (pointers, other slices, structs
containing them) are rejected at compile time. Argument conversion errors raise
TypeError immediately at the call site, not later from the future.
Results use the module's full conversion machinery, so async functions can
return PyOZ class instances (!Point), strings, lists, and so on. Errors map to
exceptions exactly like synchronous functions, including the module's
.error_mappings (pyoz.mapError / pyoz.mapErrorMsg).
Generated stubs annotate async functions as -> Awaitable[T]. Add
.withParams("name, ...") to the pyoz.func entry to give parameters real
names in stubs and help().
Tested with asyncio's default loop and with uvloop.
Async methods¶
pyoz.asyncMethod works like asyncFn for instance methods. The task runs on
another thread while Python can keep using the object, so the type of self
is the safety contract, enforced at compile time:
self parameter |
Meaning | Allowed when |
|---|---|---|
self: T |
Runs on a copy taken at call time; the task's mutations never reach the Python object | fields are pointer-free |
self: *const T |
Borrows the object; it is kept alive until the Zig task has been joined (also after cancellation) | class is __frozen__ and has no *T methods |
self: *T |
— | never (compile error) |
const Vec = struct {
pub const __frozen__ = true;
x: f64,
y: f64,
fn slowNormImpl(self: *const Vec, io: std.Io, ms: i64) !f64 {
try io.sleep(.fromMilliseconds(ms), .awake);
return @sqrt(self.x * self.x + self.y * self.y);
}
pub const slow_norm = pyoz.asyncMethod(slowNormImpl);
};
const Counter = struct {
value: i64,
pub fn inc(self: *Counter) void { self.value += 1; }
fn valueLaterImpl(self: Counter, io: std.Io, ms: i64) !i64 {
try io.sleep(.fromMilliseconds(ms), .awake);
return self.value; // the value at call time
}
pub const value_later = pyoz.asyncMethod(valueLaterImpl);
};
Keep the implementation function non-pub so it is not also exposed as a
regular method. On free-threaded builds the copy is taken inside the object's
critical section, so it is always a consistent snapshot.
Async protocols¶
Classes can implement Python's async dunders, so instances work with
async for, await obj and async with:
| Method | Python | Result becomes |
|---|---|---|
__aiter__(self: *T) *T |
async for x in obj |
the async iterator (usually self) |
__anext__(self: *T) ?V |
next item | awaitable of V; null ends the iteration (StopAsyncIteration) |
__await__(self: *const T) V |
await obj |
awaitable of V |
__aenter__(self: *T) *T |
async with obj as r |
awaitable of r (*T returning self gives back the same object) |
__aexit__(self: *T, exc_type: ?*PyObject, exc: ?*PyObject, tb: ?*PyObject) bool |
leaving the block | awaitable of the result; true suppresses the exception |
What PyOZ does with the return value decides how the awaitable behaves:
- A plain Zig value (
i64,[]const u8,*Treturningself,void, …) becomes a completed awaitable. It never suspends, so it needs no running event loop and works under asyncio, trio, anyio, or a barecoro.send(None). - A
pyoz.asyncFnresult is theasyncio.Futureitself, so the work runs on astd.Iotask. In__anext__, a task returningnullends the iteration.pyoz.Future(f)names the result type of callingpyoz.asyncFn(f). - A raw
*PyObjectis taken to already be an awaitable (for example a coroutine from calling a Pythonasync def) and is used as is. - Errors returned from the dunder raise immediately. Errors from a task
surface when it is awaited, as with
asyncFn.
__anext__ usually mutates the iterator (a cursor, a buffer), so it takes
self: *T and runs under the object's lock on free-threaded builds. Advance the
cursor there and hand the slow part to a task:
fn fetchPageImpl(io: std.Io, page: i64, pages: i64) !?i64 {
try io.sleep(.fromMilliseconds(10), .awake); // e.g. a network call
if (page >= pages) return null; // end of stream, found by the task
return page * 10;
}
const fetchPage = pyoz.asyncFn(fetchPageImpl);
const Pages = struct {
pages: i64,
_next: i64 = 0,
pub fn __aiter__(self: *Pages) *Pages {
return self;
}
pub fn __anext__(self: *Pages) !pyoz.Future(fetchPageImpl) {
defer self._next += 1;
return fetchPage(self._next, self.pages);
}
};
async for item in mymod.Pages(3):
print(item) # 0, 10, 20
await anext(mymod.Pages(0), "empty") # "empty"
If the end is known up front, declare !?pyoz.Future(f), return null without
starting a task, and otherwise return try fetchPage(...). An awaitable object can hand its whole __await__ to a
task with pyoz.asyncMethod:
const Delayed = struct {
value: i64,
ms: i64,
fn awaitImpl(self: Delayed, io: std.Io) !i64 {
try io.sleep(.fromMilliseconds(self.ms), .awake);
return self.value;
}
pub const __await__ = pyoz.asyncMethod(awaitImpl);
};
An async context manager with immediate results:
const Session = struct {
open: bool = false,
pub fn __aenter__(self: *Session) *Session {
self.open = true;
return self;
}
pub fn __aexit__(self: *Session, exc_type: ?*pyoz.PyObject, exc: ?*pyoz.PyObject, tb: ?*pyoz.PyObject) bool {
_ = .{ exc_type, exc, tb };
self.open = false;
return false; // don't suppress exceptions
}
};
Stubs declare async def __anext__(self) -> V, def __await__(self) ->
Generator[Any, Any, V], and async def __aenter__ / __aexit__, so type
checkers accept async for, await and async with on these classes. All of
this also works in ABI3 mode.
Concurrency limit¶
Under std.Io.Threaded each running job occupies one OS thread. At most 256
jobs run at once by default; further calls queue and start as others finish,
so any number of tasks can be awaited. Change it before the first async call:
Io backend¶
PyOZ uses std.Io.Threaded. Zig 0.16 also ships evented backends (io_uring on
Linux, GCD on macOS, kqueue on BSD), which would make large numbers of
concurrently sleeping tasks cheaper, but in 0.16.0 none of them compile when
used as an Io (and Windows has none). The backend is selected in one place, so
PyOZ can switch without API changes once they mature.
Performance¶
Measured against loop.run_in_executor with a ThreadPoolExecutor
(ReleaseFast, CPython 3.12):
pyoz.asyncFn |
run_in_executor |
|
|---|---|---|
Sequential await |
~41 µs | ~46–54 µs |
gather of 20,000 tasks |
~13 µs / task | ~37 µs / task |
Next Steps¶
- Free-Threading - Running without the GIL
- GIL Management - Releasing the GIL in synchronous code
- Errors - Error mappings, which also apply to async functions