Signal+|Insanely optimized script signal

Logo

An insanely fast, memory efficient, fully typed, featureful,
lightweight, open-source script signal module for Roblox.

Module buttonGithub button



:zap: Performance, efficiency, features.

Signal+ is for developers who strive for performance and efficiency.
And it still has all the features you’ll ever need!

It’s perhaps the best of its kind,
beating the top alternatives like
Good-, Lemon- and FastSignal.

An unprecedented level of optimization for
script signals, available for completely free!

:rocket: Get started!

💡 Tutorial

Quick setup

Let’s first get the module. There are two ways:

  • Get it from the creator store:
    • Click Get at the top of this post.
    • Click Get Model on the store.
    • Open the ToolBox in Roblox Studio.
    • Go to the Inventory tab.
    • Click on Signal+.
  • Get it from GitHub:
    • Click Git at the top of this post.
    • Go to Releases.
    • Download the latest .rbxm file.
    • Find the file in your file explorer.
    • Drag the file into Roblox Studio.

Basics

In any script, simply require the module. It will return a signal creation function.

local signalPlus = require(script.SignalPlus)

local mySignal = signalPlus() -- Creates a new signal.

Custom types

It’s extremely easy to define custom types for your signals.

You can provide your own parameters as shown in the screenshot.

local mySignal = signalPlus() :: signalPlus.Signal<PARAMETERS_HERE>

Not only will Connect and Once display your custom parameters, but so will Fire.

image

Documentation

signalPlus() → Signal

Signal:

  • :Connect(Function) → Connection
    • Connects the given function.
  • :Once(Function) → Connection
    • Connects the given function, but disconnects after first fire.
  • :Wait() → Arguments
    • Yields the current thread until the next fire.
  • :Fire()
    • Fires all callbacks and resumes all waiting threads.
  • :DisconnectAll()
    • Disconnects all connections.
  • :Destroy()
    • Disconnects all connections, and makes the signal unusable.

Connection:

  • :Disconnect()
    • Disconnects the connection.
      To reconnect, make a new connection.
  • .Connected → boolean

:bell: Don’t forget to stay up-to-date!

It’s highly recommended to have the latest version at all times. This ensures:

  • Newest features.
  • Best performance.
  • Little to no bugs.

You can view all releases (versions) and what they contain at the GitHub repository.
Major updates will be posted here, in the replies section, too.

:loudspeaker: Share your thoughts!

If you have any questions, feel free to leave them below.

But most importantly:

  • Report any bugs or mistakes you find for this asset and post!
  • Consider providing feedback to help me and the asset improve!

Like what you're seeing?
Check out more from me!

∙ ​ Text+|Custom fonts & fine-control
11 Likes

This looks cool, i’ll try using this whenever i use bindable events more (also, that logo looks really cool! nice job on it.)

1 Like

Rework (v2)

The module has been completely reworked.

Here are the main differences from the first version:

  • Huge optimizations; metatables approach, thread recycling, and more.
  • New DisconnectAll function for signals.
  • New Once function for signals.
  • New Connected property for connections.
  • Uses : syntax.

It just came out :sob:

Anyways, why should I use this over any other signal module?

What makes Signal+ better then GoodSignal or any other signal implementation? Is there any benchmark or peformance metrics to back up your claim that “Over 3x as fast as RBXScriptSignals (BindableEvents).” is true?

In my tests, Signal+ and GoodSignal have about the same speed, but Signal+ has slightly less memory usage (which is good).

Additionally, Signal+ has proper types, and a Destroy function.

Benchmarks aren’t any more trustworthy, as you could lie about them too.

:sweat_smile: After benchmarking it against other signal modules I realized that it needed optimizations. I then ended up adding even more features too.

I have a few comparisons with GoodSignal, that I replied to cosinewaves with:

Compared this with my own signal module SimpleSignal which i use for all my other modules:

There isn’t really much of a difference other than the fact your module has 0 autocomplete
:eyes:
image

And also that mine has a lower standard deviation (which means the times vary less)

Honestly I don’t see why this should be used over, say, GoodSignal (considering Signal+ source code is also alot more unreadable )

Heres SimpleSignal if you wanna do your own comparison, and the Benchmark module i used:

SimpleSignal:

--!optimize 2

-- SIMPLESIGNAL UTILITY MODULE
-- GoodSignal but simple
-- Author: athar_adv

local Types 				= require(script.Types)

export type RBXScriptConnection 	= Types.RBXScriptConnection
export type RBXScriptSignal<T...> = Types.RBXScriptSignal<T...>

local freeRunnerThread 		= nil

local function acquireRunnerThreadAndCallEventHandler(fn, ...)
	local acquiredRunnerThread = freeRunnerThread
	freeRunnerThread = nil
	fn(...)
	freeRunnerThread = acquiredRunnerThread
end

local function runEventHandlerInFreeThread()
	while true do
		acquireRunnerThreadAndCallEventHandler(coroutine.yield())
	end
end

local Connection = {}
local meta = {__index = Connection}

function Connection:Disconnect()
	local signal = self._signal
	
	signal._connections[self] = nil
	signal._connectionCount -= 1
	
	setmetatable(self, nil)
	table.clear(self)
	
	self.Connected = false
end

local function connection_new(signal, fn): RBXScriptConnection
	return setmetatable({
		_signal   = signal,
		_fn		  = fn,

		Connected = true,
	}, meta)
end

local Signal = {}
local meta = {__index = Signal}

function Signal:Destroy()
	self:DisconnectAll()
	
	setmetatable(self, nil)
	table.clear(self)
end

function Signal:Connect(fn)
	local connection = connection_new(self, fn)
	self._connections[connection] = true
	
	self._connectionCount += 1
	
	return connection
end

function Signal:Once(fn)
	local connection
	connection = self:Connect(function(...)
		connection:Disconnect()
		
		fn(...)
	end)
	
	return connection
end

function Signal:DisconnectAll()
	-- It's that shrimple
	for connection in self._connections do
		connection:Disconnect()
	end
end

function Signal:Fire(...)
	for connection in self._connections do
		if not freeRunnerThread then
			freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)

			coroutine.resume(freeRunnerThread)
		end
		task.spawn(freeRunnerThread, connection._fn, ...)
	end
end

function Signal:Wait()
	local running = coroutine.running()
	local connection
	
	connection = self:Connect(function(...)
		connection:Disconnect()
		
		if coroutine.status(running) ~= "suspended" then
			return
		end
		
		task.spawn(running, ...)
	end)
	
	return coroutine.yield()
end

-- Create a new <code>RBXScriptSignal</code> object.
local function signal_new<T...>(): RBXScriptSignal<T...>
	return setmetatable({
		_connections = {},
		_connectionCount = 0,
	}, meta)
end

return {
	new = signal_new
}

Benchmark:

local function table_sum(t)
	local sum = 0
	
	for _, v in t do
		sum += v
	end
	
	return sum
end

local function table_maxv(t)
	local max = -math.huge
	
	for _, v in t do
		if v > max then
			max = v
		end
	end
	
	return max
end

local function table_minv(t)
	local min = math.huge
	
	for _, v in t do
		if v < min then
			min = v
		end
	end
	
	return min
end

local function table_mode(t)
	local counts = {}
	local maxCount = 0
	local modeValue = 0

	for _, value in t do
		counts[value] = (counts[value] or 0) + 1
		if counts[value] > maxCount then
			maxCount = counts[value]
			modeValue = value
		end
	end

	return modeValue -- Return only the first most popular mode
end

local function dround(n: number, r: number)
	local p = 10^r
	
	return math.floor(n * p) / p
end

-- Returns the time it took <code>testfn</code> to run after the initial call.
local function benchmark_start<A..., R...>(testfn: (A...) -> R..., ...: A...): (number, R...)
	local start = os.clock()
	
	local result = {testfn(...)}
	
	return os.clock() - start, unpack(result)
end

-- Calls <code>testfn</code> <code>repeats</code> times and returns <code>avtype</code> elapsed time (truncated to <code>decimals</code> decimal places).
local function benchmark_findavg(repeats: number, avtype: "mean"|"max"|"min"|"median"|"mode"|"total", decimals: number, benchInterval: number, testfn: () -> ()): (number, {number})
	local times = {}
	local total = 0
	
	for i = 1, repeats do
		local t = benchmark_start(testfn)
		total += t
		
		table.insert(times, t)
		if i % benchInterval == 0 then
			task.wait()
		end
	end
	
	table.sort(times)
	if avtype == "max" then
		return dround(table_maxv(times), decimals), times
	elseif avtype == "min" then
		return dround(table_minv(times), decimals), times
	elseif avtype == "median" then
		return dround(times[#times//2], decimals), times
	elseif avtype == "mean" then
		return dround(table_sum(times)/repeats, decimals), times
	elseif avtype == "mode" then
		return dround(table_mode(times), decimals), times
	elseif avtype == "total" then
		return dround(total, decimals), times
	else
		error(`Unknown avtype {avtype}`)
	end
end

local function stdDeviation(times, mean)
	local sum = 0
	for _, v in times do
		sum += (v - mean)^2
	end
	return math.sqrt(sum/#times)
end

return {
	findavg = benchmark_findavg,
	start = benchmark_start,
	stdDeviation = stdDeviation
}

Also,

Not very convincing :V

4 Likes

I genuinely do not know how I changed that accidentally. Thanks for pointing it out!

I’m guessing that you’re talking about the number key names. It definitely makes it way less readable, but is a performance and memory improvement. I usually don’t do that, and I usually comment my open-source stuff very thoroughly too. This module was not really meant to be read or changed, though, as it’s just a small module with small room to be improved.

I apologize. I seem to have accidentally send the message before completing it. :sweat_smile:

Sorry that it seems rushed — because it was. Mistakes happen.


Thank you for taking the time to investigate and reply. Let me know if there’s anything else!

1 Like

:bell: Version 2.1.0

Changes & improvements:

  • Now properly cleans up linked list with DisconnectAll.
  • Setup a free thread on initialize.
  • Switch from a doubly linked list to a singly linked.
  • Additional, minor performance improvements.
  • Minor comment changes & additions.
  • Small bug fixes.

:bell: Version 2.2.0

Changes & improvements:

  • Thread recycling now stores multiple threads, and dynamically adjusts to the signals’ need.
  • When a reusable thread is created, it is now immediately used.
  • Will no longer error if you attempt to disconnect a signal that isn’t connected.
  • Switched a task.spawn() to coroutine.resume.
  • Cached coroutine.create and table.insert.
  • Minor comment changes and additions.
  • Renamed a few functions.

this is just a worse version of goodsignal

please elaborate on why you think it’s worse

I benchmarked Connect, Once, and Fire:

Connect:

Once:

Fire:

the old version (v2.1.0) is about the same speed, but the new update (v2.2.0) makes it a lot faster

the only instance I found so far where GoodSignal is faster is when firing an event with no connections attached to it:

code (used with Benchmarker):
local signals = game.ReplicatedStorage.Modules -- (directory)

local signalPlus = require(signals.SignalPlus)
local goodSignal = require(signals.GoodSignal)
local oldSignalPlus = require(signals.OldSignalPlus) -- v2.1.0

local event1 = signalPlus()
local event2 = goodSignal.new()
local event3 = oldSignalPlus()
local event4 = Instance.new("BindableEvent")

-- stays here when testing :Fire() but moves to loops when testing :Connect() or :Once()
local signal1 = event1:Connect(function(var) end)
local signal2 = event2:Connect(function(var) end)
local signal3 = event3:Connect(function(var) end)
local signal4 = event4.Event:Connect(function(var) end)

return {
    ParameterGenerator = function()
        return
    end,

    Functions = {
        ["SignalPlus"] = function(Profiler)
            for i = 1, 250 do
                event1:Fire(8)
            end
        end,

        ["GoodSignal"] = function(Profiler)
            for i = 1, 250 do
                event2:Fire(8)
            end
        end,

        ["OldSignalPlus"] = function(Profiler)
            for i = 1, 250 do
                event3:Fire(8)
            end
        end,

        ["RBXScriptSignal"] = function(Profiler)
            for i = 1, 250 do
                event4:Fire(8)
            end
        end,
    },
}
if there's anything I did wrong here let me know

this module is great, thanks for making and sharing it

3 Likes

Thanks a lot for the kind words, and taking the time to test it.

1 Like

:bell: Version 2.3.0

Changes & improvements:

  • Now fully removes all data with DisconnectAll and Destroy.
  • All functions now have descriptions/documentation.
  • Now uses task library to create and resume threads, as to ensure proper error handling and compatabillity.
    • Had to use coroutine.running on callback completion due to task.spawn being a combined function, not allowing me to store the thread before starting it.
    • Saves another variable declaration in the Fire function when a thread is not available.
  • Changed logic in Fire function to avoid an unnecessary nil variable declaration.
  • Avoided another variable declaration in the Fire function when a thread is available.
  • No longer caches functions — found out it didn’t actually help to do so.
  • Minor comment changes and additions.
  • Version number is now listed at the top.

I saw this and was wondering is this Typed? Does it support custom Typed annotations for instance Typed parameters like so: signalA:Connect(function(Karen: Person) end)) or something like it? Typed parameters, I mean. I saw other Signal resources and they usually do not provide this as a default. When I Fire() with a Type, does Connect detect the Types of variable so I can get Type inference when I use :Connect()?

No, this is not a feature of Signal+ at the moment.
I weren’t actually aware that this was possible, but I’ll see if I can figure it out!

It’s thoroughly and cleanly typed, even having descriptions/documentation for all of the functions, unlike other signal modules.

:bell: Version 2.4.0

New features:

  • You can now easily define custom signal types. Check tutorial at the top to learn more.

Changes & improvements:

  • Removed Connected property from Wait connections.
  • Made connection variable assignment direct inside of Connect function.
  • Switched [4] (callback) and Connected around in connections — for readability.
  • Changed description for the module.
  • Shortened DevForum link.
1 Like

Wow, lovely!

Looks like there is some typing now for Signal’s :Connect() and :Fire(). I don’t even think there is a Signal module out there right now that has this feature. I will look into using this. Amazing, thanks!

1 Like

:bell: Version 2.5.0

Changes & improvements:

  • Fixed Once not providing the passed arguments to the callback.
  • Wait now returns the passed arguments.
    • Types were updated accordingly.
  • Fixed a weird thread recycling issue.
  • Minor comment changes and additions.