gen_statem: Asynchronous Initialization

3 Jan 2024 14:11 erlang

When you use gen_statem:start/3,4 or gen_statem:start_link/3,4, the call blocks until the server process has finished running Module:init/1.

If Module:init/1 takes a long time to run, this can cause several problems:

  • If your process is being started from a supervisor, the supervisor will be unable to handle any messages until init/1 completes. This will result in calls such as supervisor:which_children/1 blocking, or delays in restarting other supervised processes.
  • If you want to start a number of processes, they’ll be initialised sequentially, rather than in parallel. This can result in slow startup for your application.

Here are a few ways to mitigate the problem. They’re not all appropriate for all situations.

enter_loop

Note: don’t actually do this; there are better solutions.

In a previous blog post, I showed how to solve this for gen_server by using gen_server:enter_loop/3,4,5. You can the same thing for gen_statem, as follows:

start_link(Host, Port) when is_list(Host), is_integer(Port) ->
    % Pass Args as a list containing a list, so that init/1 is called, for compatibility with the behaviour.
    proc_lib:start_link(?MODULE, init, [[Host, Port]]).

init([Host, Port]) ->
    % For example:
    Opts = [{active, true}, {mode, binary}],
    {ok, Sock} = gen_tcp:connect(Host, Port, Opts),

    % We've done the synchronous bit; call init_ack to unblock start_link.
    proc_lib:init_ack({ok, self()}),

    % Continue asynchronously.
    StateData = #state{
        % ...
    },

    gen_statem:enter_loop(?MODULE, [], connected, StateData).

As a guideline, you want to divide Module:init/1 into the following:

  1. A synchronous, blocking piece. Any failures here will cause start_link to fail. Use this for anything that would be better handled by simply failing to start. See “It’s About the Guarantees”, by Fred Hebert, for more on this.
  2. An asynchronous, non-blocking piece. At this point, you’re claiming that the server is in a known state and can handle requests. Any failures here should have their own retry logic or you should just die and have the supervisor deal with it. The strategy you choose comes down to what guarantees you’re claiming to offer.

Looping

If you want to implement a loop in a gen_statem, you’ll probably do this by returning something like {next_event, internal, loop} from the event handler. You can trigger this behaviour using gen_statem:enter_loop/5,6:

start_link(Host, Port) when is_list(Host), is_integer(Port) ->
    proc_lib:start_link(?MODULE, init, [[Host, Port]]).

init([Host, Port]) ->
    % ... sync init ...
    proc_lib:init_ack({ok, self()}),
    % ... async init ...
    gen_statem:enter_loop(?MODULE, [], connected, StateData, [{next_event, internal, loop}]).

handle_event(internal, loop, _, StateData) ->
    % ... something interesting ...
    {keep_state_and_data, [{next_event, internal, loop}]}.

state_enter

gen_statem can use state enter calls. You can use these as a way to defer initialization work, but with a major caveat.

start_link(Host, Port) when is_list(Host), is_integer(Port) ->
    gen_statem:start_link(?MODULE, [Host, Port], []).

callback_mode() -> [state_enter, handle_event_function].

init([Host, Port]) ->
    % ...
    {ok, initializing, StateData}.

handle_event(enter, _, initializing, StateData) ->
    % do asynchronous initialization here.
    {next_state, connected, StateData};
handle_event(enter, _, _, _) ->
    % all other state enter events.
    keep_state_and_data;
handle_event(EventType, EventContent, State, StateData) ->
    % ...

This works pretty well, but the major caveat is that you’re only allowed to return a restricted list of actions from handle_event(enter, ...). In particular, you can’t return {next_event, internal, Event}, which means that you can’t easily start a looping action.

Combining init and next_event

My currently preferred solution is the following. It can easily be combined with the further use of next_event to trigger a loop.

start_link(Host, Port) when is_list(Host), is_integer(Port) ->
    gen_statem:start_link(?MODULE, [Host, Port], []).

callback_mode() -> [handle_event_function].

init([Host, Port]) ->
    % ...
    {ok, initializing, StateData, {next_event, internal, initalize}}.

handle_event(internal, initialize, initializing, StateData) ->
    % do asynchronous initialization here.
    {next_state, connected, StateData, [{next_event, internal, loop}]};
handle_event(internal, loop, _, StateData) ->
    % ... something interesting ...
    {keep_state_and_data, [{next_event, internal, loop}]}.