The shared-state problem
The typical approach to multi-threading: shared data protected by mutexes.
1class SensorManager {
2 std::mutex mtx_;
3 SensorData latest_; // shared — both threads touch it
4 bool updated_ = false;
5public:
6 void updateFromSensor() { // called from sensor thread
7 std::lock_guard lk(mtx_);
8 latest_ = readSensor();
9 updated_ = true;
10 }
11
12 SensorData getLatest() { // called from display thread
13 std::lock_guard lk(mtx_);
14 updated_ = false;
15 return latest_;
16 }
17};
This works for two threads, but as the number of callers grows, lock contention increases, and the reasoning about which thread owns which state becomes harder. Every method is a potential deadlock site.
The active object pattern eliminates shared state: the object owns its own thread and processes all calls as messages from a queue. No external thread ever touches its internals directly.
The active object
1#include <thread>
2#include <queue>
3#include <mutex>
4#include <condition_variable>
5#include <functional>
6#include <atomic>
7
8class ActiveObject {
9public:
10 ActiveObject() : running_(true), thread_([this] { run(); }) {}
11
12 ~ActiveObject() {
13 post([this] { running_ = false; }); // poison pill
14 thread_.join();
15 }
16
17 // Post a message (callable) to the internal queue — non-blocking
18 void post(std::function<void()> msg) {
19 {
20 std::lock_guard<std::mutex> lk(mtx_);
21 queue_.push(std::move(msg));
22 }
23 cv_.notify_one();
24 }
25
26private:
27 void run() {
28 while (running_) {
29 std::function<void()> msg;
30 {
31 std::unique_lock<std::mutex> lk(mtx_);
32 cv_.wait(lk, [this] { return !queue_.empty(); });
33 msg = std::move(queue_.front());
34 queue_.pop();
35 }
36 msg(); // execute in this thread — no external lock needed
37 }
38 }
39
40 std::atomic<bool> running_;
41 std::thread thread_;
42 std::mutex mtx_;
43 std::condition_variable cv_;
44 std::queue<std::function<void()>> queue_;
45};
All state lives inside the object. The thread executes messages sequentially — no two messages run concurrently, so no internal mutex is needed on the state.
A concrete active sensor
1class SensorService : private ActiveObject {
2 // State — only accessed from the internal thread
3 SensorData latest_;
4 std::vector<std::function<void(SensorData)>> subscribers_;
5
6public:
7 // Public API — called from any thread, posts to internal queue
8 void requestRead() {
9 post([this] { doRead(); });
10 }
11
12 void subscribe(std::function<void(SensorData)> cb) {
13 post([this, cb = std::move(cb)] mutable {
14 subscribers_.push_back(std::move(cb));
15 });
16 }
17
18 // Returns a future — caller can wait for the result
19 std::future<SensorData> getLatest() {
20 auto promise = std::make_shared<std::promise<SensorData>>();
21 auto future = promise->get_future();
22 post([this, promise] {
23 promise->set_value(latest_);
24 });
25 return future;
26 }
27
28private:
29 // Runs only in the internal thread
30 void doRead() {
31 latest_ = readSensor(); // no lock — only this thread accesses latest_
32 for (auto& cb : subscribers_)
33 cb(latest_);
34 }
35};
Usage from multiple threads:
1SensorService sensor;
2
3// Display thread — subscribes to updates
4sensor.subscribe([](SensorData d) {
5 display.update(d.temperature); // called in sensor's thread — be careful!
6});
7
8// Control thread — periodic reads
9while (true) {
10 sensor.requestRead();
11 std::this_thread::sleep_for(std::chrono::milliseconds(100));
12}
13
14// Query thread — wait for a value
15auto future = sensor.getLatest();
16auto data = future.get(); // blocks until the active object responds
The callback ownership problem
In the subscriber example, the callback display.update runs in the sensor’s
thread, not the display’s thread. If display has its own thread and its own
state, this is a thread-safety violation.
The correct fix: make the display also an active object, and have callbacks post to the display’s queue:
1class DisplayService : private ActiveObject {
2public:
3 void update(SensorData d) {
4 post([this, d] { doUpdate(d); });
5 }
6private:
7 void doUpdate(SensorData d) {
8 lcd.draw(d.temperature); // only runs in DisplayService's thread
9 }
10};
11
12DisplayService display;
13
14sensor.subscribe([&display](SensorData d) {
15 display.update(d); // posts to display's queue — non-blocking, thread-safe
16});
Each active object owns its state and processes messages from its own thread.
Communication between them is always via post — never via direct calls.
FreeRTOS variant
On FreeRTOS, the active object pattern maps directly to a task + queue:
1class ActiveSensorTask {
2 QueueHandle_t queue_;
3 TaskHandle_t task_;
4
5 struct Message {
6 enum class Type { Read, Subscribe, Stop } type;
7 // payload...
8 };
9
10public:
11 ActiveSensorTask() {
12 queue_ = xQueueCreate(16, sizeof(Message));
13 xTaskCreate(taskFn, "Sensor", 512, this, 2, &task_);
14 }
15
16 void requestRead() {
17 Message msg{Message::Type::Read};
18 xQueueSend(queue_, &msg, 0);
19 }
20
21 void requestStop() {
22 Message msg{Message::Type::Stop};
23 xQueueSend(queue_, &msg, portMAX_DELAY);
24 }
25
26private:
27 static void taskFn(void* param) {
28 static_cast<ActiveSensorTask*>(param)->run();
29 }
30
31 void run() {
32 Message msg;
33 while (true) {
34 if (xQueueReceive(queue_, &msg, portMAX_DELAY) == pdTRUE) {
35 if (msg.type == Message::Type::Stop) break;
36 if (msg.type == Message::Type::Read) doRead();
37 }
38 }
39 vTaskDelete(nullptr);
40 }
41
42 void doRead() {
43 SensorData d = readSensor();
44 // notify other tasks via their queues
45 }
46};
The structure is identical: a task owns its state, receives typed messages from a queue, processes them sequentially. No mutex needed on internal state.
Bounded queue and back-pressure
The std::queue in the basic implementation grows without bound — a slow
consumer can exhaust memory. Use a bounded queue with a post that signals
back-pressure:
1bool post(std::function<void()> msg) {
2 std::lock_guard<std::mutex> lk(mtx_);
3 if (queue_.size() >= maxQueueSize_) return false; // reject
4 queue_.push(std::move(msg));
5 cv_.notify_one();
6 return true;
7}
On FreeRTOS, xQueueSend with timeout 0 returns errQUEUE_FULL — the caller
decides whether to drop, retry, or escalate.
Active object vs actor model
The active object pattern is the C++ version of the actor model (Erlang, Akka). Each object is an actor — isolated state, message-passing communication.
The difference: actors typically have a runtime that routes messages between objects by name or address. Active objects wire up their own queues explicitly — simpler, no framework, but less dynamic.
For small embedded systems, the active object pattern with FreeRTOS tasks and queues gives the benefits of actor isolation without a framework.
Summary
- Active object = one thread + one queue + all state inside
- External threads post messages (callables or typed structs) — never call methods directly on shared state
- The internal thread executes messages sequentially — no internal mutex needed
- Callbacks that post to another active object’s queue keep the invariant: no cross-thread state access
- Maps directly to FreeRTOS task + queue — same pattern, different runtime
- Use bounded queues and handle back-pressure explicitly
What’s next
- FreeRTOS task and queue design — FreeRTOS implementation of the same pattern
- Lock-free queues — replacing the mutex-protected queue with a lock-free SPSC
- Observer pattern — callbacks across active objects