Logos. Process
(Logos v0.2.0)
Copy Markdown
The BEAM-native concurrency primitive family: spawn, spawn-link,
spawn-monitor, link, unlink, monitor, demonitor,
trap-exits!, exit, self, send, receive-match!. Every function
here is plain Elixir, called from Logos.Primitives' (args, runtime)
dispatch against already-evaluated argument values -- none of them need
macro-hood. (The one related piece that does need macro-hood is the
Lisp-level receive macro in concurrency.logos, since its clauses must
stay unevaluated until a match is found.)
spawn -- what actually runs
spawn/2 takes a zero-arg %Logos.Fn{} (a thunk) and the calling
Logos.Runtime, and starts a genuine new BEAM process (Kernel.spawn/1)
that evaluates the thunk's body via Logos.Eval.apply_fn/3 against the
same runtime. This works because Logos.Runtime's registry lives in
a :public ETS table (see that module's moduledoc): a spawned process
can read and write Vars/namespaces directly through the table, with no
need to round-trip requests through some single owning process. Zero-arg
only -- matching real Clojure/core.async-style thread-spawning
convention (a "thunk"), simplest possible calling shape; a caller who
wants to pass data into the spawned process closes over it in the fn
itself ((spawn (fn [] (worker-body some-captured-value)))), exactly
like a real closure would.
Logos.Eval.apply_fn/3's lexical environment handling already gives the
spawned process a fresh Logos.Env frame chained under the closure's
captured env (see Logos.Eval's own private bind_params/4 --
Env.new(closure_env)) -- nothing extra is needed here for that.
Crash semantics
If the thunk's evaluation raises Logos.EvalError (Logos.Eval's
primitive-level failure signal, not a BEAM-level crash by itself),
run_thunk/2 rescues it and re-raises a real Logos.ProcessCrash
carrying the same reason so the spawned BEAM process terminates
abnormally, exactly the way a real crash would -- link/monitor on
the spawning side see a genuine :EXIT/:DOWN with that reason,
matching real BEAM semantics (a Logos function erroring is conceptually
"this process failed", the same as an Elixir process raising). An
uncaught Logos-level (throw ...) (Logos.Thrown, a real Elixir
exception with no enclosing try) needs no special handling here at
all -- it already propagates as an ordinary Elixir exception and
crashes the process on its own.
send -- what a message can carry
A send message can carry any Logos.Value.t() -- the full set of
runtime values, which is broader than what the reader can produce from
source text (Logos.Form.t()) -- including a live %Logos.Fn{}
closure or a %Logos.Pid{}/%Logos.Atom{}. concurrency.logos's swap!
relies on exactly this: it sends its update function as part of the
message to an atom's owning loop process, which then applies that
function locally. This module just does a real Erlang send/2;
Elixir/Erlang terms (including closures/pids-as-data, since we're
always staying on one node) transfer in-VM with no serialization at
all. Out of scope: a %Logos.Fn{} closure carries a Logos.Env chain
(arbitrary nested Elixir terms, including potentially other pids/refs)
that would not serialize sensibly to a different BEAM node.
Cross-node Logos usage instead goes through mix logos.remsh, which
ships each top-level form as source text via :rpc.call to the target
node and evaluates it there -- never raw cross-node message passing of
a live Logos closure. Nothing here needs to guard against that; it
simply never arises as long as spawn only ever creates local
processes (which is all Kernel.spawn/1 does).
receive-match! -- genuine selective receive
A dynamic Lisp predicate can't drive Erlang's own compiled receive
clauses, so receive_match!/5 implements the standard Elixir/Erlang
"explicit mailbox draining" technique by hand, using real receive/
after: pop one message, test it against the predicate; on a match, run
the handler and return its result; on a miss, send/2 the message back
to this same process (self()) and keep scanning. This is an
accepted tradeoff, not a bug this implementation tries to avoid:
because a re-sent message goes to the back of the mailbox, a message
that's skipped over on one scan can end up reordered relative to any
brand-new message that arrives while the scan is still in progress.
receive_match!/3 (2-arg predicate/handler primitive form) blocks
indefinitely (Erlang's own :infinity timeout atom -- equivalent to a
bare receive with no after at all: no busy-loop while the mailbox is
genuinely empty, real BEAM scheduling blocks the process). The 5-arg
form takes an explicit timeout_ms (an integer) and a zero-arg
default_fn thunk called once the deadline passes with nothing having
matched.
Note the one real caveat of this technique, inherent to the design (not
a bug): if the mailbox already contains messages and none of them ever
matches the predicate, each receive immediately re-matches its own
just-requeued message, which busy-loops (consuming CPU) rather than
truly blocking, until the deadline (or, for the indefinite form,
forever). This is the well-known drawback of hand-rolled selective
receive on the BEAM, and it's accepted here rather than engineered
around: avoiding it would require tracking message identity to tell
"already scanned and requeued this pass" apart from "genuinely new,"
which is more machinery than this primitive's use cases justify.
:DOWN/:EXIT message normalization
{:DOWN, ref, :process, pid, reason} and {:EXIT, pid, reason} are
raw Erlang tuples generated by the VM itself (from monitor/
trap-exits!) -- not something built via our own send, and not
expressible in Logos's own data model at all (Logos has no native tuple
type; see Logos.Form.t()'s 13-case table -- lists/vectors/maps/sets
only). Left completely raw, Lisp code could receive such a message but
could never destructure it (no elem/tuple-access primitive exists).
So every message popped off the mailbox by receive_match!/5 is passed
through normalize_message/1 first: a raw :DOWN/:EXIT tuple becomes
an ordinary Logos list ((:DOWN ref-marker :process pid reason) /
(:EXIT pid reason), using first/rest-friendly Elixir lists, not
vectors, so plain first/rest/= work on it directly from Lisp) with
its pid() field(s) wrapped as %Logos.Pid{} (so it round-trips through
link/monitor/send the same way any other Pid does); anything else
(an ordinary message sent via our own send primitive, already
perfectly good Logos.Value.t()) passes through completely unchanged.
A monitor reference has no dedicated Logos wrapper type of its own --
it's carried as an opaque {:logos_ref, ref} marker tuple, the same
private-tuple-marker idiom Logos.Eval/
Logos.Primitives already use for {:primitive, name}/{:host_fn, ...}
-- Lisp code can hold/pass/=-compare it (needed for demonitor) but
never has to (and structurally cannot) look inside it.
Summary
Functions
(exit) / (exit reason) -- terminates the calling process (real
Kernel.exit/1; never returns). (exit pid reason) -- sends an exit
signal to another process (real Process.exit/2; returns true).
Real Process.monitor/1. Returns the reference wrapped as an opaque {:logos_ref, ref} marker -- see moduledoc.
Indefinite-block form: (receive-match! pred handler).
(receive-match! pred handler timeout-ms default-fn) -- see moduledoc.
timeout_ms is either the atom :infinity or a non-negative integer of
milliseconds; default_fn (a zero-arg callable) is invoked once the
deadline passes with nothing having matched -- nil means "no default,
raise Logos.EvalError with reason :receive_timeout instead" (used
when a caller wants the indefinite-block behavior but happened to still
pass an explicit timeout).
Starts a new BEAM process running thunk's zero-arg body against runtime. Returns a %Logos.Pid{}.
Like spawn/2, atomically linked to the calling process (real Kernel.spawn_link/1 semantics).
Like spawn/2, atomically monitored by the calling process (real
Kernel.spawn_monitor/1 semantics). Returns a two-element Logos list
(pid monitor-ref) -- the natural Lisp-level calling shape for a
primitive that hands back two related-but-distinct values (mirroring
how Kernel.spawn_monitor/1 itself returns a {pid, ref} tuple; a
Logos list rather than a raw Elixir tuple since tuples aren't part of
the Logos data model, see moduledoc).
Wraps Process.flag(:trap_exit, bool). Returns the previous value, matching Process.flag/2's own return convention.
Functions
@spec demonitor({:logos_ref, reference()}) :: :ok
@spec exit_pid(Logos.Pid.t(), term()) :: boolean()
(exit) / (exit reason) -- terminates the calling process (real
Kernel.exit/1; never returns). (exit pid reason) -- sends an exit
signal to another process (real Process.exit/2; returns true).
Arity choice mirrors Erlang's own exit/1 (self) vs exit/2 (other
process) split directly rather than inventing a different Lisp-level
convention -- (exit) alone defaults to reason :normal (an unadorned
"just stop me" call), (exit reason) self-exits with an explicit
reason, (exit pid reason) is the two-arg
form aimed at someone else. A bare Logos keyword :normal/:kill is
translated to the literal Erlang atom BEAM gives special meaning to
(reason_term/1); any other reason value (including an arbitrary
keyword/string/whatever) is passed through completely as-is -- exit
reasons are opaque terms as far as BEAM is concerned except for those
two specific atoms.
@spec link(Logos.Pid.t()) :: :ok
@spec monitor(Logos.Pid.t()) :: {:logos_ref, reference()}
Real Process.monitor/1. Returns the reference wrapped as an opaque {:logos_ref, ref} marker -- see moduledoc.
@spec receive_match!(term(), term(), Logos.Runtime.t()) :: term()
Indefinite-block form: (receive-match! pred handler).
@spec receive_match!( term(), term(), :infinity | non_neg_integer(), term() | nil, Logos.Runtime.t() ) :: term()
(receive-match! pred handler timeout-ms default-fn) -- see moduledoc.
timeout_ms is either the atom :infinity or a non-negative integer of
milliseconds; default_fn (a zero-arg callable) is invoked once the
deadline passes with nothing having matched -- nil means "no default,
raise Logos.EvalError with reason :receive_timeout instead" (used
when a caller wants the indefinite-block behavior but happened to still
pass an explicit timeout).
@spec self() :: Logos.Pid.t()
@spec send(Logos.Pid.t() | Logos.Atom.t(), term()) :: term()
Real send/2. msg can be any Logos.Value.t() -- see moduledoc. Returns msg, matching Erlang send/2's own return value.
@spec spawn(Logos.Fn.t(), Logos.Runtime.t()) :: Logos.Pid.t()
Starts a new BEAM process running thunk's zero-arg body against runtime. Returns a %Logos.Pid{}.
@spec spawn_link(Logos.Fn.t(), Logos.Runtime.t()) :: Logos.Pid.t()
Like spawn/2, atomically linked to the calling process (real Kernel.spawn_link/1 semantics).
@spec spawn_monitor(Logos.Fn.t(), Logos.Runtime.t()) :: [term()]
Like spawn/2, atomically monitored by the calling process (real
Kernel.spawn_monitor/1 semantics). Returns a two-element Logos list
(pid monitor-ref) -- the natural Lisp-level calling shape for a
primitive that hands back two related-but-distinct values (mirroring
how Kernel.spawn_monitor/1 itself returns a {pid, ref} tuple; a
Logos list rather than a raw Elixir tuple since tuples aren't part of
the Logos data model, see moduledoc).
Wraps Process.flag(:trap_exit, bool). Returns the previous value, matching Process.flag/2's own return convention.
@spec unlink(Logos.Pid.t()) :: :ok