%% Copyright 2026 Benoit Chesneau %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. %%% @doc Event Loop Worker Pool with Process Affinity. %%% %%% This module provides a pool of event loops for parallel Python coroutine %%% execution, inspired by libuv's "one loop per thread" model. Each loop has %%% its own worker and maintains its own event ordering. %%% %%% Process Affinity: All tasks from the same Erlang process are routed to %%% the same event loop (via PID hash). This guarantees that timers and %%% related async operations from a single process execute in order. %%% %%% @private -module(py_event_loop_pool). -behaviour(gen_server). %% Pool management -export([ start_link/0, start_link/1, stop/0, get_loop/0, get_stats/0 ]). %% Distributed task API (pool-aware) -export([ create_task/3, create_task/4, run/3, run/4, spawn_task/3, spawn_task/4, await/1, await/2, %% Per-process namespace API exec/1, exec/2, eval/1, eval/2, get_all_loops/0, get_all_sessions/0, is_owngil_enabled/0 ]). %% Legacy API -export([run_async/1]). %% gen_server callbacks -export([ init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2 ]). -record(state, { num_loops :: non_neg_integer(), supported :: boolean(), owngil_enabled :: boolean(), sessions :: ets:tid() | undefined }). %% OWN_GIL session record - maps {PID, LoopIdx} to worker/handle -record(owngil_session, { key :: {pid(), pos_integer()}, %% {CallerPid, LoopIndex} worker_id :: non_neg_integer(), %% Worker thread index handle_id :: non_neg_integer(), %% Namespace handle ID monitor_ref :: reference() %% Process monitor for cleanup }). %% Persistent term keys for O(1) access -define(PT_LOOPS, {?MODULE, loops}). -define(PT_NUM_LOOPS, {?MODULE, num_loops}). -define(PT_OWNGIL_ENABLED, {?MODULE, owngil_enabled}). -define(PT_SESSIONS, {?MODULE, sessions}). %%% ============================================================================ %%% API %%% ============================================================================ -spec start_link() -> {ok, pid()} | {error, term()}. start_link() -> PoolSize = application:get_env(erlang_python, event_loop_pool_size, erlang:system_info(schedulers)), start_link(PoolSize). -spec start_link(pos_integer()) -> {ok, pid()} | {error, term()}. start_link(NumLoops) -> gen_server:start_link({local, ?MODULE}, ?MODULE, [NumLoops], []). -spec stop() -> ok. stop() -> gen_server:stop(?MODULE). %% @doc Get an event loop reference for the calling process. %% Always returns the same loop for the same PID (process affinity). -spec get_loop() -> {ok, reference()} | {error, not_available}. get_loop() -> case pool_size() of 0 -> {error, not_available}; N -> %% Hash PID to get consistent loop assignment Hash = erlang:phash2(self()), Idx = (Hash rem N) + 1, {LoopRef, _WorkerPid} = get_loop_by_index(Idx), {ok, LoopRef} end. %% @doc Get pool statistics. -spec get_stats() -> map(). get_stats() -> gen_server:call(?MODULE, get_stats). %%% ============================================================================ %%% Distributed Task API (Pool-aware) %%% ============================================================================ %% @doc Submit an async task and return a reference to await the result. %% Tasks from the same process always go to the same loop (ordered execution). %% %% Example: %% Ref = py_event_loop_pool:create_task(my_module, my_async_func, [arg1]), %% {ok, Result} = py_event_loop_pool:await(Ref) -spec create_task(Module :: atom() | binary(), Func :: atom() | binary(), Args :: list()) -> reference(). create_task(Module, Func, Args) -> create_task(Module, Func, Args, #{}). -spec create_task(Module :: atom() | binary(), Func :: atom() | binary(), Args :: list(), Kwargs :: map()) -> reference(). create_task(Module, Func, Args, _Kwargs) -> %% Check if OWN_GIL mode is enabled case is_owngil_enabled() of true -> %% OWN_GIL path: route to worker via session case ensure_session() of {ok, {WorkerId, HandleId, _LoopIdx}} -> Ref = make_ref(), Caller = self(), ModuleBin = py_util:to_binary(Module), FuncBin = py_util:to_binary(Func), ok = py_nif:owngil_submit_task(WorkerId, HandleId, Caller, Ref, ModuleBin, FuncBin, Args), Ref; {error, _Reason} -> %% Fall back to regular path create_task_regular(Module, Func, Args) end; false -> create_task_regular(Module, Func, Args) end. %% @private Regular (non-OWN_GIL) task creation -spec create_task_regular(Module :: atom() | binary(), Func :: atom() | binary(), Args :: list()) -> reference(). create_task_regular(Module, Func, Args) -> case get_loop() of {ok, LoopRef} -> create_task_on_loop(LoopRef, Module, Func, Args, #{}); {error, not_available} -> %% Fallback to default event loop py_event_loop:create_task(Module, Func, Args, #{}) end. %% @doc Submit a task to a specific loop. -spec create_task_on_loop(LoopRef :: reference(), Module :: atom() | binary(), Func :: atom() | binary(), Args :: list(), Kwargs :: map()) -> reference(). create_task_on_loop(LoopRef, Module, Func, Args, Kwargs) -> Ref = make_ref(), Caller = self(), ModuleBin = py_util:to_binary(Module), FuncBin = py_util:to_binary(Func), ok = case py_event_loop:get_process_env() of undefined -> py_nif:submit_task(LoopRef, Caller, Ref, ModuleBin, FuncBin, Args, Kwargs); EnvRef -> py_nif:submit_task_with_env(LoopRef, Caller, Ref, ModuleBin, FuncBin, Args, Kwargs, EnvRef) end, Ref. %% @doc Blocking run of an async Python function. -spec run(Module :: atom() | binary(), Func :: atom() | binary(), Args :: list()) -> {ok, term()} | {error, term()}. run(Module, Func, Args) -> run(Module, Func, Args, #{}). -spec run(Module :: atom() | binary(), Func :: atom() | binary(), Args :: list(), Opts :: map()) -> {ok, term()} | {error, term()}. run(Module, Func, Args, Opts) -> Timeout = maps:get(timeout, Opts, 5000), Kwargs = maps:get(kwargs, Opts, #{}), Ref = create_task(Module, Func, Args, Kwargs), await(Ref, Timeout). %% @doc Fire-and-forget task execution. -spec spawn_task(Module :: atom() | binary(), Func :: atom() | binary(), Args :: list()) -> ok. spawn_task(Module, Func, Args) -> spawn_task(Module, Func, Args, #{}). -spec spawn_task(Module :: atom() | binary(), Func :: atom() | binary(), Args :: list(), Kwargs :: map()) -> ok. spawn_task(Module, Func, Args, _Kwargs) -> %% Check if OWN_GIL mode is enabled case is_owngil_enabled() of true -> %% OWN_GIL path: route to worker via session case ensure_session() of {ok, {WorkerId, HandleId, _LoopIdx}} -> Ref = make_ref(), %% Spawn a receiver that discards the result Receiver = erlang:spawn(fun() -> receive {async_result, _, _} -> ok after 30000 -> ok end end), ModuleBin = py_util:to_binary(Module), FuncBin = py_util:to_binary(Func), ok = py_nif:owngil_submit_task(WorkerId, HandleId, Receiver, Ref, ModuleBin, FuncBin, Args), ok; {error, _Reason} -> %% Fall back to regular path spawn_task_regular(Module, Func, Args) end; false -> spawn_task_regular(Module, Func, Args) end. %% @private Regular (non-OWN_GIL) spawn_task -spec spawn_task_regular(Module :: atom() | binary(), Func :: atom() | binary(), Args :: list()) -> ok. spawn_task_regular(Module, Func, Args) -> case get_loop() of {ok, LoopRef} -> Ref = make_ref(), CallerEnv = py_event_loop:get_process_env(), Receiver = erlang:spawn(fun() -> receive {async_result, _, _} -> ok after 30000 -> ok end end), ModuleBin = py_util:to_binary(Module), FuncBin = py_util:to_binary(Func), ok = case CallerEnv of undefined -> py_nif:submit_task(LoopRef, Receiver, Ref, ModuleBin, FuncBin, Args, #{}); EnvRef -> py_nif:submit_task_with_env(LoopRef, Receiver, Ref, ModuleBin, FuncBin, Args, #{}, EnvRef) end, ok; {error, not_available} -> py_event_loop:spawn_task(Module, Func, Args, #{}) end. %% @doc Wait for an async task result. -spec await(Ref :: reference()) -> {ok, term()} | {error, term()}. await(Ref) -> await(Ref, 5000). -spec await(Ref :: reference(), Timeout :: non_neg_integer() | infinity) -> {ok, term()} | {error, term()}. await(Ref, Timeout) -> receive {async_result, Ref, Result} -> Result after Timeout -> {error, timeout} end. %%% ============================================================================ %%% Per-Process Namespace API %%% ============================================================================ %% @doc Execute Python code in the calling process's event loop namespace. %% %% Each Erlang process gets an isolated Python namespace for its assigned %% event loop. Functions defined via exec/1 can be called via create_task/3 %% with the `__main__' module. %% %% Example: %%
%% ok = py_event_loop_pool:exec(<<"
%% async def my_async_func(x):
%% return x * 2
%% ">>),
%% Ref = py_event_loop_pool:create_task('__main__', my_async_func, [21]),
%% {ok, 42} = py_event_loop_pool:await(Ref)
%%
-spec exec(Code :: binary() | iolist()) -> ok | {error, term()}.
exec(Code) ->
case get_loop() of
{ok, LoopRef} ->
exec(LoopRef, Code);
{error, not_available} ->
%% Fallback to default event loop
py_event_loop:exec(Code)
end.
-spec exec(LoopRef :: reference(), Code :: binary() | iolist()) -> ok | {error, term()}.
exec(LoopRef, Code) ->
py_nif:event_loop_exec(LoopRef, Code).
%% @doc Evaluate a Python expression in the calling process's namespace.
%%
%% Returns the result of evaluating the expression.
%%
%% Example:
%%
%% ok = py_event_loop_pool:exec(<<"x = 42">>),
%% {ok, 42} = py_event_loop_pool:eval(<<"x">>),
%% {ok, 84} = py_event_loop_pool:eval(<<"x * 2">>)
%%
-spec eval(Expr :: binary() | iolist()) -> {ok, term()} | {error, term()}.
eval(Expr) ->
case get_loop() of
{ok, LoopRef} ->
eval(LoopRef, Expr);
{error, not_available} ->
%% Fallback to default event loop
py_event_loop:eval(Expr)
end.
-spec eval(LoopRef :: reference(), Expr :: binary() | iolist()) -> {ok, term()} | {error, term()}.
eval(LoopRef, Expr) ->
py_nif:event_loop_eval(LoopRef, Expr).
%%% ============================================================================
%%% Pool Utilities
%%% ============================================================================
%% @doc Get all event loop references in the pool.
%%
%% Returns a list of {LoopRef, WorkerPid} tuples for all loops in the pool.
-spec get_all_loops() -> {ok, [{reference(), pid()}]} | {error, not_available}.
get_all_loops() ->
case pool_size() of
0 -> {error, not_available};
N ->
Loops = persistent_term:get(?PT_LOOPS),
{ok, [element(Idx, Loops) || Idx <- lists:seq(1, N)]}
end.
%% @doc Get all OWN_GIL sessions in the pool.
%%
%% Returns a list of {WorkerId, HandleId} tuples for all active sessions.
%% Used by py_import to apply imports/paths to running OWN_GIL sessions.
-spec get_all_sessions() -> [{non_neg_integer(), non_neg_integer()}].
get_all_sessions() ->
case get_sessions_table() of
undefined -> [];
Tid ->
ets:foldl(
fun(#owngil_session{worker_id = W, handle_id = H}, Acc) ->
[{W, H} | Acc]
end,
[],
Tid
)
end.
%%% ============================================================================
%%% Legacy API
%%% ============================================================================
%% @doc Submit an async request (legacy API for backward compatibility).
-spec run_async(map()) -> ok | {error, term()}.
run_async(Request) ->
case get_loop() of
{ok, LoopRef} ->
py_event_loop:run_async(LoopRef, Request);
{error, not_available} ->
{error, event_loop_not_available}
end.
%%% ============================================================================
%%% gen_server callbacks
%%% ============================================================================
init([NumLoops]) ->
process_flag(trap_exit, true),
%% Check if OWN_GIL mode is enabled and supported (Python 3.14+)
OwnGilConfigured = application:get_env(erlang_python, event_loop_pool_owngil, false),
OwnGilEnabled = OwnGilConfigured andalso py_nif:owngil_supported(),
%% Initialize OWN_GIL infrastructure if enabled
{Sessions, OwnGilReady} = case OwnGilEnabled of
true ->
%% Start subinterpreter thread pool
case py_nif:subinterp_thread_pool_start(NumLoops) of
ok ->
%% Create sessions ETS table
Tid = ets:new(?MODULE, [
set, public,
{keypos, #owngil_session.key},
{read_concurrency, true}
]),
persistent_term:put(?PT_SESSIONS, Tid),
{Tid, true};
{error, _} ->
error_logger:warning_msg("py_event_loop_pool: OWN_GIL pool failed to start~n"),
{undefined, false}
end;
false ->
{undefined, false}
end,
persistent_term:put(?PT_OWNGIL_ENABLED, OwnGilReady),
case create_loops(NumLoops, []) of
{ok, LoopList} ->
Loops = list_to_tuple(LoopList),
persistent_term:put(?PT_LOOPS, Loops),
persistent_term:put(?PT_NUM_LOOPS, NumLoops),
{ok, #state{
num_loops = NumLoops,
supported = true,
owngil_enabled = OwnGilReady,
sessions = Sessions
}};
{error, Reason} ->
error_logger:warning_msg("py_event_loop_pool: failed to create loops: ~p~n", [Reason]),
persistent_term:put(?PT_LOOPS, {}),
persistent_term:put(?PT_NUM_LOOPS, 0),
{ok, #state{
num_loops = 0,
supported = false,
owngil_enabled = OwnGilReady,
sessions = Sessions
}}
end.
%% @private Create NumLoops independent event loops with workers
create_loops(0, Acc) ->
{ok, lists:reverse(Acc)};
create_loops(N, Acc) ->
case py_nif:event_loop_new() of
{ok, LoopRef} ->
WorkerId = iolist_to_binary([<<"pool_">>, integer_to_binary(N)]),
case py_event_worker:start_link(WorkerId, LoopRef) of
{ok, WorkerPid} ->
ok = py_nif:event_loop_set_worker(LoopRef, WorkerPid),
ok = py_nif:event_loop_set_id(LoopRef, WorkerId),
create_loops(N - 1, [{LoopRef, WorkerPid} | Acc]);
{error, Reason} ->
{error, {worker_start_failed, Reason}}
end;
{error, Reason} ->
{error, {loop_create_failed, Reason}}
end.
handle_call(get_stats, _From, State) ->
%% Build base stats
BaseStats = #{
num_loops => State#state.num_loops,
supported => State#state.supported,
owngil_enabled => State#state.owngil_enabled
},
%% Add OWN_GIL-specific stats if enabled
Stats = case State#state.owngil_enabled of
true ->
%% Get pool stats from NIF
PoolStats = try py_nif:subinterp_thread_pool_stats() catch _:_ -> #{} end,
%% Count active sessions
ActiveSessions = case State#state.sessions of
undefined -> 0;
Tid -> ets:info(Tid, size)
end,
maps:merge(BaseStats, #{
active_sessions => ActiveSessions,
pool_stats => PoolStats
});
false ->
BaseStats
end,
{reply, Stats, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info({'EXIT', _Pid, _Reason}, State) ->
{noreply, State#state{supported = false}};
handle_info({'DOWN', MonRef, process, Pid, _Reason}, State) ->
%% Process died - clean up any OWN_GIL sessions for this process
case State#state.sessions of
undefined -> ok;
Tid ->
%% Find and remove all sessions for this PID using ets:foldl
%% to avoid dialyzer issues with match specs and record types
ets:foldl(fun(#owngil_session{key = {SessionPid, _} = Key,
worker_id = WorkerId,
handle_id = HandleId,
monitor_ref = SessionMonRef}, Acc) ->
case SessionPid =:= Pid andalso SessionMonRef =:= MonRef of
true ->
%% Destroy session in worker
catch py_nif:owngil_destroy_session(WorkerId, HandleId),
%% Remove from ETS
ets:delete(Tid, Key);
false ->
ok
end,
Acc
end, ok, Tid)
end,
{noreply, State};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, State) ->
%% Clean up OWN_GIL sessions if enabled
case State#state.sessions of
undefined -> ok;
Tid ->
%% Destroy all sessions
ets:foldl(fun(#owngil_session{worker_id = WorkerId, handle_id = HandleId}, _) ->
catch py_nif:owngil_destroy_session(WorkerId, HandleId),
ok
end, ok, Tid),
catch ets:delete(Tid)
end,
%% Stop OWN_GIL thread pool if it was started
case State#state.owngil_enabled of
true -> catch py_nif:subinterp_thread_pool_stop();
false -> ok
end,
%% Clean up regular event loops
case persistent_term:get(?PT_LOOPS, {}) of
{} -> ok;
Loops ->
lists:foreach(fun({LoopRef, WorkerPid}) ->
catch py_event_worker:stop(WorkerPid),
catch py_nif:event_loop_destroy(LoopRef)
end, tuple_to_list(Loops))
end,
catch persistent_term:erase(?PT_LOOPS),
catch persistent_term:erase(?PT_NUM_LOOPS),
catch persistent_term:erase(?PT_OWNGIL_ENABLED),
catch persistent_term:erase(?PT_SESSIONS),
ok.
%%% ============================================================================
%%% Internal functions
%%% ============================================================================
%% @private Get the pool size
-spec pool_size() -> non_neg_integer().
pool_size() ->
persistent_term:get(?PT_NUM_LOOPS, 0).
%% @private Get a loop by 1-based index
-spec get_loop_by_index(pos_integer()) -> {reference(), pid()}.
get_loop_by_index(Idx) ->
Loops = persistent_term:get(?PT_LOOPS),
element(Idx, Loops).
%% @private Check if OWN_GIL mode is enabled
-spec is_owngil_enabled() -> boolean().
is_owngil_enabled() ->
persistent_term:get(?PT_OWNGIL_ENABLED, false).
%% @private Get the sessions ETS table
-spec get_sessions_table() -> ets:tid() | undefined.
get_sessions_table() ->
persistent_term:get(?PT_SESSIONS, undefined).
%% @private Get or create OWN_GIL session for calling process
%% Returns {ok, {WorkerId, HandleId, LoopIdx}} or {error, Reason}
-spec ensure_session() -> {ok, {non_neg_integer(), non_neg_integer(), pos_integer()}} | {error, term()}.
ensure_session() ->
Pid = self(),
N = pool_size(),
case N of
0 -> {error, not_available};
_ ->
%% Hash PID to get consistent loop assignment
Hash = erlang:phash2(Pid),
LoopIdx = (Hash rem N) + 1,
ensure_session(Pid, LoopIdx)
end.
%% @private Get or create session for a specific PID and loop index
-spec ensure_session(pid(), pos_integer()) -> {ok, {non_neg_integer(), non_neg_integer(), pos_integer()}} | {error, term()}.
ensure_session(Pid, LoopIdx) ->
case get_sessions_table() of
undefined ->
{error, owngil_not_enabled};
Tid ->
Key = {Pid, LoopIdx},
case ets:lookup(Tid, Key) of
[#owngil_session{worker_id = WorkerId, handle_id = HandleId}] ->
%% Session exists
{ok, {WorkerId, HandleId, LoopIdx}};
[] ->
%% Create new session
create_session(Tid, Pid, LoopIdx)
end
end.
%% @private Create a new OWN_GIL session
-spec create_session(ets:tid(), pid(), pos_integer()) -> {ok, {non_neg_integer(), non_neg_integer(), pos_integer()}} | {error, term()}.
create_session(Tid, Pid, LoopIdx) ->
%% Create session via NIF - assigns worker and creates namespace
case py_nif:owngil_create_session(LoopIdx - 1) of %% Convert to 0-based index
{ok, WorkerId, HandleId} ->
%% Monitor the process for cleanup on exit
MonRef = erlang:monitor(process, Pid),
Session = #owngil_session{
key = {Pid, LoopIdx},
worker_id = WorkerId,
handle_id = HandleId,
monitor_ref = MonRef
},
%% Use insert_new to handle race conditions
case ets:insert_new(Tid, Session) of
true ->
{ok, {WorkerId, HandleId, LoopIdx}};
false ->
%% Another process created the session first, destroy ours
erlang:demonitor(MonRef, [flush]),
catch py_nif:owngil_destroy_session(WorkerId, HandleId),
%% Retry lookup
case ets:lookup(Tid, {Pid, LoopIdx}) of
[#owngil_session{worker_id = W, handle_id = H}] ->
{ok, {W, H, LoopIdx}};
[] ->
{error, session_conflict}
end
end;
{error, Reason} ->
{error, Reason}
end.