[Beta] Deferred Lua Event Handling

Hey developers,

We’re enabling a new opt-in feature which will change when event handlers are called by our engine. Here I’m going to dive into detail about what the feature does, why we are doing it, and how it may affect you if you choose to opt-in.

Update (May 21)

We know some of you had concerns about this feature, we have updated the post to better reflect our intentions. See the reply from @zeuxcg for more in depth information.

What we’re doing

Right now, all event handlers get called immediately when an event fires. For every instance added to your game, property that changes, or some other trigger that is invoked we need to run Lua code right then and there.

Deferred events change this. Instead of resuming handlers immediately we will defer them until a slightly later time. We then have specific ‘invocation points’ where we call all of the deferred event handlers and any event handlers they in turn defer.

This diagram compares the existing behavior (‘Immediate’) and the behavior when this feature is enabled (‘Deferred’). Overall, the total time taken does not change but the ordering does slightly. This occurs because the handler for ‘Event A’, which invokes ‘Event C’, does not run until after the main thread (blue) yields.

Our set of invocation points may increase over time depending on the feedback we receive, however right now these are:

  1. RenderStepped
  2. Waiting script resumption (e.g. wait, spawn, delay)
  3. Stepped
  4. Heartbeat
  5. BindToClose

Why we’re doing it

Across the Roblox engine there are many different events which can all invoke Lua code. For a number of reasons, this isn’t ideal:

  • If we want to change 1,000 properties, 1,000 snippets of code potentially need to run after each change
  • Performance-critical systems can fire events requiring them to yield back and forth to Lua
  • Event handlers can make changes to the place or trigger other events any time an event is fired
  • An event can fire multiple times despite being redundant (such as a property changing twice)

By having specific portions in which Lua-can-run we can build better systems based on a number of assumptions:

  • Performance-critical systems don’t need to yield to Lua leading to performance gains
  • The place will never change outside of an invocation point (unless we change it)
  • We can collapse redundant events into a single event (we don’t do that just yet)

How you’ll be affected

Unless you have code in your game that depends on the immediacy of event handlers you are unlikely to notice a difference. Event handlers will continue to be called in order, in the same engine step, albeit slightly later.

We know some developers rely on event handlers being called immediately and can appreciate that transitioning to the new behavior may not be easy therefore you can explicitly set your place’s signal behavior to ‘Immediate’ opting-out long before we make any changes to the default behavior. Doing this will ensure your games continue to use the current behavior for as long as we support it.

To help you determine if you’re affected, here are some common code-patterns that will have a slightly different behavior when deferred events are enabled:

FastSpawn

FastSpawn is an alternative to Spawn which allows you to run a callback instantly without halting the current thread if it yields. If it is implemented with an engine event (as shown below) it will now be resumed later in the current Lua invocation point.

local function fastSpawn(f, ...)
    local bindable = Instance.new("BindableEvent")
    local args = table.pack(...)
    bindable.Event:Connect(function ()
        bindable:Destroy()
        f(table.unpack(args))
    end)
    bindable:Fire() -- will now queue the event for resumption
end

In addition, we are planning to introduce a new method which will make deferring a thread easier. More to come on this soon.

Inline Resumption

Code which relies on events being resumed immediately will stop working correctly. In the following example, false will always be returned when deferred events are enabled because the callback has not run.

local success = false
event:Connect(function ()
    success = true
end)
doSomethingToTriggerEvent() -- Causes `event` to fire
return success

You must make sure you yield the thread until at least when the event should have fired.

Connect Once

Sometimes you only want to listen for the first occurence of an event

connection = event:Connect(function ()
    connection:Disconnect()
    -- do something
end)

With deferred events enabled, multiple events can be queued before you disconnect from the event. We are looking at alternative solutions but for now you can manually check if the event is still connected before running any code.

connection = event:Connect(function ()
    if not connection.Connected then
        return
    end
    connection:Disconnect()
    -- do something
end)

We are also aware of some minor issues with Roblox scripts. These will be fixed as soon as possible.

How you can enable it

You can test the new behavior in Studio by changing the SignalBehavior property of Workspace:

  • Default: The default behavior (currently ‘Immediate’)
  • Deferred: Event handlers are deferred
  • Immediate: Event handlers are called immediately

Feedback

If you have any feedback about this feature, questions, comments, or concerns please post them here.

FAQ

If an event fires is it deferred until the next frame?

No, we process the queue of deferred events until it is empty. If you invoke another event with your code, that event will be added to the back of the queue and run later in the same invocation point. The diagram above shows an example of this. ‘Event C’ is triggered by the handler of ‘Event A’ so we add its handler to the back of the current queue.

There is still a concept of ‘re-entrancy’ which prevents events from continuously firing one another when they reach a certain depth. The current limit for this is 10.

When will Lua run?

Lua will run at specific invocation points. Right now, these are:

  1. RenderStepped
  2. Waiting script resumption
  3. Stepped
  4. Heartbeat
  5. BindToClose
194 Likes

This topic was automatically opened after 13 minutes.

This is a bad implementation of FastSpawn anyway, as it would be cleaner to use a coroutine as such

local function fastSpawn(f, ...)
  coroutine.wrap(f)(...)
end

With this, you only use one line, and you dont have to pack the variadics, using a bindable also takes up memory as you’re creating an entire Roblox Instance for the sole puprose of starting another thread. I wonder where the Bindable implementation of this solution came from, I always used coroutine.wrap to achieve this.

As for inline resumtion, this example isn’t good either, as its easier to just do

event:Wait()
return true

The dispatch function is also not disclosed so afaik we have no idea how this function works. In the case that you need arguments to handle the call, you should do

local co = coroutine.running()
event:Connect(function(s)
  if s == 1 then
    coroutine.resume(co, true)
  end
end)

return corotuine.yield()

I wonder why RBXScriptSignal:Wait() or coroutines were not brought up, as using the former already handles the yield for you, and the latter gives you full thread control for that script

46 Likes

Alright, this is certainly interesting.

Question: if I have some code bound to RenderStepped, and an event which fired previously is also deferred until the RenderStepped event, which one runs first?

To be more general, to what extent is order of execution preserved? How much existing code can we rely on being executed in the same order; under what circumstances will the order of execution change?

24 Likes

Do you have any idea what the performance implications might look like? Will this impact, for example, an immediate wait or RunService.Heartbeat:Wait() on init?

Is there a reason this is in Workspace and not ServerScriptService? That seems like a more appropriate place for the property.

11 Likes

Question: Let’s assume I have a script ScriptA that fires a BindableEvent BindableEventA and a script ScriptB that listens to BindableEventA. Given the FastSpawn example, I assume that this will now have a slight delay. But if ScriptB calls a second BindableEvent, called BindableEventB that script ScriptC listens to, when will ScriptC run? Will it wait for the next invocation point? Does this mean that latency will keep stacking up?

I have a framework where individual components communicate with each other through the use of BindableEvents. Sometimes a series of multiple components are involved that all execute one after another. It is very important for me that splitting code into multiple components will not cause significant gaps due to latency.

14 Likes

Question, does this means that the custom wait which relies in Hearbeat or Stepped won’t work good at all anymore?

6 Likes

If the event never fires, this will never return. In the example, dispatch is just some function that should (but may not) cause the event to fire.


Event handlers are resumed in the order that the event was fired. If event A fires before RenderStepped then it’ll be called first.


Nope, these will be unaffected.


We process the queue of deferred event handlers until it is empty. If you have an event handler which triggers another event, that event’s handler will be added to the back of the queue and will run in the same invocation point. It is worth noting that a re-entrancy limit of 10 still applies to deferred threads.

16 Likes

The reason for the bindable event fastSpawn implementation is because it preserves stack trace errors for debugging purposes.

Coroutines don’t provide the same level of error tracing information, it just spits out an error message which doesn’t give a lot of context if the function can be called from many different places.

We switch between the two implementations for live games and studio-debugging modes, but now this update will basically kill off the debugging-oriented implementation.

31 Likes

Aren’t stack traces passed through coroutine.resume/wrap now?

image

9 Likes

Looks like not even roblox is completely ready with this change.
With deferred events enabled opening dev console produces this nice little error.
RobloxStudioBeta_I0jEtV2xfW
It’s probably reasonable to assume it’s going to take a while for this change to completely phase in.
Do we have a estimated timeframe on when roblox is going to phase out immediate behavior?

24 Likes

This is a really good change and I actually made a custom behavior like this for some of my scripts. They won’t be needed anymore but at least it’ll be in the engine.

By the way, is this the end of debounces?

4 Likes

I was just about to make a comment about this, I’ve had no issue at all debugging coroutines ever since the February 2021 updates. In light of this I see no reason to keep spamming BindableEvent objects as a fast-spawn solution.

3 Likes

It’s still an issue, coroutine.wrap doesn’t concatenate the trace when it propagates the error.

8 Likes

Not quite sure on how this affects debounces (as far as I’m aware anyways) - delaying the firing of an event to later in the frame doesn’t stop it from being fired multiple times, no?

3 Likes

So wait then I’m missing something. Does this mean events are like spawn() now in terms of threading?

2 Likes

Just retested the coroutine approach, I remember why I personally kept the bindable event implementation.

Clicking the red output error message opens up the script where the coroutine was created, not the actual module script that errored.

This becomes really tedious to debug when we have a ton of module scripts to actively work on and a core “Event” handler module script that keeps getting opened unnecessarily.

Comparison between fastSpawn and Bindable output behaviors:

30 Likes

I assume defferred thread calls are still queued, so they will fire in the order that it’s called.

7 Likes

What’s this delay exactly by the way? Is it like work cycles? Post says invocation point which isn’t really descriptive for me. If it’s like that isn’t there a possibility of two events running at the same time?

6 Likes

Edit: Oh neat, this may not be a problem.

This seems to solve this problem of signals firing re-entrance. As long as events fired in the data model are ok, we should be fine.


I’m sorry, but please do not release this change as-is. This will destroy responsiveness in all of my experienes, and (I assume) many others experiences. The code will still function, but this change may introduce significantly latency (read: multiple frames) of latency.

The root of the issue is that I, and many others chain signals together that flow across our data models. If anyone has code like this, this change introduces latency into core data models.

This change breaks how I, and many other developers, program, forcing me to add latency into my game. I use Roblox’s datamodel as the source of truth. We gave a [programming talk]( 5 Powerful Code Patterns Behind Top Roblox Games) at RDC about this. We use signals such as:

  1. Attributes
  2. CollectionService
  3. ChildAdded/ChildRemoved
  4. PropertyChanged events

I also use signals for internal models that don’t use Roblox as a source of truth.

By doing this, we introduce unavoidable latency into my datamodel updates. For example, consider this scenario.

  1. I instantiate a new object into the game, tag with CollectionService
  2. Event fires (later now), we set some properties and then we maybe instantiate 2-3 other objects with tags. Finally, we parent these.
  3. These child added events fire (later now), and so we do this again
  4. We repeat a few more times, and now stuff loads in over 3-4 frames, instead of one.

Another example if where I recursively replicate properties in my virtual data-model using signals downwards. This will now occur over multiple frames, missing first-rate responsiveness. This is a super common pattern.

  1. Listen to input
  2. Abstract input into a data model and fire off the event (like setting a bool value, or having a custom signal)
  3. Listen to this abstraction instead of the true input event.

By making this change, we introduce a round of latency between this input and the response to the user. In more complicated data models, we may introduce even FURTHER rounds of latency, leading to multiple frame delays. In something like a VR, this is deadly.

It will be very hard to audit all of the places that I am using signals. :Connect occurs 1923 times across 910 scripts in my experiences. These thousands of concurrent users, and my code is used in many more places.

Please consider an alternative behavior. I’m willing to sit down and chat about this, because this is a truly disruptive and breaking change for us.

54 Likes