Low-Performance, custom RBXScriptSignal module

Hello all.
I’ve recently been upgrading most of my modules in-game, and I’ve realized that I have been using BindableEvents for my custom event module. This in itself isn’t really anything bad, but I figured I could optimize this by using a custom implementation that has no properties such as Name, no external events such as AncestryChanged etc - basically, have a connection class that is as bare-bone as it gets.
So, here we are!
This module is pretty much exactly the same as your default RBXScriptSignal - I’ll go over the properties in a bit. This does not have additional functionality such as a timeout property for :Wait(), you can implement that yourself easily by just wrapping this module.

Documentation

The documentation for this is exactly the same as Roblox’s RBXScriptSignal, including it’s return RBXScriptConnection.
The only difference is that you construct the RBXScriptSignal class with Connection.new() - which returns the custom script signal.

Example script:

local ScriptSignal = Connection.new()
local ScriptConnection = ScriptSignal.Invoked:Connect(print)
ScriptSignal:Invoke('Hello', 'World!')

delay(1, function()
	ScriptSignal:Invoke('I\'ve waited 1 second for this message!')
end)

print(ScriptSignal.Invoked:Wait()) --> I've waited 1 second for this message!

print(ScriptConnection.Connected) --> true
ScriptConnection:Disconnect()
print(ScriptConnection.Connected) --> false
ScriptSignal:Invoke('This wont be invoked, because the connection was disconnected.') --> nothing
Source
local Connection = {}

local ConnectionBase = {}
local InvokedBase = {}
ConnectionBase.__index = ConnectionBase
InvokedBase.__index = InvokedBase

function ConnectionBase:Invoke(...)
	for _, Data in next, self.Listeners do
		coroutine.wrap(Data.Callback)(...)
	end
	for i,v in next, self.Yielded do
		self.Yielded[i] = nil
		coroutine.resume(v, ...)
	end
end
function InvokedBase:Connect(f)
	local Connection = self.INTERNAL_Reference
	local Timestamp = os.clock()
	local Data = {
		Disconnect = function(self)
			self.Connected = false
			Connection.Listeners[Timestamp] = nil
		end,
		Callback = f,
		Connected = true
	}
	
	Connection.Listeners[Timestamp] = Data
	return Data
end
function InvokedBase:Wait()
	local Connection = self.INTERNAL_Reference
	Connection.Yielded[#Connection.Yielded + 1] = coroutine.running()
	return coroutine.yield()
end

function Connection.new()
	local Meta = setmetatable({
		Listeners = {},
		Invoked = setmetatable({INTERNAL_Reference = false}, InvokedBase),
		Yielded = {}
	}, ConnectionBase)
	Meta.Invoked.INTERNAL_Reference = Meta
	
	return Meta
end

return Connection

Roblox asset (toolbox): https://www.roblox.com/library/5683733180/Connection

This module is actually a bit slower when creating a new connection, but when connecting callbacks to it etc, the difference is more visible.
If you find any bugs with this, make sure to report them by contacting me or posting them on this thread!

4 Likes

Metatables shouldn’t be avoided for an implementation like this - if you would benchmark this class with and without metatables, you’d get almost 50% faster instantiation speed with metatables than without while only <1% slower speed for invoking with metatables.

It’s outside my realm of knowledge, but metatables might decrease your memory footprint for an implementation like this, though it would be negligible.

So the main win for metatables is much much faster instantiation for classes and a coding style that’s closer to Roblox engineer internal lua samples.

3 Likes

I originally wanted to do this, but it was a bit annoying for me to rewrite the code due to the fact that Table.NestedTable:Foo()'s self would point to NestedTable, with no references to Table.
I made this in about 5 mins, but is this what you mean?:

local Connection = {}

local ConnectionBase = {}
local InvokedBase = {}
ConnectionBase.__index = ConnectionBase
InvokedBase.__index = InvokedBase

function ConnectionBase:Invoke(...)
	for _, Data in next, self.Listeners do
		coroutine.wrap(Data.Callback)(...)
	end
	for i,v in next, self.Yielded do
		self.Yielded[i] = nil
		coroutine.resume(v, ...)
	end
end
function InvokedBase:Connect(f)
	local Connection = self.INTERNAL_Reference
	local Timestamp = os.clock()
	local Data = {
		Disconnect = function(self)
			self.Connected = false
			Connection.Listeners[Timestamp] = nil
		end,
		Callback = f,
		Connected = true
	}
	
	Connection.Listeners[Timestamp] = Data
	return Data
end
function InvokedBase:Wait()
	local Connection = self.INTERNAL_Reference
	Connection.Yielded[#Connection.Yielded + 1] = coroutine.running()
	return coroutine.yield()
end

function Connection.new()
	local Meta = setmetatable({
		Listeners = {},
		Invoked = InvokedBase,
		Yielded = {}
	}, ConnectionBase)
	Meta.Invoked.INTERNAL_Reference = Meta
	
	return Meta
end

EDIT:
And yes, you are right, this is more efficient because it doesn’t need to create new tables, functions etc for every connection. By doing

local Table = {}
Table.__index = Table

local Clone = setmetatable({}, Table)

rather than

local Table = {}
local Clone = setmetatable({}, {__index = Table})

you also optimize a bit by removing the unnecessary extra table created for the second parameter of setmetatable.

Running a benchmark I got these results when comparing that test script and the current script:

[+] Benchmark Old Connection.new ran! Time taken: 0.12993729999289

[+] Benchmark New Connection.new ran! Time taken: 0.087050900328904

I created one of these myself, but never implemented a :Wait() method, which I really like about this. I think you should show a comparison of benchmarks for this vs. using BindableEvents.

I used this benchmark previously:

local function Benchmark(Name, f)
    local Start = os.clock()

    if xpcall(f, function() 
            print('[-] Benchmark', Name, 'failed! Time taken:', os.clock() - Start) 
    end, warn) then
        print('[+] Benchmark', Name, 'ran! Time taken:', os.clock() - Start)
        return os.clock() - Start
    end
end
local function LoopBenchmark(Name, f, NumIters)
	Benchmark(Name, function()
		for i = 1, NumIters or 10 ^ 5 do
			f()
		end
	end)
end

local Connection = {}

local ConnectionBase = {}
local InvokedBase = {}
ConnectionBase.__index = ConnectionBase
InvokedBase.__index = InvokedBase

function ConnectionBase:Invoke(...)
	for _, Data in next, self.Listeners do
		coroutine.wrap(Data.Callback)(...)
	end
	for i,v in next, self.Yielded do
		self.Yielded[i] = nil
		coroutine.resume(v, ...)
	end
end
function InvokedBase:Connect(f)
	local Connection = self.INTERNAL_Reference
	local Timestamp = os.clock()
	local Data = {
		Disconnect = function(self)
			self.Connected = false
			Connection.Listeners[Timestamp] = nil
		end,
		Callback = f,
		Connected = true
	}
	
	Connection.Listeners[Timestamp] = Data
	return Data
end
function InvokedBase:Wait()
	local Connection = self.INTERNAL_Reference
	Connection.Yielded[#Connection.Yielded + 1] = coroutine.running()
	return coroutine.yield()
end

function Connection.new()
	local Meta = setmetatable({
		Listeners = {},
		Invoked = InvokedBase,
		Yielded = {}
	}, ConnectionBase)
	Meta.Invoked.INTERNAL_Reference = Meta
	
	return Meta
end
LoopBenchmark('BindableEvent', function()
	Instance.new'BindableEvent'
end, 10 ^ 5)
LoopBenchmark('Connection.new', function()
	Connection.new()
end, 10 ^ 5)

Result:

[+] Benchmark BindableEvent ran! Time taken: 0.068671999964863
[+] Benchmark Connection.new ran! Time taken: 0.049620099831372

The result is more favorable towards the benchmark that ran first, but either way Connection.new wins.
If you replace

LoopBenchmark('Connection.new', function()
	Connection.new()
end, 10 ^ 5)
LoopBenchmark('BindableEvent', function()
	Instance.new'BindableEvent'
end, 10 ^ 5)

with

LoopBenchmark('Connection.new', function()
	Connection.new().Invoked:Connect(print)
end, 10 ^ 5)
LoopBenchmark('BindableEvent', function()
	Instance.new'BindableEvent'.Event:Connect(print)
end, 10 ^ 5)

, the result would be:

[+] Benchmark Connection.new ran! Time taken: 0.072952100075781
[+] Benchmark BindableEvent ran! Time taken: 0.37662040023133

as you can see, this module is much faster when it comes to thousands of callbacks.

BTW the Destroy function is quite important, forgot to add that :sweat_smile:

1 Like

Funny because I implemented this just today before I read your post like you. Mine has :Wait(TimeOut).

The reason I needed to implement a custom one is bindable events have .Event:Connect() which sounds weird. I just want mine to be Signal:Listen()

So yah that’s the story.

[Update 1.0.1]

  • Fixed a rare occurrence which caused some weird issues

What was the rare occurrence, it would be helpful to know