This page is the map of erlang_python. It
says which processes and threads exist, how a call travels from py:call/3
to Python and back in each context mode, how Python calls Erlang, and which
code paths are live. Read it before opening c_src/ or src/py_context.erl.
The code map lists every file; the glossary
defines the overloaded words (worker, context, pool).
Erlang VM child OS process
+--------------------------------------------------+ (isolated mode only)
| erlang_python_sup |
| py_callback registry of Erlang funs Python |
| may call |
| py_shm shared memory regions |
| py_thread_handler spawns a handler process |
| per Python thread that calls |
| Erlang |
| py_logger / py_tracer Python logging, tracing |
| py_context_sup ---- py_context (one per |
| context; mode worker | owngil | isolated) |
| py_context_init starts the default pool |
| py_event_worker_sup / _registry loop drivers |
| py_event_loop, py_event_loop_pool main- |
| interpreter asyncio loops |
+--------------------------------------------------+
| NIF calls (dirty schedulers) | Unix socket, ETF frames
v v
+------------------------+ +-------------------------+
| libpython in the VM | | python3 py_isolated_ |
| main interpreter | | child.py |
| worker contexts: one | | reader thread + main |
| pthread each, shared | | thread, own asyncio |
| GIL | | loop |
| owngil contexts: one | +-------------------------+
| pthread + own sub- |
| interpreter each |
+------------------------+
A context is the unit of work: an Erlang process (py_context) that owns
one Python execution environment and serves calls in order. Pools
(py_context_router) route py:call/3 to a context by scheduler affinity.
worker |
owngil |
isolated |
|
|---|---|---|---|
| Python runs in | the VM, main interpreter | the VM, a sub-interpreter with its own GIL | a child process |
| Thread | one pthread per context (ctx_thread_main_worker) |
one pthread per context (ctx_thread_main_owngil) |
the child's main thread |
| Erlang process loop | the receive loop in py_context |
the receive loop in py_context |
py_isolated (gen_statem) |
| Transport | NIF request queue on py_context_t |
same | Unix socket, frames of the callback pipe format |
| Python -> Erlang | suspension protocol | blocking callback pipe | socket frames |
| Interrupt | PyThreadState_SetAsyncExc, next bytecode |
same | signal in the child, SIGKILL backstop |
| Guide | context-affinity, pools | owngil_internals | isolated |
py:call(M, F, A)picks a context throughpy_context_routerand sends{call, From, MRef, M, F, Args, Kwargs}to thepy_contextprocess (src/py.erl,src/py_context.erl:call/6). The caller waits inawait_reply/3; a timeout there callsinterrupt/1.- The
py_contextreceive loop takes it and callshandle_call_with_suspension/5, which calls thecontext_call_asyncNIF (c_src/py_nif.c,nif_context_call_async). The NIF converts the arguments (term_to_py,c_src/py_convert.c) into a request, enqueues it on the context's queue (ctx_queue_enqueue) and returns{enqueued, Ref}at once. The Erlang process is now free to serve callbacks. - The context's pthread (
ctx_thread_main_workerorctx_thread_main_owngil,c_src/py_nif.c) dequeues the request and runs it throughctx_execute_request(one function for both modes), which calls into Python with the GIL held. - The thread converts the result (
py_to_term) and sends{py_result, Ref, Result}to thepy_contextprocess, which repliesFrom ! {MRef, Result}.
nif_context_call and friends also have a blocking variant used as a
fallback when a context has no thread ({error, async_requires_worker_thread}),
which never happens for contexts created today.
- Same first step: the message reaches the
py_contextpid, which in this mode runspy_isolated(agen_statementered withenter_loop). - In
idlethe request is encoded withterm_to_binaryinto a frame<<Id:64/native, Len:32/native, Status:8, ETF/binary>>and written to the Unix socket; the state becomes{busy, Id}and other callers' requests are postponed (served in order when the child is free). - In the child, the reader thread parses frames and queues the request;
the main thread runs it (
_erlang_impl/_isolated.py,Runtime._dispatch) and writes the reply frame. - Back in
py_isolated, the reply for the busy id moves the state toidleand answers the caller. A crash of the child is seen as the port'sexit_status; the state goes through{restarting, Reason}and a new child is started within the restart budget.
Protocol details: the module header of src/py_isolated.erl and the
docstring of priv/_erlang_impl/_isolated.py.
erlang_call_impl in c_src/py_callback.c chooses one of these paths, in
this order (the comment above it is the authoritative version):
- Suspension (worker contexts). The Python call raises
SuspensionRequired; the context thread returns{suspended, CallbackId, State, {Name, Args}}topy_context, which runs the registered fun (execute/2inpy_callback), possibly serving nested calls meanwhile (wait_for_callback/2), and resumes with theresume_callbackNIF. - Blocking callback pipe (owngil contexts). The context thread writes
a request on a pipe and blocks; the
py_contextprocess has a dedicated handler (callback_handler_loop/1) that runs the fun and writes the response frame back withcontext_write_callback_response. - Thread worker (
c_src/py_thread_worker.c): any Python thread that is not a context thread (threading.Thread, executors) asks thepy_thread_handlercoordinator for a handler process and talks to it over a pipe. There is also an async variant (erlang.async_call) using a per-interpreter async pipe.
In isolated mode there is one path: a status-3 frame on the socket, answered
by a process the py_isolated state machine spawns; nested calls into the
same context are dispatched immediately because they come from that process.
Every message and frame, with the rules for changing them, is in protocols; the states each process and thread moves through are in state machines; the reasons behind the design are in the decision records.
Three different machineries, on purpose:
- Embedded contexts share an
ErlangEventLoop(priv/_erlang_impl/_loop.py) whoseadd_reader/add_writermap ontoenif_selectandcall_laterontoerlang:send_after; readiness is delivered to apy_event_workerprocess per loop (c_src/py_event_loop.c,src/py_event_worker.erl). Worker loops (py_context:start_loop/1) run such a loop on the context thread. py_event_loop/py_event_loop_poolexpose the main-interpreter loops forpy:async_call/3and friends.- An isolated child runs a plain asyncio loop; the only integration is delivering results over the socket. Nothing of the reactor is ported.
See event_loop_architecture for the embedded loop and asyncio for the API.
py_buffer(native): a NIF resource Erlang writes into and Python reads through the buffer protocol; embedded modes only.py_shmandpy_buffer:new(#{shared => true}): a file mappedMAP_SHAREDby the VM (through iommap) and by any interpreter, embedded or child, with flow control through callbacks (_py_buffer_wait,_py_buffer_consumed). See isolated.py_channel,py_byte_channel: message queues between Erlang and Python coroutines, embedded modes only.
| State | Owner | Notes |
|---|---|---|
| Registered callbacks | py_callback ETS py_callbacks |
also mirrored in a C name registry so erlang.<name> resolves; py_state exposes its store the same way (erlang.state_get) |
| Import and path registry | py_import ETS |
applied to every new interpreter and to isolated children at start |
| Preload code | py_preload persistent_term |
run once per interpreter |
| Context pid -> NIF ref | py_context ETS py_context_refs |
lets interrupt/1 reach a context blocked in a NIF; isolated contexts store the atom isolated |
| Per-Erlang-process Python env | py process dictionary + NIF env resource |
py:call(Ctx, ...); not applicable to isolated contexts |
| Shared regions | py_shm ETS py_shm_regions |
closed on owner death |
| Python-side state | the interpreter | lost when an isolated child restarts |
- A context serves one request at a time. Embedded: the thread dequeues one
request; nested callbacks are served by the
py_contextprocess while the thread waits. Isolated: enforced by the{busy, Id}state andpostpone. - Only the context's own thread touches its Python objects; NIF callers only
enqueue.
py_context_tfields are documented inc_src/py_nif.h. - Interrupts target the request executing now. Isolated mode also cancels a queued request by id; embedded modes cannot.
- Everything that crosses the socket is a term; NIF resources (channels,
native buffers, object references) do not cross, and the API says so with
{error, not_supported_in_isolated}. binary_to_termon child data is notsafe: the child can create atoms.
Every code path in src/ and c_src/ is on the path of a context created
today, with one exception: the test-only fd/TCP/UDP NIFs in
c_src/py_event_loop.c ("Test Helper Functions"), which the suites use.
The legacy worker API, its executor thread, the deprecated async worker
NIFs and the unused worker pool were removed in 5.0.0.