This guide covers running a long-lived asyncio event loop inside a Python context and driving it from Erlang: starting and stopping the loop, injecting coroutines into it, and serving TCP or UDP on sockets that Erlang owns. You need it when you want Python servers or background async work to run as supervised workers inside the VM, the way gunicorn runs worker processes, with Erlang as the arbiter.
An owngil py_context has its own interpreter, its own GIL, its own thread and
its own ErlangEventLoop. py_context:start_loop/1 runs that loop forever on
the context thread. From then on:
py_context:submit/4,5schedules a coroutine or a plain function on the loop and returns a task reference; the result arrives as{async_result, TaskRef, {ok, Value} | {error, Reason}}(usepy_event_loop:await/1,2orsubmit_await/4,5,6).- fds registered by the loop (servers, connections, channels) are served by
the loop thread; readiness comes through the context's own
py_event_workerprocess. py_context:stop_loop/1,2stops it from inside, or interrupts the thread if it does not exit within the grace period. The owner receives{py_loop_exit, Ctx, Result}when the loop ends, for any reason.
Worker contexts get the same API on the shared main interpreter loop, which
allows one running ErlangEventLoop per interpreter: use owngil (Python
3.14+) for several workers.
Bind once in Erlang, duplicate the listen fd for each worker with
py:dup_fd/1, and let each worker accept on its copy.
# myapp.py, importable by the workers
import asyncio
import erlang
class Echo(asyncio.Protocol):
def connection_made(self, transport):
self.transport = transport
def data_received(self, data):
self.transport.write(data)
async def serve(listen_fd):
server = await erlang.server.serve(listen_fd, Echo)
return 'serving'{ok, LSock} = gen_tcp:listen(8000, [binary, {reuseaddr, true}, {backlog, 1024}]),
{ok, LFd} = inet:getfd(LSock),
Workers = [begin
{ok, W} = py_context:new(#{mode => owngil, preload => <<"import myapp">>}),
ok = py_context:start_loop(W),
{ok, Dup} = py:dup_fd(LFd),
{ok, <<"serving">>} = py_context:submit_await(W, myapp, serve, [Dup]),
W
end || _ <- lists:seq(1, 4)].Every worker now accepts on the same socket; the kernel hands each connection to one of them. Erlang keeps the listen socket open across worker restarts, so replacing a worker never closes the port.
For UDP, open the socket with gen_udp, dup its fd the same way and call
erlang.server.serve(fd, MyDatagramProtocol, udp=True); on Linux use one
SO_REUSEPORT socket per worker instead of one shared fd, so the kernel
spreads flows.
When Erlang wants to decide per connection (routing, tenancy, or keeping some connections in Erlang), accept in Erlang and adopt the fd in the worker.
async def adopt(fd):
await erlang.server.adopt(fd, Echo)
return 'adopted'{ok, Conn} = gen_tcp:accept(LSock),
{ok, Fd} = inet:getfd(Conn),
{ok, Dup} = py:dup_fd(Fd),
{ok, <<"adopted">>} = py_context:submit_await(Worker, myapp, adopt, [Dup]),
gen_tcp:close(Conn). %% Python owns the dup, Erlang drops its copysubmit targets module level functions (Module:Func), imported in the
worker (put entry points in a module, or register one in sys.modules from
preload). Coroutine functions are awaited, plain functions are called.
{ok, Ref} = py_context:submit(W, myapp, refresh_cache, [Key]),
%% ... other work ...
{ok, Result} = py_event_loop:await(Ref, 5000).
%% or in one step
{ok, Result} = py_context:submit_await(W, myapp, refresh_cache, [Key], #{}, 5000).Task start failures come back as errors, not silence: {error, function_not_found} for a missing module or function, {error, args_conversion_failed}, or the Python exception if the call itself raised.
A py_channel awaited inside the loop delivers Erlang messages to the running
loop with no polling; use it as the control plane of a worker (adopt this fd,
drain, report stats):
async def control(channel_ref):
ch = erlang.Channel(channel_ref)
async for msg in ch:
match msg:
case ('adopt', fd):
await erlang.server.adopt(fd, Echo)
case ('stop',):
return 'stopped'{ok, Ch} = py_channel:new(),
{ok, _} = py_context:submit(W, myapp, control, [Ch]),
ok = py_channel:send(Ch, {adopt, Dup}).ok = py_context:stop_loop(W, 5000), %% cooperative, interrupt after 5 s
receive {py_loop_exit, W, Result} -> Result end,
ok = py_context:start_loop(W). %% same context, fresh loopstop_loopreturnsokonce the loop has exited,{error, no_loop}when none runs,{error, timeout}if it survived the interrupt.py_context:interrupt/1ends the loop at once with{py_loop_exit, W, {error, interrupted}}; a loop blocked in a C call (time.sleep, a numpy kernel) exits when that call returns.py_context:stop/1on a looping context interrupts the loop first, then destroys the context.- If the owner process (the caller of
start_loop, or#{owner => Pid}) dies, the loop is stopped. - Put workers under your own supervisor.
py_contextprocesses aretemporaryunderpy_context_sup; a restart ispy_context:new/1again,start_loop, and a new dup of the listen fd. Usememory_limitinpy_context:new/1to cap a worker, andpy_nif:context_memory_usage/1to watch it.
- While a loop runs,
py_context:call/eval/exec/call_methodon that context return{error, loop_running}. The thread is busy in the loop, and a call that timed out would interrupt it. Usesubmit. - Pass fds you may close:
py:dup_fd/1copies,serveandadoptwrap the fd in a socket that owns it and close it when done. Never hand the original fd of a livegen_tcpsocket. - One running
ErlangEventLoopper interpreter: worker mode supports one worker loop, owngil one per context. - Modules used by
submitmust be importable in the worker; theexecnamespace of the context is not searched.
examples/bench_worker_loop.erl (Apple M4 Pro, OTP 29, Python 3.14, gen_tcp
clients in the same VM):
| Measure | Result |
|---|---|
| connect + echo + close, one worker | about 15 000 conn/s |
| keep-alive echo, one worker, 50 connections | about 70 000 req/s |
| connect + echo + close, 2 to 8 workers on one listen fd | about 23 000 conn/s (client bound) |
submit_await coroutine, one caller |
about 25 us round trip |
submit_await coroutine, 100 callers |
about 4 us per op, 250 000 ops/s |
py_context:call on an idle owngil context |
about 12 us |
| Erlang accept + adopt vs Python accept | 13 500 vs 14 500 conn/s |
- Asyncio for the ErlangEventLoop itself
- Channels for the control plane
- Interrupts for what an interrupt does to a loop
- OWN_GIL Internals for the thread and interpreter model