[Beta] Deferred Lua Event Handling

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.

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)?

This might be of interest:

3 Likes

This broke my game in ways I don’t fully understand. I can’t tell if it’s because this feature is incomplete or something, but it feels like certain things are firing completely out of order now, even where it should make sense either way.

8 Likes

I have a similar optimization situation on updating that can greatly be helped with the introduction of deferred events. Right now, I have a building that needs to keep track of parts that are connected to the ground, either directly or through another grounded part, and mark parts that are no longer connected and are floating in the air. Every time a part is removed from the building, it needs to run its ‘update’ function. The function is fast enough to update smoothly, but the problem lies when multiple parts are removed on the same frame, the building has to update IMMEDIATELY upon every part being removed so the function runs needlessly multiple times. With deferred events, multiple part removals can be deferred to be handled all at once and only require a single update call max per frame.

This image shows many updates happening on the same frame that could easily be condensed to 5 updates total with deferred events.

Overall, I’m excited for this update to get out of Beta and fully released for all to use. This thread has been packed with great information, some mixed emotions or confusion from some members but I think that the more you know about the update the more you will be excited as I am :cowboy_hat_face:

4 Likes

I don’t know if this is the right place to post glitches about differed events, so please let me know if I’m being disruptive to the thread.
I found a reliable way to reproduce this warning in studio (when signals are differed)

RunService warning

  1. Create a part and select it as either move, scale, or rotate

  2. Duplicate the part

  3. Change selection modes (I.E. from move to select, from rotate to scale, etc.)

You can definitely tell it’s a beta, but I’m excited to see all the creases ironed out as time goes on

Edit: Never mind the previous statement, it seems to happen completely at random when changing selection modes after selecting a new BasePart.

1 Like

This fundamentally changes Roblox’s event handling model and breaks any code that expects to be notified immediately when an event fires. Basically, most code that uses events.

Once your callback is called, it’s already too late. The caller has already sped way ahead of you and done other things, and you have to account for way more edge cases now. It is now impossible to use events to ensure that something is acted upon in a timely manner; you may as well use spawn.

Changes in Instance hierarchy are particularly dangerous examples of this, as scripts might want to know when an Instance they control is reparented/etc and act on it quickly, but now scripts that execute before the callback can see invalid/unintended states.

Things like the camera input being off by one frame appear out of thin air as a side effect of this. By the time scripts are notified of the events corresponding to user input, it’s already too late to change things in response before the next frame.

The fix is to notify scripts faster, which is what Roblox already does and has done since the beginning of time. I do not see why it’s required to break every script in Roblox that relies on events, even if you’re going to do it over the course of a few years. You can not expect every experience on Roblox to migrate, especially the ones that aren’t being maintained anymore, or whose developers have long left the platform. This will leave all the hidden gems of Roblox in an incredibly broken state.

If this is needed for Parallel Luau, keep the old system and simply use this new one only for events that are fired in parallel (so basically, events that are fired outside the Actor by code running desynchronized inside it). This will keep every existing script on Roblox working, while preparing for the future of new code running in parallel. Breaking so many things on Roblox outside of Parallel Luau is not required.

Parallel Luau is a beta feature and has no compatibility guarantees yet, and making that change to allow it to function is perfectly acceptable because running Luau in parallel is a new concept that requires some care. It’s expected that it’ll be different than regular scripting.

However forcing this change on existing code and the entire non-parallel Roblox ecosystem is not acceptable for me. I highly recommend that you backpedal a bit on this change and consider confining it only to actually concurrent systems like Parallel Luau. This is detrimental to my code and many others’ outside of parallel execution.

Firing an event is inviting other code to act on it instantly, that’s what events are for. Events should not be queued unless there is reason for not being able to act on it instantly, like in the case of Parallel Luau.

Of course nobody’s going to read my concerns, but I think it’s worth writing them down here. :/

16 Likes

I think I have an actually sensible idea for how this idea could be implemented into the engine, without breaking existing code or removing functionality, and while allowing for Parallel Luau.

Confine deferred behavior to crossing VM boundaries. When you fire an event, callbacks from the same VM will be called immediately as they are now, but callbacks from other VMs will be deferred.

This will preserve the immediate behavior for all current games and all code confined to one Actor, but have deferred behavior for certain things like Instance hierarchy changes propagating up past the Actor or desynchronized threads firing BindableEvents/etc.

It will avoid breaking existing code while allowing events to work in multithreaded code. All the effort towards setting up these defer queues won’t go to waste yet all games on Roblox will not cease to function randomly.

Only those who explicitly opt-in to using Actors or task.desynchronize will ever have to deal with deferred events. Everything else will be Immediate. Events firing inside the same Actor will be immediate.

Current behavior is preserved. Backwards compatibility is achieved. Yet it paves the road for Parallel Luau.

As far as I can tell, this is the only way to have your cake and eat it too. If you break every existing game, developers will be mad. Developers are currently mad. If you don’t defer events ever then Parallel Luau will take huge performance hits waiting for other VMs whenever you fire events. But if you use this hybrid approach then existing games will continue to function and Parallel Luau can zoom as fast as it wants.

I hope I’m not too late to propose this. I think there is still time to save this feature.

16 Likes

Hey @zeuxcg, a question if you don’t mind:

In regards to events being deferred, are all same events grouped up, or does the deferring not care for what event it is? What I mean is, if 10 different events are invoked for the same instance, would they be deferred? Or do events only get deferred when the same event is called in rapid succession, such as with BindableEvents?

1 Like

Events are currently always deferred. When you call an event, the callback is no longer invoked, and you will be able to do whatever you want. Then, eventually, at some point, the callback will be invoked, some time in the future. Right on time… (not)

Usually, “some time in the future” is one of the “checkpoints” in the current frame. Your code will still execute, it just won’t execute when you expect it to, and you will no longer be able to use events for efficiently dispatching instant messages.

You can still use it for dispatching messages that will eventually arrive, though. Just not time-sensitive ones.

1 Like

It’s difficult to test code with this when API events, especially .PlayerAdded, are completely or to some degree broken and don’t seem to fire at all or they do rarely. My new project becomes half-unplayable with the deferred signal behavior enabled as of now.

I am quite worried with this change, considering that apparently every event will now be deferred (definitely a bit afraid on how this can affect .AncestryChanged, .Changed, and such).

2 Likes

This. Not every event needs to be deferred. There is a solution.

1 Like