Coroutine Signal module

--!strict
-- // By StrategicPlayZ \\ --
-- Signal module


type ModuleType = {
	new: () -> signalType,
	Connect: (handler: (any) -> (any)) -> connectionType,
	Disconnect: () -> (nil),
	Wait: () -> any,
	Fire: (any) -> nil,
	Destroy: () -> nil
}


local Signal = {}
export type connectionType = {
	_signal: signalType,
	_handler: any,
	_connectionIndex: number,
	Disconnect: any
}
export type signalType = {
	_connections: {[number]: connectionType},
	Fire: any,
	Connect: any,
	Wait: any,
	Destroy: any
}

function Signal.new(): signalType
	local newSignal: signalType = {
		_connections = {},
		Fire = Signal.Fire,
		Connect = Signal.Connect,
		Wait = Signal.Wait,
		Destroy = Signal.Destroy
	}
	
	return newSignal
end

function Signal:Connect(handler: (any) -> (any)): connectionType
	local self: signalType = self
	
	local numConnections: number = #self._connections
	
	local newConnection: connectionType = {
		_signal = self,
		_handler = handler,
		_connectionIndex = (numConnections + 1),
		Disconnect = Signal.Disconnect
	}
	
	table.insert(self._connections, (numConnections + 1), newConnection)
	
	return newConnection
end

function Signal:Disconnect(): nil
	local self: connectionType = self
	
	local currentSignal: signalType = self._signal
	
	currentSignal._connections[self._connectionIndex] = nil
	
	table.clear(self)
	self = nil :: any
	
	return nil
end

function Signal:Wait(): any
	local self: signalType = self
	
	local thread: thread = coroutine.running()
	
	local c: connectionType; c = self:Connect(function(...)
		c:Disconnect()
		coroutine.resume(thread, ...)
	end)
	
	return coroutine.yield()
end

function Signal:Fire(...: any): nil
	local self: signalType = self
	
	for index: number, connection: connectionType in pairs(self._connections) do
		connection._handler(...)
	end
	
	return nil
end

function Signal:Destroy(): nil
	local self: signalType = self
	
	for index: number, connection: connectionType in pairs(self._connections) do
		connection:Disconnect()
	end
	
	table.clear(self)
	self = nil :: any
	
	return nil
end

return Signal

I made a Signal module using coroutines. It is very efficient according to my benchmarks. What do you think?

Yes I know that this does not allow yielding in connections, but that can be easily fixed like this:

Signal:Connect(coroutine.wrap(function()

end))
4 Likes

I’m not going to lie. This is beautiful. I have yet to test it but I will do so shortly, and edit my post to convey my results. One thing though, is that you could always use that wonderful optimization of pre-defining globals like table.insert/clear/pairs/etcetera
(I refuse to call this a micro optimization since it can considerably boost performance, under performance intensive workloads, however as SilentsReplacement has stated, this is rather redundant for normal use. <3 LuaU)
Results posted below, I was genuinely a bit too disappointed to remember to edit this, and I had far too much to say. Not a bad module though if used as intended.

2 Likes

coroutine.resume doesn’t have any concept of passing in thread continuations. Roblox is currently adding support for this, It’s still better to yield for approximately a frame before returning the results so that the continuations are passed so no one encounters any bugs when yielding through the Wait method.

function Signal:Wait(): any
	local self: signalType = self
	
	local thread: thread = coroutine.running()
	
	local c: connectionType; c = self:Connect(function(...)
		c:Disconnect()
		coroutine.resume(thread, ...)
	end)

    game:GetService("RunService").Heartbeat:Wait()
	return coroutine.yield()
end

Additionally, you should name your variables properly rather than lazy naming them.


@IDoLua That “gucci” optimization is infact a very good example of premature optimization. Previously, it added some considerable boost but not anymore due to the new Luau’s VM. (Though in native Lua, its relative the same performance as Luau). Test this and you’ll see for your self.

2 Likes

What do you mean by “thread continuations”?

The following code seems to work fine without your changes (it gives the same result in bindable events as well).

local signal = require(workspace.Signal)

local test = signal.new()

coroutine.wrap(function()
	wait(5)
	for i = 1,10 do
		test:Fire(i)
	end
end)()

local a = test:Wait()
print(a)
local a = test:Wait()
print(a)
local a = test:Wait()
print(a)
local a = test:Wait()
print(a)
local a = test:Wait()
print(a)
local a = test:Wait()
print(a)
local a = test:Wait()
print(a)
local a = test:Wait()
print(a)
local a = test:Wait()
print(a)
local a = test:Wait()
print(a)

Output:

1, 2, 3, 4, 5, 6, 7, 8, 9, 10

That is not what I mean by thread continuations. See: ModuleScripts that yield with coroutine.yield() will break parent thread

I see. It does not break the Wait function though, if it did then I would have added the yield before returning. But it doesn’t, so I don’t see any reason to do so. If you are able to produce a function which breaks this, I will add the yield in the Wait function.

Alright boys… The results are in… and to say I’m disappointed is reasonable.
Some notes before I post the image:

  1. I did NOT expect this to behave nearly as good as localized code.
  2. I was considering use of this in a small irrelevant project I’ve been working on that is beyond micro-optimized. One could say; It’s optimized to hell and back.
  3. Without this module, and using a localized equivalent with 0 dependancies, I got anywhere from 11-38% less elapsed time, with approximately 64x more iterations, differing due to Frame Instability.
  4. On the topic of FPS, I am running a :Fire() loop on Heartbeat, passing the Delta and indexing the result of :Wait() in a repeat until loop. I am getting anywhere from 290-500 fps, with 1% lows being 291, and Average FPS of 399.

And finally, the results are ready.

Notes:
true is me testing if the repeat loop errors out or not since it was taking unreasonably long.
I do not have screenshots of the localized results, I would have posted them otherwise.
This module is beautiful and educated me on some of LuaU’s !strict typechecking syntax. It is a feast for the eyes and the occasional friend who learns by comprehending but the performance is sadly unsuited for myself, thank you.

1 Like

Are you able to send the code you used for benchmarking? So I could use it to improve this module in the future. Thanks for the feedback!

Also the slowness of Wait is most likely caused by the coroutine.resume and coroutine.yield and not the module itself. I will try to improve it in the future (if the cause is my code and not coroutine).

Ah snap, I’m sorry. I’ve closed the place, and I do not have an autosave for it.
I sadly do not have the time to write out the steps to code the localized tests.
To sum it up here’s chronological steps for recreation:

Define MySignal = Signal.new()
Define DoIterate = true
Define Index = 0
Define Buffer = table.create(Iterations)
Define start/end with no value, so that it may be referenced by the repeat loop
Start the repeat loop, wrapped as: coroutine.resume(coroutine.create(f))
Inside repeat loop it’s as follows: repeat Buffer[Index] = MySignal:Wait() until not DoIterate
Following the repeat loop: print(“Finished”, string_formatiscool.bin.jpeg.wav)
Following the routine, a wait() call just to make absolutely sure the coroutine has started waiting.
Define Start = os.clock()
Define c; c=Heartbeat:Connect(function(Delta) … end)
Heartbeat loop is something as follows: Index += 1; MySignal:Fire(Delta); If Statement to check if…
…Index is >= Iterations; if condition passes then do: DoIterate = false; MySignal:Fire(Delta); …
…c:Disconnect(); ![The signal must be fired again or it will simply Yield forever in the routine.]!

That’s all the time I’ve got left to spare, sorry friend!

I did my own benchmark. (I did not do your idea of the benchmark of using Heartbeat because it was getting confusing if the fluctuation in the elapsed time was the Signal module or Heartbeat.)

Code:

local RunService = game:GetService("RunService")
local Signal = require(workspace.Signal)

local mySignal = Signal.new()
--local mySignal = Instance.new("BindableEvent")

local event = mySignal.Event or mySignal

local iterations = 100000
local index = 1

local startTime, finishTime = nil, nil

coroutine.wrap(function()
	while (index < iterations) do
		if (event:Wait() ~= index) then
			print("Not the correct index. Signal module broke.")
			return
		end
	end
	
	finishTime = os.clock()
	
	print(index)
	print("Finished: " .. (finishTime - startTime))
end)()

startTime = os.clock()
while (index <= iterations) do
	mySignal:Fire(index)
	index += 1
end

Results:

BindableEvent: 0.20944450004026
SignalModule: 0.075790100265294
1 Like