Skip to main content

Command Palette

Search for a command to run...

Signals and Slots for IoT: Calm, Caffeinated Concurrency with Nim and Sigils

Updated
9 min read
Signals and Slots for IoT: Calm, Caffeinated Concurrency with Nim and Sigils

Sigils has been a fun little project I created to implement QT's famous signals and slots mechanism in Nim. Initially it was created for working with my experimental UI library Figuro but it has started to get a life of its own.

Signals and slots are a powerful programming construct that lends itself to implementing observer patterns, bus architectures, and producer-consumer patterns. The last one lends itself to supporting common multi-threading patterns!

After adding threading support to Sigils this spring it dawned on me: signals and slots delivered a very useful set of core features: message passing oriented, event driven, fully typed, and pleasantly thread-savvy. Everything needed for implementing actors?!

You see, for a long I’ve wanted something closer to Elixir and Erlang for Nim where self-contained and preemptive actors are easy to create and use. Unfortunately they’re harder to replicate in a natively compiled language. Go doesn’t do it, neither does async.

Combine threaded signal and slots with an easy way to spawn up a new thread for an object and bam you got a preemptive actor system! Now some say we’re not using “lightweight” threads or some such, but who cares when we can run 9,000+ threads on moderate Linux servers. Smaller number of threaded actors can be very useful for embedded real time devices as well!

Signals and Slots

What are signals and slots? If you haven’t used QT before you may be unfamiliar with them. QT introduces them like this:

In GUI programming, when we change one widget, we often want another widget to be notified. More generally, we want objects of any kind to be able to communicate with one another. For example, if a user clicks a Close button, we probably want the window's close() function to be called.

Other toolkits achieve this kind of communication using callbacks. A callback is a pointer to a function, so if you want a processing function to notify you about some event you pass a pointer to another function (the callback) to the processing function. The processing function then calls the callback when appropriate. While successful frameworks using this method do exist, callbacks can be unintuitive and may suffer from problems in ensuring the type-correctness of callback arguments.

In Qt, we have an alternative to the callback technique: We use signals and slots. A signal is emitted when a particular event occurs. Qt's widgets have many predefined signals, but we can always subclass widgets to add our own signals to them. A slot is a function that is called in response to a particular signal. Qt's widgets have many pre-defined slots, but it is common practice to subclass widgets and add your own slots so that you can handle the signals that you are interested in.

You can read more in the QT Documentation or Wikipedia.

The core feature of signals and slots lies in decoupling. Interactions become messages, or, events. From this you can begin modeling systems as connected state machines. It a bit of an art to learn how to use them effectively.

Backstory: IoT Project Quagmire

For the past few months I’ve been building out an IoT project for a startup using Nim, which has been a “superpower” for my IoT work. Some of the issues I've encountered would've been much harder to solve without it.

The IoT project started with just a simple sensor but unfortunately got more complicated as we had to work around the software limitations of a vendor's hardware module that we rely on. This made the problem an order of magnitude more complex.

This complicated the data handling significantly and the original codebase meant as a quick and dirty prototype had to evolve. Early on I had introduced a simple worker thread with a shared object protected via a lock. This was simple and mostly worked.

However it became a bit of a mess trying to ensure that the data from the database, vendor’s cloud API, and our own sensors were being coordinated nicely. It was hard to understand or modify.

I decided to try using my Sigils library in a new database syncing module. I’d done a bunch of work making it thread-safe this spring and it’b been solid and made threading easy. The experiment worked very well.

Now I've rewritten most of it to use Sigils between components and it has drastically simplified the architecture. At this points it's almost 7,000 lines of code so it's not “tiny”. Making new features and fixing issue is drastically easier now.

Using Signals and Slots in Nim

Sigils is based on idea of events and message passing. It’s a form of object oriented programming, but shares a heritage more with SmallTalk and Object-C than Java and C++.

At it’s core it is pretty simple:

import sigils

type Counter*[T] = ref object of Agent
       value: T

proc valueChanged*[T](tp: Counter[T], val: T) {.signal.}

proc setValue*[T](self: Counter[T], value: T) {.slot.} =
  echo "setValue! ", value
  if self.value != value:
    # we want to be careful not to set circular triggers!
    self.value = value
    emit self.valueChanged(value)

That’s it. However, you add in threading and suddenly you begin get concurrency and separation of concerns. Under the hood that emit self.valuedChanged(value) doesn’t care if it’s invoking a native proc, or a remote proc.

Why Signals and Slots Work well in Nim Projects

  • Decoupled by default: producers and consumers meet at events, not internals.

  • Typed contracts: .signal. and .slot. capture intent you can refactor fearlessly.

  • Ownership-aware concurrency: slots run on their agent’s thread; shared state stays sane.

  • Intentional wiring: connect reads like a map of the system — grepable topology.

  • Cross‑thread, zero drama: Agent, AgentProxy, moveToThread, and emit make multi-thread fan-out/fan-in feel frictionless.

Calm beats clever. Sigils keeps the caffeine without the jitters.

A Small, Generic Example (Nim + Sigils)

Imagine a sensor pipeline: a Sensor emits readings, a Projector updates views for a UI, and a Processor enriches data. Three agents, two threads, one readable wiring harness.

import sigils
import sigils/threads

type
  Temperature = object
    celsius: float
    ts: int64

  Sensor = ref object of Agent
  Projector = ref object of Agent
  Processor = ref object of Agent
  Boot = ref object of Agent

proc start(self: Boot) {.signal.}
proc reading(self: Sensor, t: Temperature) {.signal.}

proc onReading(self: Projector, t: Temperature) {.slot.} =
  discard  # update in-memory view / notify UI

proc onProcess(self: Processor, t: Temperature) {.slot.} =
  discard  # persist, aggregate, or enrich

proc init(self: Sensor) {.slot.} =
  let tProj = newSigilThread(); tProj.start()
  let tProc = newSigilThread(); tProc.start()

  let projector = Projector().moveToThread(tProj)
  let processor = Processor().moveToThread(tProc)

  # Fan-out one signal to many slots, across threads
  threads.connect(self, reading, projector, onReading)
  threads.connect(self, reading, processor, onProcess)

  # Kick off a first reading; later, timers/IO would emit more
  emit self.reading(Temperature(celsius: 21.5, ts: 1690000000))

proc run() =
  let tSensor = newSigilThread(); tSensor.start()
  let sensor = Sensor().moveToThread(tSensor)
  let boot = Boot()
  threads.connect(boot, start, sensor, init)
  emit boot.start()

Readability is the feature. Who emits? Who listens? Which thread owns what? It’s all in one place, with types you can lean on. You can use this to encapsulate data while orchestrating events from a single point.

Meanwhile a signal can target any agent or a very specific agent avoiding the need for complex object hierarchies or creating one off traits or interfaces. You gain granularity. Now there is the downside that groups of methods aren’t modeled but we can lean on Nim’s concepts for that if we want.

Since connect can tie a single event to multiple slot's you get free bus and fan-out even across threads. You can readily connect another slot just to debug and log an event.

Queued Connections and Loops

Another useful pattern is simple looping for batching work (better timers are coming to Sigils soon!):

import sigils
import sigils/threads
import std/times

type
  Processor = ref object of Agent

proc start(self: Processor) {.signal.}
proc update(self: Processor, lastTs: DateTime) {.signal.}

proc onUpdate(self: Processor, lastTs: DateTime) {.slot.} =
  echo "Starting from: ", lastTs
  discard  # do more processing
  emit self.update(now())

proc init(self: Sensor) {.slot.} =
  # Whenever `update` is emitted it's put onto the internal thread queue
  connectQueued(self, processor, onUpdate, update)
  emit self.update(loadLastTimeFromDb())

proc run() =
  let tProc = newSigilThread(); tProc.start()
  let processorProxy = Processor().moveToThread(tProc)
  threads.connect(processorProxy, start, processorProxy, init)
  emit processorProxy.start()

Actors, Airlocks, and Alliterations

Signals & slots bears similarities to a few great patterns:

  • Actors: Each Agent acts like an actor — single-threaded in spirit, stateful by design. Slots are the airlocks where messages meet mutation.

  • Event sourcing adjacent: Signals are domain events you can journal. Want replay? persist at emit time or inside a slot.

  • CQRS without contortions: Use signals for commands (do this) and slots to project read models (show that). Writes stay focused; reads stay fast.

Pragmatic patterns, not purist prescriptions. The architecture breathes as the system grows.

Threading Model, Made Mild

  • Agent and AgentProxy clarify ownership and routes.

  • moveToThread pins state to a thread; invariants stay local.

  • .slot. runs where the state lives — no surprise sharing.

  • .signal. + emit give you typed, async messaging.

  • threads.connect is the wiring harness you can reason about at a glance.

Use shared lockers only when necessary; let agents own their data and their destiny.

Production Playbook

  • Name domain events, not mechanics: readingReady beats evt42.

  • Co-locate connections: put the wiring where the components are born.

  • Idempotency everywhere: expect retries, reorders, and late arrivals.

  • Backpressure by boundaries: slow consumers don’t stall producers.

  • Fail fast, log well, restart small: treat agents like OTP processes.

Where It Shines (and When to Add More)

  • Shines for:

    • Multi-thread orchestration with crisp, typed boundaries.

    • Fanning out domain events to logs, views, and analytics.

    • Evolving systems incrementally: add listeners, swap implementations, keep calm.

  • Add layers when:

    • You need durable, replayable event logs — append events at emit/slot edges.

    • You want tree-structured supervision — introduce lifecycle helpers and restarts.

Caveats

Sigils support for threading is still young. Even during this project there’s a few features I’ve wanted. Items like forwarding a signal from one agent to another without needing to make a new slot, or connecting signal and slots directly between two remote threads, etc. Better timers is high on my list.

Sigils supports std/async but it’s a CPU hog due to limits in std/asyncdispatch API. Perhaps I’ll have to add Chronos support later.

Closing Notes from an OTP Alum

Years of Elixir taught me to cherish explicit boundaries, restartable units, and the quiet confidence of message passing. [Sigils](https://github.com/elcritch/sigils) for Nim lets me keep that mindset without fighting the language. Signals, slots, and steady systems: a friendly trio for IoT that stays fast, focused, and surprisingly fun.

If you want to push further, try:

  • A tiny event log for a handful of high-value signals.

  • A minimal supervision helper (start/monitor/restart) for agents.

  • Slot-level telemetry (queue depth, handler time) to catch slowdowns early.

Signals, slots, and serene systems — composable, comprehensible, and quietly quick.