SkillAgentSearch skills...

Signals

General purpose modern C++ Signal-Slot providing ease of use, flexibility and extremely high performance aiming to replace traditional interfaces in real-time applications

Install / Use

/learn @TheWisp/Signals
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Signals

For design choices see this blog post

Why Yet Another Signal-Slot Library?

This library is optimized for video games (and probably other low-latency applications as well). Interestingly, even though the observer pattern is generally useful, it has never been standardized in C++, which leads to the never-ending attempts at improvements by curious people. Many signal-slot libraries do not focus on performance, e.g. boost::signals2 invocation can be 90x more expensive than a simple function call.

There are many similar libraries - such as jl_signal, nuclex signal/slots and several dozens more. My work is based on a previous research which focused on the syntax and performance improvements brought by a C++17 feature - template<auto>. This library is a combination of modern C++ exploration, system programming and data-structure design. It aims to become feature-complete like boost::signals, yet extremely light-weight - both run time and memory footprint - in order to replace interface or std::function based callbacks.

signal emission is faster than virtual function calls. Compared to virtual calls, signal calls only take between 22% and 77% of the time, depending on the number and the level of randomness of classes and objects.

Design Choices

Direct (Blocking) Calls

In game systems, the logic flow often consists of many fast and weakly ordered function calls. Asynchronous calls are rather the exceptions than the default. Thread-safe calls add additional costs, thus should be the exceptions rather than the default.

Optimized for Emission

Latency is the bottleneck.

O(1) Connection and Disconnection

In a dynamic world, slots (receivers) are often frequently created and destroyed. A linear search removal algorithm can easily become the performance bottleneck, especially when a large number of slots all disconnect at the same time. Removing by swapping with the end mitigates the problem, but the overall time spent removing N slots with a linear search would still be O(N^2). In this library, a slot is removed by marking its index unused, which then gets skipped and cleaned up in the next emission. Benchmarks have shown that the overhead is dominated by memory accessing (cache misses), rather than checking for null (pipeline stalling).

Safe Recursion and Modification While Iterating

Just like direct function calls, recursions can naturally emerge from complex and dynamic behaviors. Furthermore, the signals and slots may be side-effected by their own results!

Usage

Simply include the single header, signals.hpp. A C++17 compliant compiler is necessary. Give it a try on Godbolt!

Basics

The following example demonstrates how to define, connect and emit a signal.

// A function callback
void on_update(float delta) { }

// A member function callback
class my_class{
  void on_update(float delta) { }
};

int main()
{
  // A signal specifying its signature in the template parameter
  fteng::signal<void(float delta)> update;

  // Connects to a function callback
  update.connect(on_update);

  // Connects to an object's member function
  my_class* my_obj = new my_class;
  update.connect<&my_class::on_update>(my_obj);

  // Connects to a lambda callback
  update.connect([](float delta) { });

  // Connects to a generic lambda callback
  update.connect([](auto&&... as) { });

  // Emits the signal
  update(3.14f);

  delete my_obj;
}

Signals automatically disconnect from their slots (receivers) upon destruction.

class button{
  public: fteng::signal<void(button& btn, bool down)> pressed;
};

class my_special_frame {
  std::vector<button> buttons;

  my_special_frame() {
    buttons.emplace_back();
    buttons.back().pressed.connect<&my_special_frame::on_button_pressed>(this);
  }

  void on_button_pressed(button& btn, bool down) {
    /* ... */
  }
};

Connection Management

Slots don't automatically disconnect from the signal when they go out of scope. This is due to the non-intrusive design and the "pay only for what you use" principle.

To help automatically disconnect the slot, the connect() method returns an unmanaged (raw) connection, which may be converted to a fteng::connection representing the unique ownership. It is recommended to save this connection into the slot's structure in order to automatically disconnect from the signal in an RAII fashion.

The following design would automatically disconnect the object from the signal when it is deleted.

class game { /*...*/ };
fteng::signal<void(const game& instance)> game_created;

class subsystem
{
  //Connects a signal with a lambda capturing 'this'
  fteng::connection on_game_created = game_created.connect([this](const game& instance)
  {
    std::cout << "Game is created.\n";
  });
};

int main()
{
  subsystem* sys1 = new subsystem;

  game game_instance;
  game_created(game_instance); // Notifies each subsystem

  delete sys1; // Automatically disconnects from the signal

  game game_instance2;
  game_created(game_instance2); // Notifies each subsystem. Should not crash.
}

Alternatively, you may use a member function for callback.

class subsystem
{
  //Connects a signal with a member function
  fteng::connection on_game_created = game_created.connect<&subsystem::on_game_created_method>(this);

  void on_game_created_method(const game& instance)
  {
    std::cout << "Game is created.\n";
  };
};

A few important notes about the connection object:

  • connection is default-constructible, moveable but not copyable.
  • Destroying the connection object would automatically disconnect the associated signal and slot.
  • If you know the slot outlives the signal, it's fine to connect them without saving the connection object. There won't be any memory leak.
  • If the signal can outlive the slots, store the connection in the slot's structure so that it disconnects the signal automatically.

Connecting / Disconnecting Slots from Callback

Sometimes during the callback, we might want to disconnect the slot from the signal. There are also cases where we want to create or destroy other objects, who just happen to observe the same signal that triggered the callback. The following example demonstrates how these usage are supported by the library.

fteng::signal<void(entity eid)> entity_created;

class A
{
  std::unique_ptr<B> b;

  fteng::connection on_entity_created = entity_created.connect([this](entity eid)
  {
    // Creates a 'B' which also connects to the signal.
    // It's fine to connect more objects to the signal during the callback, 
    // With a caveat that they won't be notified this time (but next time).
    b = std::make_unique<B>(); 
  });
};

class B
{
  // C is some class that also listens to entity_created
  std::vector<C*> cs;

  fteng::connection on_entity_created = entity_created.connect([this](entity eid)
  {
    /* ... */
    if (eid == some_known_eid){

      // Imagine this operation automatically disconnects all C objects from entity_created
      // It's fine to disconnect any object from the signal during the callback, no matter if it's
      // the object being called back or any other object. The disconnected objects are skipped over.
      for (C* c : cs) 
        delete c;

      // Also fine
      on_entity_created.disconnect();

      // Also fine - Don't do this in modern C++ though ...
      delete this;
    }
  });
};

Blocking a Connection

A connection can be temporarily disabled with block(), so that it won't be notified by the signal until it has been unblock() ed again.

fteng::signal<void()> sig;

class Foo
{
  fteng::connection conn = sig.connect([this](){
    conn.block();
    sig(); // Now this won't cause an infinite recursion.
    conn.unblock();
  });
};

Performance Benchmark

With a little help of template metaprogramming, I've generated classes of different virtual tables (even though small vtable with just 2 methods).

The bottleneck of emission is the cache loading, therefore it makes sense to test different scenarios depending on object memory addresses and class vtable addresses. If the objects being called are nicely aligned in the memory, we could expect a speed-up from the cache coherence. Similarly, if all objects are from the same class, their virtual methods would be the same and therefore a speed-up. In the benchmark, I've tested 4 scenarios where each creates 100,000 objects from at most 100 different classes:

  • SAME class, SEQUENTIAL objects: all objects are instances of the same class, and are contiguous in the memory.
  • SAME class, RANDOM objects: all objects are instancess of the same class, but are randomly scattered in the memory.
  • RANDOM class, SEQUENTIAL objects: each object's class is one of 100 possible classes, but they are contiguous in the memory.
  • RANDOM class, RANDOM objects: each object's class is one of 100 possible classes, and they are randomly scattered in the memory.

Xeon E3-1275 V2 @ 3.90 GHz 16.0 GB RAM

| | Signal (member func) | Signal (lambda) | Virtual Call | Sig(mem func) % of Virtual | Sig(lambda) % of Virtual | | -------------------------------- | -------------------- | --------------- | ------------ | -------------------------- | ------------------------ | | SAME class, SEQUENTIAL objects | 303 us | 335 us | 482 us | 62% | 69% | | SAME class, RANDOM objects | 307 us | 354 us | 1336 us | 22%

View on GitHub
GitHub Stars232
CategoryDevelopment
Updated1mo ago
Forks29

Languages

C++

Security Score

100/100

Audited on Feb 24, 2026

No findings