Here I provide a comparison of 4 different Signal class implementations, which each have various advantages and disadvantages, as well as presenting what I think is the optimal Signal class implementation to use going forwards.
TL;DR: Use this GoodSignal implementation (CODE LINK) unless you have a good reason not to!
Below I provide links to a Github gist of the code for each implementation, but if you want to download the full code for all of them + Unit Tests + Benchmark code you can get a Model containing all of that here: Signal class comparison with Tests & Benchmark - Roblox
API
All 4 variants share the following API:
local Signal = require(--[[the module]])
local sig = Signal.new()
local connection = sig:Connect(function(...)
print(...)
end)
sig:Fire(nil, "test1") --> Invokes the handler, printing `nil test1`
connection:Disconnect()
sig:DisconnectAll() --> If you need to disconnect all
task.spawn(function()
print(sig:Wait())
end)
sig:Fire(nil, "test2") --> Resumes the waiter, printing `nil test2`
RobloxSignal Implementation (link)
The most obvious implementation of a Signal class is this one: A simple wrapper around a BindableEvent
. There’s some detail to correctly implementing this, because by default a BindableEvent deep-copies any tables that you pass to it, which you probably did not want. To work around this, you have to pass the arguments through some other mechanism and “pick them back up” in the event handler.
This is further complicated if you want the implementation to still work correctly with the new SignalBehavior = Deferred
switch, which the implementation linked here actually does. Doing this requires another trick based on the fact that event handlers are called in reverse order of connection, but doesn’t add much additional cost.
SimpleSignal Implementation (link)
What comes next after an implementation directly on top of a BindableEvent
? Implementing one in pure Lua of course! The SimpleSignal implementation here presents implementing a pure Lua Signal class which respects all of the behaviors that a normal RBXScriptSignal has, while using the most straightforward implementation possible.
What do we mean “respects all of the behaviors” here? Specifically I mean that:
- It is “yield-safe” – you can yield inside of the event handlers without blocking progress of the thread which called
:Fire(...)
. - Event handlers are run in reverse order of connection.
- Connecting a new event handler in the middle of processing an existing event handler will not cause that new event handler to be run until subsequent calls to
:Fire(...)
. - An event handler can
:Disconnect()
itself without causing issues. - A
:Wait()
method is provided.
Previously full correctness here was not possible, but the very recently added task.spawn
API makes this Signal implementation possible.
FastSignal Implementation (link)
Next, we can go all the way in the other direction with FastSignal
, which sacrifices all that correctness that SimpleSignal
had in order to be as performant as possible while still implementing the Signal
class patterns. Specifically, it sacrifices correctness in that:
- Yielding an an event handler will cause the thread that called
:Fire()
to yield, so you need to explicitly calltask.spawn
in an event handler when it needs to make yielding function calls. - Connecting new event handlers in an event handler will cause unpredictable and problematic behavior (may cause existing event handlers to not be called correctly), though an event handler can still disconnect specifically itself without causing issues.
GoodSignal Implementation (link)
Where can we go from here? Well, next we have the main event, I present the GoodSignal
implementation, which provides as much of the performance benefit of FastSignal
as possible without sacrificing the correctness of SimpleSignal
. (It manages to do this by using a much more complex implementation than SimpleSignal
).
The main benefit of GoodSignal
over SimpleSignal
is that it has 2x or better performance in most scenarios, however it has also a couple of behavior improvements over the SimpleSignal
implementation:
-
:Fire
never causes any memory allocation unless you yield in an event handler, and:Connect
only costs exactly one memory allocation. - the
GoodSignal
implementation will handle all cases of:Fire
ing and:Disconnect
ing in an event handler correctly. (TheSimpleSignal
implementation may fail when you disconnect other handler functions from within a handler function).
An Aside on Memory Leaks
When using one of the pure Lua implementions of Signal, you don’t have to worry at all about extra memory leaks. However, when using an implementation backed by a BindableEvent
such as RobloxSignal
, you have to be very careful to actually disconnect all of the connections you make, otherwise you will run into the following issue: PSA: Connections can memory leak Instances!
For example, the following will permanently leak memory (but would not if you used one of the pure Lua implementations):
do
local sig = RobloxSignal.new()
sig:Connect(function()
print(sig)
end)
end
Detailed Performance Comparison
Here is a comparison of the performance of the implementations above. Hopefully the names of the rows are self-explanitory:
| FastSignal | GoodSignal | SimpleSignal | RobloxSignal |
--------------------------------------------------------------------------------
CreateAndFire | 0.6ÎĽs | 1.2ÎĽs | 2.4ÎĽs | 18.5ÎĽs |
ConnectAndDisconnect | 0.3ÎĽs | 0.3ÎĽs | 0.4ÎĽs | 1.8ÎĽs |
FireWithNoConnections | 0.1ÎĽs | 0.0ÎĽs | 0.0ÎĽs | 2.2ÎĽs |
Fire | 0.2ÎĽs | 0.8ÎĽs | 3.8ÎĽs | 3.2ÎĽs |
FireManyArguments | 0.2ÎĽs | 0.8ÎĽs | 2.0ÎĽs | 3.5ÎĽs |
FireManyHandlers | 0.2ÎĽs | 4.4ÎĽs | 15.2ÎĽs | 6.0ÎĽs |
FireYieldingHandler | N/A | 5.1ÎĽs | 5.1ÎĽs | 6.1ÎĽs |
WaitOnEvent | 3.1ÎĽs | 3.5ÎĽs | 5.1ÎĽs | 5.6ÎĽs |
What Do Those Performance Results Mean?
-
GoodSignal
performs better thanSimpleSignal
in almost every regard. The only case where it would ever make sense to useSimpleSignal
is if you need to edit the Signal class adding more functionality, and aren’t comfortable trying to edit the significantly more complicatedGoodSignal
implementation. -
FastSignal
wins by a lot on performance… but it comes with the cost of having potential bugs if you change something from a yielding call to a non-yielding call without thinking about it or end up accidentally disconnecting handlers in a scenario you didn’t think of ahead of time and breaking the behavior. If you really need the perf it makes sense to useFastSignal
, however you should keep in mind that as soon as you start writing non-trivial code inside of an event handler, the cost of the code in the event handler will dwarf the raw cost of firing the event handler, and make the perf cost of the Signal implementation not very relevant. -
There’s almost no reason to use
RobloxSignal
now thattask.spawn
exists allowing better pure Lua signal implementations: It has worse perf in every regard and introduces memory leak potential. The only reason might be if you’re really paranoid about wanting exactly the same behavior as RBXScriptSignal no matter what happens.
A note on Array vs Dict based Objects
Some people have suggested using an array to store the fields of the Signal / Connections objects internally instead of a dict. This is not a good idea. Here’s a performance comparison between using an array vs a dict (GoodSignal) internally. The dict wins:
This may be somewhat surprising to people who have seen Lua optimization advice. That’s because the only applies to Roblox’ Luau VM! In vanilla Lua using an array internally rather than a dict with fields would be almost always be faster, but the Luau VM has some optimizations which make field access very fast, even faster than array access in some conditions like these.
Updates
-
Update 6/19/2022: Added
Once
function and allow the ability to callDisconnect
more than once. -
Update 6/19/2022: Fixed the leak of the first set of arguments passed to Fire. This has a small performance penalty in some narrow edge cases (If you both have a lot of event handlers and those event handlers also yield), but shouldn’t otherwise change the performance characteristics.
Bonus
A post from all the way back in 2014 when I posted one of the first public Lua signal implementations on Roblox. (Spoiler: It’s not as good as GoodSignal )
Closing
The task
library is awesome! Not much more to add than that.