[Beta] Deferred Lua Event Handling

As a developer in multiple engines. we always use deferred functions instead of directly editing our game’s state.
For example instead of executing an immediate function like a destroy function. most of the time we use it’s deferred version to avoid any problems.
Basically we queue the task to the engine instead of executing it immediately.

A basic example from godot engine:
The difference between the function “free” and “queue_free”.

Relying on roblox previous event immediate firing. Might make the game run as fine in 99% of situations but there will be that one game where the order of firing is just not perfect, and the game breaks leaving you with no idea of what caused it.

But if I understand roblox’s new update right. this will make events more stable and confusion free for us and also for roblox’s future updates that are related to parallel lua and other advanced features.
Of course that might not be the case. but that’s my best guess!

5 Likes

Looks like the same issue I reported earlier.

Now we only need a way to defer BindToClose to run while the game is closing without freezing studio.

1 Like

I’m so sorry, I must have not seen it since there are so many posts on this topic. I just wanted to post a report about the problem right away. I forgot to check if there are similar ones beforehand.

This might be a dumb question because I don’t understand parts of what all goes into this, but could we have something where this feature is introduced as a second type of connect, something like :connectSynchronous(), and then not change or replace :connect() but mark it as deprecated or give it a warning? In spite of performance benefits, a few other people have pointed out there are a few cases where continuing to use non-deferred event handling seems more maneuverable, and there’s also selectively maintaining old scripts if given the choice between both methods

3 Likes

Not sure how or why, maybe its just that i use the Knit framework, but this behavior randomly causes my game to not initialize on the server.

Can you give an example of what this might look like? The game I’m working on right now makes heavy use of Binders for its objects and game logic.

Let me note that he whole value of fastSpawn isn’t deferring execution. It’s saying “hey, Roblox, next time you effectively yield, please don’t block this current thread.

In other languages you have to write keywords like “await X” or declare functions as async. Lua doesn’t really care about this, and creating new threads (i.e coroutines) is reasonably cheap.

If coroutines reported their stacktrace accordingly, we wouldn’t even need other mechanisms for this sort of thing.

It’s unfortunate that wait/delay/spawn can’t be changed because effectively these already solved this problem, but are stuck operating at 30hz, on an old system.

5 Likes

This is problematic if the AncestryChanged event fires after :Destroy() or :Destroy() is called during AncestryChanged.

This will potentially break garbage collection in my games in the case of the :Destroy() event being called.

11 Likes

Agreed. A friend and I were just discussing how insane of a change that would be.

Really hope they don’t go through with that.

Quick confirmation: are the handlers called/resumed after the function is done firing the bindable 5 times? Is this behaviour consistent as well? If I fire a bindable 30 times, is the handler going to be called / resumed once all the 30 firings have been queued and referred?

Yes, they’ll get run in the same invocation point, but not necessarily immediately after, because the firings get queued at the end of the queue: There may already be other stuff in the queue in-between the code firing the event and the handlers that get queued.

1 Like

This feature is a lot more useful than it seems. For so many years I was stuck on problems like this without a simple solution:

Property dependency problem

You have a custom Lua-based rectangle with a position signal and a size signal / property.
A second rectangle’s size and position are dependent on the size and position of the first rectangle. When you update a rectangle, you set it’s position and size. First, the position is set and the position signal fires. Second, the size is set and the size signal fires. This results in 2 updates and causes the second rectangle to update twice within the same frame. Deep dependencies like this result in exponentially more updates than needed.

This may seem like a very specific but easy problem to solve, but it can get much worse.
Now imagine you have a BodyRectangle, which is dependent on: GuiScale, ParentRectanglePosition, ParentRectangleSize, TextBoundsResult1, and ScrollbarWidth.

  • GuiScale is dependent on: GlobalScreenSize
  • ParentRectangleSize is dependent on: GuiScale, GlobalScreenSize
  • TextBoundsResult1 are dependent on: GuiScale
  • ScrollbarWidth is dependent on: GuiScale

When the player resizes their screen, this is what happens:

GlobalScreenSize runs (fires change)
    GuiScale runs (fires change)
        ParentRectangleSize runs (fires change)
            BodyRectangle runs (fires change)
        TextBoundsResult1 runs (fires change)
            BodyRectangle runs (fires change)
        ScrollbarWidth runs (fires change)
            BodyRectangle runs (fires change)
        BodyRectangle runs (fires change)
    ParentRectangleSize runs (fires change)
        BodyRectangle runs (fires change)

BodyRectangle just updated and changed 5 times in a single frame. If this rectangle had multiple nested parent rectangles with similar behaviors, it could update hundreds of times in one frame.

With deferred handling, this is what would happen:

GlobalScreenSize runs (fires change)
    GuiScale (added to queue)
    ParentRectangleSize (added to queue)
GuiScale runs (fires change)
    ParentRectangleSize (already added to queue)
    TextBoundsResult1 (added to queue)
    ScrollbarWidth (added to queue)
    BodyRectangle (added to queue)
ParentRectangleSize runs (fires change)
    BodyRectangle (already added to queue)
TextBoundsResult1 runs (fires change)
    BodyRectangle (already added to queue)
ScrollbarWidth runs (fires change)
    BodyRectangle (already added to queue)
BodyRectangle runs (fires change)

It now updates just one time in that frame. With Roblox deferred event handling, it would actually update 4 times in 1 frame, but the queue order would still prevent exponentially more updates because the custom properties wouldn’t need to change so many times.


I’m unlikely to directly use the built in method for performance reasons though. A custom invocation queue implemented as an array with non-yielding function will be much faster, especially if it doesn’t need an RBXScriptConnection (or something similar) every time an object is added. It’s also useful to be able to remove a function from this queue if it’s disconnected by a previous function in the queue.

1 Like

I mean, the engine’s event handling code already does basically this. If every handler was a unique coroutine then things would be way slower than they are right now. Instead, there’s a pool of coroutines which get recycled so that if you don’t actually yield in the handler there’s not much cost, the coroutine just immediately returns to the pool.

4 Likes

Alright. I think I’d just want to be able to “raw add” / “raw remove” functions from the queue without a connection-related userdata needing to be allocated. Doubly linked lists are a lot faster when disconnecting from handlers with many connections, but it’s rare for a function to need to be removed from the invocation queue.

What about strategy games? I had an RTS nearing release, but I have to review all the code to get rid of undefined behavior now. A way to start would be searching for “:Fire” in all scripts, and examine the context.
I have more games affected by this update too.

Bindable events have been there for way more than a decade, and they have always worked so it’s safe to assume events fire instantly. This has never been reported in the documentation or elsewhere as bad practice. So a good portion of games that use events will be affected (not just “some developers”), and it’s not our fault.

But in the long term, as explained in an official reply, this is a good update, even if some games remain unplayable.
Unfortunately, this has usually been the trend with Roblox updates - they often improve stuff, but are occasionally destructive. I still miss some games which were broken by different Roblox updates, and the developer did not fix them because they were either inactive or tired of recoding their games over and over again. Anaminus’ Cruiser Hyperspace is the first to come to my mind, but there are many others.

And yes, it’s obvious their intention is not to break games, and this was a required change. We just have to adapt to them :smiley: :disappointed_relieved:

5 Likes

Are we always gonna be able to switch between Immediate and Deferred?

I’d really like it if this was the case.

1 Like

The platform did well for 13+ years.
If it breaks in 5 more years, it’s mostly not gonna be because you didn’t change the order of lua event handling.

I think you should explain in detail why this is necesarry as it’s a very important update with the possible risk of breaking most, if not every single game out there (espacially because of errors caused by CoreScripts), and us developers would like to get the full message that you guys do indeed know what you’re doing.
“three quick examples” just sounds like damage control to me.

1 Like

I was testing out this feature in studio just now and found out a problem that will break some of my game’s features. When a model is removed, its PrimaryPart is set to nil and its children are deleted after the ChildRemoved event is sent. So the following example will now act differently depending on if the immediate or deferred setting is being used:

local Model = game.Workspace.ContainerModel

Model.ChildRemoved:Connect(
	function(Child) -- with deferred behavior, Child is now an empty model
		print(Child.PrimaryPart, #Child:GetDescendants())
	end
)

Model.ChildModel:Destroy()

Workspace hierarchy:

image

Output with immediate behavior: Part 1
Output with deferred behavior: nil 0

The same issue appears with the DescendantRemoving event, where the argument will be an empty model. I think it is safe to say that the Child variable being an empty model here is undesired behavior.

11 Likes

I am unsure how so many people are affected to be honest. Is there common practice among developers (aside from the issue about input handling being delayed) where the immediate invocation of the event is required? Or to disconnect a connection the first time it is fired (and why not just use signal:Wait() in a coroutine as a replacement for that)?