← Back to Blog

Actor Model in Robotic Systems: Encapsulation and Scheduling

In complex robotic control systems, tasks such as sensor data acquisition, path planning, and motion control often need to be processed in parallel. Traditional concurrency models based on shared memory and locks can easily introduce deadlocks and race conditions as the system scales, making debugging difficult. The Actor model, which decouples components through message passing, has become an excellent alternative.

This article explores how to encapsulate a lightweight Actor system in a robotic environment and demonstrates its core implementation using Go.

Why Choose the Actor Model?

Robotic systems are inherently event-driven: a lidar detects an obstacle, an odometer updates position, or a high-level command issues a target point. These events need to flow efficiently between different modules.

The core ideas of the Actor model are:

  1. Everything is an Actor: Each Actor is an independent computational unit with private state.
  2. Message Passing: Actors communicate by sending messages, not sharing memory.
  3. Concurrent Execution: Actors process received messages concurrently.

In low-level languages like C++, we typically need to manually manage thread pools and message queues. In Go, Goroutines and Channels are naturally the cornerstones of the Actor model.

System Design: Static Shortcuts and Lifecycle

In practical engineering (referencing the design of a large search company's robotics library), to facilitate calls from business code, a global or singleton ActorSystem is often encapsulated, providing static methods as "Static Shortcuts."

The trade-off of this design lies in:

  • Pros: Business logic doesn't need to pass complex context objects around; Send can be called from anywhere.
  • Cons: It introduces global state, which poses challenges for testing and modularity. However, in embedded or single-application robot processes, this convenience is often worth it.

Clean Room Implementation (Go)

We will build a simplified Actor system containing the following elements:

  • PID (Process ID): Uniquely identifies an Actor.
  • Actor Interface: Defines behavior for receiving messages.
  • System: Manages Actor registration, destruction, and message dispatch.

1. Defining the Infrastructure

package actor

import (
    "fmt"
    "sync"
)

// PID unique identifier
type PID string

// Message generic message interface
type Message interface{}

// Actor interface for receiving and processing messages
type Actor interface {
    Receive(ctx *Context)
}

// Context contains message and context information
type Context struct {
    Message Message
    Sender  PID
    Self    PID
}

// System manager
type System struct {
    actors map[PID]chan Message
    mu     sync.RWMutex
    wg     sync.WaitGroup
}

func NewSystem() *System {
    return &System{
        actors: make(map[PID]chan Message),
    }
}

2. Actor Registration and Startup

We use Goroutines to simulate the Actor's run loop. Each Actor has an independent Channel as its mailbox.

func (s *System) Spawn(name PID, actor Actor) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, exists := s.actors[name]; exists {
        return fmt.Errorf("actor %s already exists", name)
    }

    mailbox := make(chan Message, 100) // Buffered mailbox
    s.actors[name] = mailbox
    s.wg.Add(1)

    go func() {
        defer s.wg.Done()
        for msg := range mailbox {
            actor.Receive(&Context{
                Message: msg,
                Self:    name,
            })
        }
    }()
    return nil
}

3. Message Dispatch (Simulating Static Shortcut)

In C++ implementations, TActorSystem::Send is often encapsulated as a static function. In Go, we can achieve a similar effect through System instance methods. The key is that sending is non-blocking (unless the mailbox is full).

func (s *System) Send(target PID, msg Message) {
    s.mu.RLock()
    ch, ok := s.actors[target]
    s.mu.RUnlock()

    if ok {
        select {
        case ch <- msg:
            // Send successful
        default:
            fmt.Printf("Warning: Mailbox for %s is full, dropping message\n", target)
        }
    } else {
        fmt.Printf("Warning: Actor %s not found\n", target)
    }
}

Industrial Application

In a robot navigation stack, we can define a Planner Actor and a MotorController Actor.

type MoveCommand struct {
    X, Y float64
}

type MotorActor struct{}

func (m *MotorActor) Receive(ctx *Context) {
    switch msg := ctx.Message.(type) {
    case MoveCommand:
        fmt.Printf("[Motor] Moving to (%f, %f)\n", msg.X, msg.Y)
    }
}

func main() {
    sys := NewSystem()
    sys.Spawn("motor_1", &MotorActor{})

    // Simulate sensor data triggering path planning, send command directly
    sys.Send("motor_1", MoveCommand{X: 10.5, Y: 20.0})
    
    // Graceful shutdown mechanism would be needed in a real system
}

This pattern effectively decouples "decision" (sending messages) from "execution" (processing messages). The HTSwap mechanism is used in C++ for lock-free swapping of message queues, while in Go, Channels handle synchronization under the hood, making the code much cleaner.

By encapsulating the scheduling logic, robot module developers only need to focus on the business logic within Receive, without worrying about thread safety and lock contention, which is the true value of architectural design.