Production async hosting

PyDotNet runs Python coroutines on one process-wide, long-lived asyncio.SelectorEventLoop. The loop starts with PyRuntime.Initialize and is drained and stopped by PyRuntime.Shutdown.

This applies to CallAsync, EvaluateAsync, CallAsyncEnumerable, and PyTaskGroup. Keeping one loop avoids per-call loop construction, permits Python coroutines to execute concurrently, and preserves loop-affine Python resources such as clients and connection pools between calls.

Backpressure

The host admits at most 256 .NET operations by default. Additional calls asynchronously wait for capacity without occupying a thread-pool thread. Configure the limit for the application's expected Python workload:

PyRuntime.Initialize(new PyRuntimeOptions
{
    MaximumConcurrentAsyncOperations = 64,
    AsyncShutdownTimeout = TimeSpan.FromSeconds(20),
});

The limit covers an operation until its Python coroutine completes. Use a lower value for memory-heavy workloads and a higher value for predominantly I/O-bound Python code.

Cancellation

Cancellation tokens are propagated to the future returned by asyncio.run_coroutine_threadsafe. Cancellation therefore reaches the Python task and runs its finally blocks and asynchronous context-manager cleanup before the .NET task completes with OperationCanceledException.

using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await pythonFunction.CallAsync<string>(arguments, timeout.Token);

A token cancelled while waiting for host capacity prevents the Python coroutine from starting. Cancelling async enumeration cancels the current __anext__ operation; early disposal also awaits aclose() on the host.

Shutdown

PyRuntime.Shutdown first stops admission and waits for admitted async operations to finish. It performs this drain before acquiring the shutdown GIL, allowing Python tasks to continue making progress. The event loop is then stopped and closed before Python object references are swept.

Applications should stop accepting requests and cancel application-level tokens before calling Shutdown when they require a bounded shutdown deadline. PyDotNet deliberately drains already-admitted operations until AsyncShutdownTimeout; it then cancels remaining Python futures and waits for their cancellation cleanup.

In an application with a Microsoft.Extensions.Hosting host, services.AddPyDotNet() runs this drain from an IHostedService, so shutdown ordering belongs to the host and there is no Shutdown call to place on every exit path. The host's own shutdown timeout applies on top of AsyncShutdownTimeout, and the shorter of the two wins — see Hosting and dependency injection.

Metrics

The PyDotNet meter publishes pydotnet.async.active, pydotnet.async.waiting, and pydotnet.async.cancellations for host capacity and cancellation monitoring.

Compatibility

The public async API signatures are unchanged. Code using CallAsync, EvaluateAsync, async generators, or PyTaskGroup automatically uses the persistent host.

Async APIs require ReleaseGilAfterInit = true, which is the default. Retaining the initialization thread's GIL is intended only for specialized synchronous embedding.