Current section
Files
Jump to
Current section
Files
docs/architecture.md
# Architecture
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](code-map.md) lists every file; the [glossary](glossary.md)
defines the overloaded words (worker, context, pool).
## One picture
```
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.
## The three context modes
| | `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](context-affinity.md), [pools](pools.md) | [owngil_internals](owngil_internals.md) | [isolated](isolated.md) |
## Life of a call, per mode
### worker and owngil (embedded)
1. `py:call(M, F, A)` picks a context through `py_context_router` and sends
`{call, From, MRef, M, F, Args, Kwargs}` to the `py_context` process
(`src/py.erl`, `src/py_context.erl:call/6`). The caller waits in
`await_reply/3`; a timeout there calls `interrupt/1`.
2. The `py_context` receive loop takes it and calls `handle_call_with_suspension/5`,
which calls the `context_call_async` NIF
(`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.
3. The context's pthread (`ctx_thread_main_worker` or
`ctx_thread_main_owngil`, `c_src/py_nif.c`) dequeues the request and
runs it through `ctx_execute_request` (one function for both
modes), which calls into Python with the GIL held.
4. The thread converts the result (`py_to_term`) and sends
`{py_result, Ref, Result}` to the `py_context` process, which replies
`From ! {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.
### isolated
1. Same first step: the message reaches the `py_context` pid, which in this
mode runs `py_isolated` (a `gen_statem` entered with `enter_loop`).
2. In `idle` the request is encoded with `term_to_binary` into 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).
3. 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.
4. Back in `py_isolated`, the reply for the busy id moves the state to
`idle` and answers the caller. A crash of the child is seen as the port's
`exit_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`.
## Python calling Erlang (`erlang.call`)
`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):
1. **Suspension** (worker contexts). The Python call raises
`SuspensionRequired`; the context thread returns
`{suspended, CallbackId, State, {Name, Args}}` to `py_context`, which runs
the registered fun (`execute/2` in `py_callback`), possibly serving nested calls
meanwhile (`wait_for_callback/2`), and resumes with
the `resume_callback` NIF.
2. **Blocking callback pipe** (owngil contexts). The context thread writes
a request on a pipe and blocks; the `py_context` process has a dedicated
handler (`callback_handler_loop/1`) that runs the fun and writes the
response frame back with `context_write_callback_response`.
3. **Thread worker** (`c_src/py_thread_worker.c`): any Python thread that is
not a context thread (`threading.Thread`, executors) asks the
`py_thread_handler` coordinator 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](protocols.md); the states each process and thread moves through
are in [state machines](state-machines.md); the reasons behind the design
are in the [decision records](decisions/overview.md).
## asyncio
Three different machineries, on purpose:
- Embedded contexts share an `ErlangEventLoop` (`priv/_erlang_impl/_loop.py`)
whose `add_reader`/`add_writer` map onto `enif_select` and `call_later` onto
`erlang:send_after`; readiness is delivered to a `py_event_worker` process
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_pool` expose the main-interpreter loops for
`py:async_call/3` and 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](event_loop_architecture.md) for the embedded
loop and [asyncio](asyncio.md) for the API.
## Data paths that avoid copies
- `py_buffer` (native): a NIF resource Erlang writes into and Python reads
through the buffer protocol; embedded modes only.
- `py_shm` and `py_buffer:new(#{shared => true})`: a file mapped
`MAP_SHARED` by the VM (through iommap) and by any interpreter, embedded or
child, with flow control through callbacks (`_py_buffer_wait`,
`_py_buffer_consumed`). See [isolated](isolated.md#bulk-data-with-shared-memory).
- `py_channel`, `py_byte_channel`: message queues between Erlang and Python
coroutines, embedded modes only.
## Where state lives
| 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 |
## Invariants worth knowing before editing
- A context serves one request at a time. Embedded: the thread dequeues one
request; nested callbacks are served by the `py_context` process while the
thread waits. Isolated: enforced by the `{busy, Id}` state and `postpone`.
- Only the context's own thread touches its Python objects; NIF callers only
enqueue. `py_context_t` fields are documented in `c_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_term` on child data is not `safe`: the child can create atoms.
## What is live and what is not
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.