Meta-Process
A meta-process solves a specific problem: how to integrate blocking I/O with the actor model without breaking its guarantees. It runs two goroutines - one executes your blocking I/O code, the other handles actor messages. This separation preserves sequential message processing while allowing continuous external I/O operations.
Meta-processes are owned by their parent process. When the parent terminates, all its meta-processes terminate with it. This dependency is by design - meta-processes extend the parent's capabilities rather than existing as independent entities in the supervision tree.
The Problem
Actors work sequentially. One message arrives, gets processed, completes. Next message. This simplicity eliminates race conditions and makes reasoning straightforward.
Blocking I/O breaks this model. Call net.Listener.Accept() in a message handler and the actor freezes. The goroutine blocks waiting for connections. Other messages pile up unprocessed. The actor becomes unresponsive.
The obvious fix fails. Spawn a goroutine for Accept() and now two goroutines access the actor's state concurrently. You need locks. The sequential guarantee vanishes. The actor model collapses into traditional concurrent programming with all its complexity.
Meta-processes preserve both. One goroutine blocks on I/O. Another goroutine processes messages sequentially. Neither interferes with the other.
Two Goroutines, Two Purposes
When a meta-process starts, the framework launches two goroutines:
External Reader: Runs your Start() method from beginning to end. This goroutine is meant for blocking operations - Accept() loops, ReadFrom() calls, reading from pipes. When external events occur, this goroutine sends messages into the actor system using Send(). It never processes incoming messages.
Actor Handler: Created on-demand when messages arrive in the mailbox. Processes messages sequentially by calling your HandleMessage() and HandleCall() methods. When the mailbox empties, this goroutine terminates. Next time messages arrive, a new actor handler spawns. This goroutine never does I/O directly - it handles requests from actors.
The External Reader runs continuously from spawn until termination. The Actor Handler comes and goes based on message traffic.
Why Regular Processes Cannot Do This
Goroutines
One per process
Two per meta-process
Message queues
4 queues (urgent, system, main, log)
2 queues (system, main)
Identifier type
gen.PID
gen.Alias
States
Init → Sleep → Running → Terminated
Sleep → Running → Terminated
Spawn children
Processes and meta-processes
Meta-processes only
Synchronous calls
Can make calls with Call()
Cannot make calls
Links and monitors
Can create and receive
Can only receive
Processes have one goroutine that must handle everything. If it blocks on I/O, message processing stops. If it spawns additional goroutines for I/O, the actor model breaks.
Meta-processes separate concerns. The External Reader handles I/O. The Actor Handler handles messages. Both run independently.
Restrictions Explained
Meta-processes cannot make synchronous calls. Which goroutine should block waiting for the response? The External Reader is blocked on external I/O. The Actor Handler might not be running. Neither can reliably wait for responses.
Meta-processes cannot create links or monitors. When a linked process terminates, it sends an exit signal as a message. The Actor Handler processes messages, but only when running. Signals could be delayed or lost if the Actor Handler is not active. Incoming links and monitors work because other processes send signals that queue in the mailbox. Creating outgoing links requires guarantees that meta-processes cannot provide.
These are not arbitrary limitations. They follow from having two goroutines with distinct responsibilities.
Behavior Implementation
type MetaBehavior interface {
Init(process MetaProcess) error
Start() error
HandleMessage(from PID, message any) error
HandleCall(from PID, ref Ref, request any) (any, error)
Terminate(reason error)
HandleInspect(from PID, item ...string) map[string]string
}Init() runs once during creation. Initialize state, store the MetaProcess reference, prepare resources. Return an error to prevent spawning.
Start() runs in the External Reader. This is where your blocking I/O lives. Loop forever accepting connections. Block reading datagrams. Read from pipes. When Start() returns, the meta-process terminates.
HandleMessage() processes regular messages sent by actors. Runs in the Actor Handler. Return nil to continue, return an error to terminate.
HandleCall() processes synchronous requests from actors. Return (result, nil) to send the result back. Return (nil, error) to send an error. The framework handles the response automatically.
Terminate() runs during shutdown regardless of how termination occurred. Close resources, flush buffers, clean up. Do not block or panic here.
HandleInspect() returns diagnostic information as string key-value pairs. Used by monitoring tools.
Three States
Sleep: External Reader is running (usually blocked on I/O), Actor Handler does not exist. Mailbox may contain messages waiting to be processed. This is the resting state when no actors are communicating with the meta-process.
Running: Both goroutines active. External Reader continues I/O operations. Actor Handler processes messages from the mailbox. Both work simultaneously without blocking each other.
Terminated: Both goroutines stopped. Start() returned and Actor Handler completed its final message.
Transitions are automatic. Message arrives → Actor Handler spawns → Sleep becomes Running. Mailbox empties → Actor Handler exits → Running becomes Sleep. Start() returns → Terminated regardless of current state.
Data Flow
The External Reader blocks reading while the Actor Handler simultaneously blocks writing. Two blocking operations, two goroutines, neither prevents the other.
Creating Meta-Processes
Define your behavior:
type UDPServer struct {
gen.MetaProcess
socket net.PacketConn
target gen.PID
}
func (u *UDPServer) Init(process gen.MetaProcess) error {
u.MetaProcess = process
u.target = process.Parent()
return nil
}
func (u *UDPServer) Start() error {
// External Reader - continuous read loop
for {
buf := make([]byte, 65536)
n, addr, err := u.socket.ReadFrom(buf)
if err != nil {
return err
}
u.Send(u.target, Datagram{Data: buf[:n], From: addr})
}
}
func (u *UDPServer) HandleMessage(from gen.PID, message any) error {
// Actor Handler - write on demand
switch msg := message.(type) {
case SendDatagram:
u.socket.WriteTo(msg.Data, msg.To)
}
return nil
}
func (u *UDPServer) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
return nil, nil
}
func (u *UDPServer) Terminate(reason error) {
u.socket.Close()
}
func (u *UDPServer) HandleInspect(from gen.PID, item ...string) map[string]string {
return map[string]string{"local_addr": u.socket.LocalAddr().String()}
}Spawn from a process:
type Server struct {
act.Actor
}
func (s *Server) Init(args ...any) error {
socket, err := net.ListenPacket("udp", ":8080")
if err != nil {
return err
}
udpServer := &UDPServer{socket: socket}
alias, err := s.SpawnMeta(udpServer, gen.MetaOptions{})
if err != nil {
socket.Close()
return err
}
s.Log().Info("UDP server listening on :8080 as %s", alias)
return nil
}The meta-process lives as long as its parent lives. When Server terminates, the UDP server terminates automatically.
State-Based Operations
Different operations are available in different states:
All states (Sleep, Running, Terminated):
Send(),SendWithPriority()- External Reader sends in Sleep, Actor Handler sends in RunningID(),Parent()- Identity never changesEnv(),EnvList(),EnvDefault()- Configuration accessLog()- Logging always availableSendPriority(),Compression()- Read settings
Running only:
SendResponse(),SendResponseError()- Only Actor Handler has thegen.ReffromHandleCall()SetSendPriority(),SetCompression()- Actor Handler controls these
Sleep and Running (not Terminated):
Spawn()- Both goroutines can spawn child meta-processes
The External Reader operates in Sleep state and has minimal capabilities - just sending messages and spawning children. The Actor Handler operates in Running state and has full capabilities for processing requests.
Shared State
Both goroutines access the same struct fields. Use atomic operations for shared counters and flags:
type TCPConnection struct {
gen.MetaProcess
conn net.Conn
bytesIn uint64 // accessed by both goroutines
bytesOut uint64 // accessed by both goroutines
}
func (t *TCPConnection) Start() error {
// External Reader
buf := make([]byte, 4096)
for {
n, err := t.conn.Read(buf)
if err != nil {
return err
}
atomic.AddUint64(&t.bytesIn, uint64(n))
t.Send(t.Parent(), Data{Bytes: buf[:n]})
}
}
func (t *TCPConnection) HandleMessage(from gen.PID, message any) error {
// Actor Handler
if msg, ok := message.(Data); ok {
n, err := t.conn.Write(msg.Bytes)
atomic.AddUint64(&t.bytesOut, uint64(n))
return err
}
return nil
}
func (t *TCPConnection) HandleInspect(from gen.PID, item ...string) map[string]string {
// Actor Handler
in := atomic.LoadUint64(&t.bytesIn)
out := atomic.LoadUint64(&t.bytesOut)
return map[string]string{
"bytes_in": fmt.Sprintf("%d", in),
"bytes_out": fmt.Sprintf("%d", out),
}
}Avoid complex synchronization. If you need mutexes, the design probably belongs in a regular process with meta-processes handling only I/O.
Common Patterns
External events to actors: External Reader reads events, sends them to actors for processing.
func (r *FileReader) Start() error {
file, _ := os.Open(r.filename)
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
r.Send(r.processor, Line{Text: scanner.Text()})
}
return scanner.Err()
}Actor-controlled I/O: Actors send commands, Actor Handler executes them against external resources.
func (e *CommandExecutor) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case RunCommand:
output, err := exec.Command(msg.Cmd, msg.Args...).Output()
e.Send(from, CommandResult{Output: output, Error: err})
}
return nil
}Full-duplex communication: External Reader reads, Actor Handler writes, both operate on the same connection.
func (t *TCPConnection) Start() error {
// Continuous reading
for {
n, err := t.conn.Read(buf)
if err != nil {
return err
}
t.Send(t.target, Received{Data: buf[:n]})
}
}
func (t *TCPConnection) HandleMessage(from gen.PID, message any) error {
// On-demand writing
if msg, ok := message.(Send); ok {
_, err := t.conn.Write(msg.Data)
return err
}
return nil
}Server accepting connections: External Reader accepts connections, spawns child meta-processes for each.
func (t *TCPServer) Start() error {
for {
conn, err := t.listener.Accept()
if err != nil {
return err
}
handler := &TCPConnection{conn: conn}
if _, err := t.Spawn(handler, gen.MetaOptions{}); err != nil {
conn.Close()
t.Log().Error("failed to spawn connection handler: %s", err)
}
}
}When to Use Meta-Processes
Use meta-processes when:
Operating on blocking I/O (TCP accept, UDP read, pipe read, file read)
Bridging external event sources with actors (monitoring filesystems, listening to OS signals)
Wrapping synchronous APIs that cannot be made asynchronous
Implementing network servers where accept loop must run continuously
Do not use meta-processes when:
Implementing business logic
Managing application state
Coordinating between actors
Processing messages that do not involve blocking I/O
Meta-processes sit at the boundary between the external world and the actor system. They translate blocking operations into asynchronous messages and execute actor commands using blocking APIs. Regular processes implement everything else.
Last updated
