(Archived) Signal API - For all your event needs!

Hey, I tried with these scripts:

local Signal = require(game.ServerScriptService.Signal)

local signaler = Signal.new()

signaler:Connect(function()

end)

wait(10)

local start = os.clock()

for i = 1, 100000 do
    signaler:Fire()
end

print(os.clock() - start)

ā€“

local Signal = require(game.ServerScriptService.NeverMore)

wait(10)

local signaler = Signal.new()
signaler:Connect(function()
	
end)

local start = os.clock()

for i = 1, 100000 do 
	signaler:Fire()
end

print(os.clock() - start)

And the results for me were:

In this case, I ran both twice, and got their average. I only did it twice, but I donā€™t think more would change much.

I didnā€™t try with ā€œpureā€ BindableEvents here, how ever, because NeverMoreā€™s signal api is already small enough, I donā€™t think that would have impacted it.

1 Like

Here it is waiting 5 seconds and connecting the event:


Nevermore does 0.066512100049295

Your signal does 0.068769700010307

BindableEvent does 0.04701770003885

If I havenā€™t messed up anything, BindableEvent is faster than both while Nevermoreā€™s signalā€™s faster than yours.
Also Iā€™ve read through your code and youā€™ve kept the coroutine concept, but the rest youā€™ve changed (2 tables for assigning __index?)

???
Yeah um, Connections run in ā€œCoroutineā€ just like normal events. Of course itā€™s a bit different internally, but it is a form of coroutine.

Hm, and what are you talking about assigning __index twice? Can you show me that so I can fix it?

2 Likes

I think this is very dependant of hardware. Mine had completely different results like I showed.

My specs are:

AMD Athlon 3000G with Radeon Vega Graphics 3.50GHZ, 4 cores.
8 gigs of ram, 2 dedicated to integrated graphics, which leaves 6.
idk aaaaa 128GB SSD with a 1TB HDD, I guess.

1 Like

I meant having 3 tables (I thought it was 2) for OOP:
image
image
image
You couldā€™ve done

Signal._index = Signal

then just assign to the metatable in the constructor:

return setmetatable({
	_connections = {}
}, Signal)

and for the rest you couldā€™ve kept that information in self
Also, table.clear() doesnā€™t free memory, and most likely (not sure) doesnā€™t get garbage collected
image
You can assign it to nil.
I can make a PR if youā€™d like to :DD

1 Like

shoreee do it I kind of want to cuz because I wanna test it out :joy:

I donā€™t like using Bindables in my opnion. Theyā€™re slower, and for what Iā€™m using it for, coroutines are fine. The only problems with coroutines being that they donā€™t error if you donā€™t wrap it in a pcall.

I donā€™t see any purpose of utilizing data-structures other than the standard dynamic arrays or dictionaries when making a custom signal, but good advice I guess.

1 Like

I did. The other link Iā€™m too dumb to understand, sorry.

Thatā€™s because I did this.

If you want to optimize your code with data-structures, I would recommend only doing so when handling a large amount of input or when your code is handling some heavy work. Otherwise, it would just lead to overcomplication and a negligible speed boost.

With a custom signal module it is very unlikely to have to do any heavy work internally.

1 Like

Why are data structures so important in this topic? Well it is about concurrency. If your game consists of tens of thousands of signals and you are expecting to disconnect and connect signals thousands of times a second, it becomes a necessity worth considering. This was the case for loleris (for example).

If we are on about negligible performance boosts, this whole library is very unnecessary. This one is a copy of the second signal class of the featured reply that I attached. I know its shortcomings. You are giving up solid safety for little performance boosts if any. Hence, if you are just trying to pursue speed, this is how you do it. If you are trying to develop a solid signal class library? Use bindables.

1 Like

Could you elaborate more on what data-structure you used in your implementation and how it fixes the thread concurrency issues?

I didnā€™t use any particular data structure in my updated signal class because it uses bindableEvents. But in the miniscule event that I would use a signal class for a monster project, I would reference this script: ReplicaService/MadworkScriptSignal.lua at master Ā· MadStudioRoblox/ReplicaService Ā· GitHub

You can try to pass Lucasā€™ signal library into this function (guaranteed to fail btw)

local function SignalBreaker(new_signal) --> is_all_good
    local signal = new_signal()
    local confirm = {}
    for i = 1, 10 do
        signal:Connect(function(check)
            if check ~= 1 then return end
            confirm[i] = true
        end)
    end
    local red_flag = false
    local bamboozle
    bamboozle = signal:Connect(function() -- bamboozler
        bamboozle:Disconnect()
        local special_signal
        for i = 1, 10 do
            if i == 5 then
                special_signal = signal:Connect(function()
                    red_flag = true
                end)
            else
                signal:Connect(function() end)
            end
        end
        special_signal:Disconnect()
    end)
    for i = 11, 100 do
        signal:Connect(function(check)
            if check ~= 1 then return end
            confirm[i] = true
        end)
    end
    signal:Fire(1)
    if red_flag == true then
        return false
    end
    for i = 1, 100 do
        if confirm[i] ~= true then
            return false
        end
    end
    return true
end

Simply put, it breaks if you violate pairs() usage by creating new index during iteration, a single-thread concurrency issue occurs.

A double-ended linked queue fixes this ^

2 Likes

Then should I create a deepcopy of all connected singlas and illerate through that instead? Didnā€™t think that wouldā€™ve been a problem with coroutines.

That isnā€™t useful but ok.

Adding on to @Ukendio point on why Bindables are Better:

I have been using 1 pair of Remotes (one RemoteEvent and one RemoteFunction) for a long time now but I switched to use 1 RemoteEvent for each thing or at least each module that needs it because itā€™s a lot easier to understand and maintain.

Similar to BindableEvent they are a lot easier to understand and maintain because they have to actually exist and you can use things like WaitForChild to make sure that it has actually been created before using it.

There are times when FastSpawn and Signal modules are useful, but usually not for Cross Script Communication.

BindableEvents do have some downsides like making a deep copy of your Tables which means they arenā€™t mutable when passed through but thatā€™s not difficult to circumvent and your code shouldnā€™t rely on that behaviorā€¦


As for this

Iā€™m asking because you made a copy of those existing and well known modules, not that I have any problems with innovation nor the functionality that the module offers but Iā€™m questioning why you are reinventing the wheel.

You should definitely take Ukendio advice they are very knowledgeable

1 Like

Got another reason to not use BindableEvents, at least not with BadgeService3.

Iā€™m working on a update to fast signal. Might be released soon.

Thanks for pinging me! Iā€™ll now provide constructive criticism on your event.

First off, I started off by testing the code you have on your GitHub. I was unable to get it to work the first time due to an oversight on line 77

table.insert(self, #self + 1, connection)

where it should be (at least Iā€™m assuming);

table.insert(self._connections, #self._connections + 1, connection)

After adding this fix I got your module to work for me. It looks like itā€™s reproā€™d in your Roblox model as well. Additionally, I attempted to use the Destroy method and was met with a nil value error on line 28:

connection:Disconnect()

Line 29 makes the indexā€™s value nil and I think that should be enough to satisfy what you wanna achieve out of a dispose method. Also the self = nil line doesnā€™t do anything because self is just a local reference to the table itself within the methodā€™s scope, but the diligence is appreciated :smiley:

At this point Iā€™ve gotten your module to work, lets see what my benchmarks are here (benchmark script at the bottom). I noticed you guys in this thread are running benchmarks on how fast ::Fire takes to execute; Iā€™ll be benchmarking the time inbetween Fire and the callbackā€™s entry point.

 21:10:28.628  bindable event - 9.22 microseconds  -  Edit
  21:10:45.311  signal module - 8.82 microseconds  -  Edit

  21:11:01.993  bindable event - 10.79 microseconds  -  Edit
  21:11:18.675  signal module - 9.32 microseconds  -  Edit

  21:11:35.358  bindable event - 9.25 microseconds  -  Edit
  21:11:52.040  signal module - 13.11 microseconds  -  Edit

  21:12:08.723  bindable event - 9.32 microseconds  -  Edit
  21:12:25.406  signal module - 8.60 microseconds  -  Edit

  21:12:42.089  bindable event - 9.26 microseconds  -  Edit
  21:12:58.771  signal module - 8.21 microseconds  -  Edit

It seems like at best I save a little more than a microsecond, and at worst it costs a little under 3 more microseconds. I wanted to see if I could make this any faster, so I started poking at the Fire method and changed a few things

  1. I replaced the pairs iterator with a numerical loop. This is known to be faster than both pairs and ipairs, and even next
  2. I removed the if statement, since (correct me if Iā€™m wrong) it seems like the statement will always be true
function Signal:Fire(...)
	for index = 1, #self._connections do
		local thread = coroutine.create(self._connections[index].Function)
		coroutine.resume(thread, ...)
	end
end

So here we have the new fire method, lets check the benchmarks

  21:25:32.109  bindable event - 9.02 microseconds  -  Edit
  21:25:48.789  signal module - 7.60 microseconds  -  Edit

  21:26:05.490  bindable event - 9.41 microseconds  -  Edit
  21:26:22.173  signal module - 8.00 microseconds  -  Edit

  21:26:38.857  bindable event - 9.34 microseconds  -  Edit
  21:26:55.537  signal module - 8.19 microseconds  -  Edit

  21:27:12.221  bindable event - 9.69 microseconds  -  Edit
  21:27:28.904  signal module - 8.04 microseconds  -  Edit

  21:27:45.587  bindable event - 9.90 microseconds  -  Edit
  21:28:02.269  signal module - 8.01 microseconds  -  Edit

Cool, it seems weā€™re 5 for 5 here and shaving off more than a microsecond each time. I wanted to see if I could improve it further so I poked at the ::Connect method as well, and instead of creating a table for each connection and storing it in _connections I just have it insert the function itself directly into self, a-la:

table.insert(self, #self + 1, givenFunction)

This required me to change the for loop & coroutine creation line ins ::Fire to

for index = 1, #self do
	local thread = coroutine.create(self[index])

This cuts down on the amount of indexing we do. Lets see where that gets us

  21:42:41.350  bindable event - 9.52 microseconds  -  Edit
  21:42:58.033  signal module - 7.88 microseconds  -  Edit

  21:43:14.716  bindable event - 9.59 microseconds  -  Edit
  21:43:31.400  signal module - 7.86 microseconds  -  Edit

  21:43:48.081  bindable event - 9.22 microseconds  -  Edit
  21:44:04.763  signal module - 7.60 microseconds  -  Edit

  21:44:21.446  bindable event - 8.95 microseconds  -  Edit
  21:44:38.128  signal module - 7.92 microseconds  -  Edit

  21:44:54.812  bindable event - 9.55 microseconds  -  Edit
  21:45:11.494  signal module - 8.10 microseconds  -  Edit

yay! weā€™re hitting sub-8 microseconds more often than not now.

My only other criticism is to use runService.Heartbeat instead of .Stepped in your ::Wait method, as Heartbeat will have fired 3 times by the time Stepped fires once. And sorry if I come off as pedantic here, I really do want to help so I tried explaining as much as I could here. I think this is a neat idea and I hope you keep up the good work!

Benchmark.lua
local Signal = require(workspace.Signal);

local tests = 1000;
local runserv = game:GetService('RunService');
local beat = runserv.Heartbeat;

local function TestA()
	local total = 0;
	local completed = 0;
	local signal = Instance.new('BindableEvent', workspace);
	
	signal.Event:Connect(function(t)
		total += (os.clock() - t);
		completed += 1;
	end);

	for i = 1, tests do
		signal:Fire(os.clock());
		beat:Wait();
	end;
	
	repeat
		beat:Wait();
	until completed == tests;
	
	signal:Destroy();

	return total / tests;
end;

local function TestB()
	local total = 0;
	local completed = 0;
	local signal = Signal.new();
	
	signal:Connect(function(t)
		total += (os.clock() - t);
		completed += 1;
	end);

	for i = 1, tests do
		signal:Fire(os.clock());
		beat:Wait();
	end;
	
	repeat
		beat:Wait();
	until completed == tests;
	
	signal:Destroy();

	return total / tests;
end;

local i;
for i = 1, 5 do
	print( string.format('bindable event - %.2f microseconds', TestA() * 1000000) ); 
	print( string.format('signal module - %.2f microseconds', TestB() * 1000000) ); 
end;
4 Likes

Iā€™ll be applying these changes to the version I got here, thanks for the tips.

As for glitches I didnā€™t notice; I stayed with the same version I believe for BadgeService3; Anyhow thanks.

(bro i was watching you type for 1 hour :fearful:)

2 Likes