Signal+|Super fast & featureful script signal

Logo

A super fast, memory efficient, open-source script signal module
for Roblox, with all features of RBXScriptSignals and even more.

Module buttonGithub button



:thinking: Why use Signal+?

It’s simply better.

  • Over 3x as fast as RBXScriptSignals (BindableEvents).
  • Memory efficient.
  • Featureful.

: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 GameLoader.
  • 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.

How to use it

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

local signalPlus = require(script.SignalPlus)

local mySignal = signalPlus() -- Will create a new signal.

Documentation

signalPlus() → Signal

Signal:

  • :Connect(function) → Connection
  • :Once(function) → Connection
    • Connects a function, but disconnects it after first fire.
  • :Wait()
    • Yields the current thread until the next fire.
  • :Fire()
  • :DisconnectAll()
  • :Destroy()
    • Makes the signal unusable.

Connection:

  • :Disconnect()
  • .Connected → boolean

: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
2 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

1 Like

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!

2 Likes

(post deleted by author)

: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.