Tutorial 8: Multi-Process Systems (C++)
Real robots run multiple processes — a sensor driver, a controller, a safety monitor, each as a separate binary. This tutorial shows how to build and run multi-process systems.
What You'll Learn
- Separate binaries sharing SHM topics
- Process lifetime and SHM ownership across processes
- Cross-process service calls
- Using
horus launchfor multi-process orchestration
Architecture
Process 1: lidar_driver Process 2: controller Process 3: safety
┌────────────────────┐ ┌────────────────────┐ ┌────────────┐
│ Publisher │──SHM─→│ Subscriber │ │ Subscriber │
│ "lidar.scan" │ │ "lidar.scan" │ │ "cmd_vel" │
└────────────────────┘ │ Publisher │──SHM─→│ │
│ "cmd_vel" │ └────────────┘
└────────────────────┘
All three processes map their topics out of the same directory, /dev/shm/horus_default/topics/, and the two ends of a topic map the same file: process 1 and 2 share lidar.scan, process 2 and 3 share cmd_vel. No message broker, no serialization.
Process 1: Sensor Driver
// sensor_driver.cpp
#include <horus/horus.hpp>
#include <cmath>
using namespace horus::literals;
class LidarSensor : public horus::Node {
public:
LidarSensor() : Node("lidar") {
pub_ = advertise<horus::msg::LaserScan>("lidar.scan");
}
void tick() override {
auto scan = pub_->loan();
for (int i = 0; i < 360; i++)
scan->ranges[i] = 2.0f + 0.5f * std::sin(i * 0.1f);
pub_->publish(std::move(scan));
}
private:
horus::Publisher<horus::msg::LaserScan>* pub_;
};
int main() {
horus::Scheduler sched;
sched.tick_rate(10_hz).name("lidar_proc");
LidarSensor lidar;
sched.add(lidar).order(0).build();
sched.spin();
}
Process 2: Controller
// controller.cpp
#include <horus/horus.hpp>
using namespace horus::literals;
class Controller : public horus::Node {
public:
Controller() : Node("controller") {
scan_sub_ = subscribe<horus::msg::LaserScan>("lidar.scan");
cmd_pub_ = advertise<horus::msg::CmdVel>("cmd_vel");
}
void tick() override {
auto scan = scan_sub_->recv();
if (!scan) return;
float min_range = 999.0f;
for (int i = 0; i < 360; i++)
if (scan->get()->ranges[i] < min_range)
min_range = scan->get()->ranges[i];
horus::msg::CmdVel cmd{};
cmd.linear = min_range > 0.5f ? 0.3f : 0.0f;
cmd_pub_->send(cmd);
}
void enter_safe_state() override {
horus::msg::CmdVel stop{};
cmd_pub_->send(stop);
}
private:
horus::Subscriber<horus::msg::LaserScan>* scan_sub_;
horus::Publisher<horus::msg::CmdVel>* cmd_pub_;
};
int main() {
horus::Scheduler sched;
sched.tick_rate(50_hz).name("ctrl_proc");
Controller ctrl;
sched.add(ctrl).order(0).build();
sched.spin();
}
Process 3: Safety Monitor
The third process watches cmd_vel without ever touching the lidar topic — it only needs the one SHM file the controller publishes into. It also hosts the estop service, covered in the next section.
// safety_monitor.cpp
#include <horus/horus.hpp>
#include <atomic>
#include <cstring>
using namespace horus::literals;
static std::atomic<bool> g_estop{false};
// A handler is a plain function pointer — it cannot capture. `*res_len` is
// IN/OUT: on entry the capacity of `res`, on return the bytes written.
static bool handle_estop(const uint8_t*, size_t, uint8_t* res, size_t* res_len) {
g_estop.store(true, std::memory_order_relaxed);
const char body[] = R"({"stopped":true})";
size_t n = sizeof(body) - 1;
if (n > *res_len) return false;
std::memcpy(res, body, n);
*res_len = n;
return true;
}
class SafetyMonitor : public horus::Node {
public:
SafetyMonitor() : Node("safety"), estop_("estop") {
cmd_sub_ = subscribe<horus::msg::CmdVel>("cmd_vel");
estop_.set_handler(handle_estop);
}
void tick() override {
estop_.process(); // answer any pending requests
auto cmd = cmd_sub_->recv();
if (!cmd) return;
if (g_estop.load(std::memory_order_relaxed) || cmd->get()->linear > 0.5f)
horus::log::warn("safety", "commanded motion blocked");
}
private:
horus::Subscriber<horus::msg::CmdVel>* cmd_sub_;
horus::ServiceServer estop_;
};
int main() {
horus::Scheduler sched;
sched.tick_rate(100_hz).name("safety_proc");
SafetyMonitor safety;
sched.add(safety).order(0).build();
sched.spin();
}
Cross-Process Service Calls
Topics are one-way fan-out. When one process needs an answer from another — "are you armed?", "stop now" — use a service instead. horus::ServiceServer and horus::ServiceClient exchange JSON payloads over the same SHM machinery as topics, on estop.request and a per-client estop.response.<pid>.
The server side is the estop_ member above: construct it with a name, hand it a handler, and call process() on every tick so requests get answered from the thread that created it. The client side is three lines from any other process:
// estop_client.cpp — a teleop binary, a watchdog, anything
#include <horus/horus.hpp>
#include <chrono>
#include <cstdio>
int main() {
horus::ServiceClient estop("estop");
auto reply = estop.call(R"({})", std::chrono::milliseconds(500));
if (!reply) { std::fprintf(stderr, "estop: no response\n"); return 1; }
std::printf("%s\n", reply->c_str()); // {"stopped":true}
}
call() returns std::nullopt on timeout or transport error — there is no exception to catch.
One caveat: horus service list finds a C++ service (it discovers services by scanning SHM for the .request topic), but horus service call does not reach one. The CLI delivers requests through a JSON file gateway under /dev/shm/horus_default/topics/.service_gateway/, and only the Rust ServiceServer polls it. Call a C++ service with a ServiceClient.
Running Multi-Process
# Build all three
g++ -std=c++17 -I horus_cpp/include -o sensor sensor_driver.cpp \
-L target/debug -lhorus_cpp -lpthread -ldl -lm
g++ -std=c++17 -I horus_cpp/include -o controller controller.cpp \
-L target/debug -lhorus_cpp -lpthread -ldl -lm
g++ -std=c++17 -I horus_cpp/include -o safety safety_monitor.cpp \
-L target/debug -lhorus_cpp -lpthread -ldl -lm
# Run (order does not matter — start them in any order)
LD_LIBRARY_PATH=target/debug ./controller &
LD_LIBRARY_PATH=target/debug ./sensor &
LD_LIBRARY_PATH=target/debug ./safety &
# Monitor from any terminal
horus topic list # shows lidar.scan, cmd_vel, estop.request
horus node list # shows lidar, controller, safety (separate PIDs)
horus service list # shows estop
Startup Order and SHM Lifetime
Startup order does not affect correctness — a subscriber can join a topic that is already being published to and will start receiving on its next recv(). What does matter is process lifetime: the first process to open a topic owns its SHM file and unlinks it on exit, so if the publisher is the sole owner and exits, the ring buffer disappears with it.
When a process genuinely does need another one up first (a driver that must claim a device, say), express it in horus launch with depends_on and start_delay rather than by hand-sequencing shell commands.
Orchestrating with horus launch
Backgrounding three binaries by hand does not survive contact with a real robot. horus launch reads a YAML file and starts, orders, and supervises the whole set:
# robot.launch.yaml
session: robot
env:
LD_LIBRARY_PATH: target/debug
nodes:
- name: lidar
command: ./sensor
- name: safety
command: ./safety
# Topics need no ordering, but we want the estop service answering
# before anything can command motion.
- name: controller
command: ./controller
depends_on: [safety]
start_delay: 0.2
restart: on-failure
horus launch --dry-run robot.launch.yaml # print the startup order, launch nothing
horus launch robot.launch.yaml # start the session
horus launch --status # list running sessions
horus launch --stop robot # stop this session by name
depends_on topologically orders startup, start_delay waits that many seconds before starting a node, and restart (never, the default, always, or on-failure) decides whether a node that dies is respawned.
Key Takeaways
- Each process has its own
Scheduler— they don't share a scheduler - Topics are shared via SHM files (
/dev/shm/horus_default/topics/) - Any process can start first; whichever opens a topic first creates (and on exit unlinks) its SHM ring buffer
horus topic listshows topics from ALL processes- No message broker — direct SHM ring buffer, ~170ns cross-process latency
ServiceServer/ServiceClientadd request/response on top of the same SHM — drive the server withprocess()on every tickhorus launchstarts, orders (depends_on,start_delay) and restarts the whole set from one YAML file- Each process can have different tick rates (10 Hz sensor, 50 Hz controller, 100 Hz safety)