What is the proper way to create "pauses" in a task scheduler?

(Apologies to those getting notifications for linked threads in advance.)

I’ve been following some good advice posted in the recent announcement about deferred signals and relating back to an old post I made asking if it was possible to architecture an entire game without wait. With this mind I’ve made some beginners’ steps into Lua-based task schedulers.

One thing I’m unsure of is how I could add delays between functions in the scheduler. From my understanding the goal should be to not use wait so I’m not too sure if using a Lua TS would actually allow me to use wait properly or if I should use another solution. As of right now, since I can’t write one myself, I’m using EchoReaper’s Task Scheduler.

So far, my idea has been to couple the task scheduler with Promise.delay to pause and resume the scheduler when the delay is up. For example:

local scheduler = TaskScheduler:CreateScheduler(60)

scheduler:QueueTask(function ()
    print("First task ran.")
    Promise.try(scheduler.Pause, scheduler):andThen(function ()
        return Promise.delay(2):andThenCall(scheduler.Resume, scheduler)
    end)
end)
scheduler:QueueTask(function ()
    print("Second task ran.")
end)

Would this be the proper way to handle delays between tasks or is there a better way? Do I have to pair up with another library or is it possible to just pause the scheduler itself before resuming? I also thought about queueing a task that just calls wait but that’s using wait and thus counterproductive.

Sample use cases of delaying between tasks:

  • Typewriter effect (three dots after loading text or general typewriting)
  • Moving to the next scene in a cutscene
  • Putting time between an action

Please only reply if you know what you’re talking about or have an idea of where I can go from here rather than posting to get a solution. Thank you!

1 Like

I set up a test just to have a go with my own code. In general I’d still like to know if there’s a better way to create pauses between tasks in a task scheduler but I also wanted to check if what I had now worked. Worst case scenario this inelegant-looking solution will have to be what I run with unless it’s time-critical code which then I’m in a pinch because it’s more than just a frame’s worth of problems.

Test environment:

ReplicatedFirst
  Promise: ModuleScript
  TaskScheduler: ModuleScript
StarterPlayerScripts
  Test: LocalScript
Test code
local ReplicatedFirst = game:GetService("ReplicatedFirst")
local TaskScheduler = require(ReplicatedFirst.TaskScheduler)
local Promise = require(ReplicatedFirst.Promise)

local PAUSE_TIME = 2

local scheduler = TaskScheduler:CreateScheduler(60)

local firstTaskScheduleTime
local secondTaskScheduleTime

scheduler:QueueTask(function ()
	print("First task ran.")
	Promise.try(scheduler.Pause, scheduler):andThen(function ()
		return Promise.delay(PAUSE_TIME):andThenCall(scheduler.Resume, scheduler)
	end)
	firstTaskScheduleTime = os.clock()
end)
scheduler:QueueTask(function ()
	print("Second task ran.")
	secondTaskScheduleTime = os.clock()
end)
scheduler:QueueTask(function ()
	print("Time between tasks: " .. tostring(secondTaskScheduleTime - firstTaskScheduleTime) .. " seconds")
end)

Results:

First task ran.
Second task ran.
Time between tasks (SignalBehavior Immediate): 3.0171824999852
Time between tasks (SignalBehavior Default): 3.1161347000161

The time between each queued task is variable, usually around 3-4 seconds. I’m not familiar with the source of the Task Scheduler but since I have my pause time set to 2 seconds and it’s waiting an extra second that helps me to deduce some points of concern in my test:

Promise implementation
To check if this is a problem with the Promise library, I set up a brief delay test.

Delayer
local ReplicatedFirst = game:GetService("ReplicatedFirst")
local Promise = require(ReplicatedFirst.Promise)

local PAUSE_TIME = 2

local initialTime

local function testDelay()
	initialTime = os.clock()
	
	Promise.resolve(initialTime):andThen(function (t)
		Promise.delay(PAUSE_TIME):andThen(function ()
			local timeDelayed = os.clock() - initialTime
			print("Time delayed: " .. tostring(timeDelayed) .. " seconds")
		end)
	end)
end

testDelay()

Results:

Time delayed: 2.4779289999278 seconds (SignalBehavior Immediate)
Time delayed: 2.3890501999995 seconds (SignalBehavior Deferred)

Not exactly the time I’m expecting but Promise runs on Heartbeat frequency therefore the increase in time is dependent on the machine. In this case my machine because it’s a LocalScript. To ensure this test wasn’t being hitched by the client connecting to the server I put the script into PlayerGui next and reset to test the new time: gave me 2 seconds roughly every time. Safe to say that beyond the case of initial connection, Promises are fine.

Task Scheduler implementation:
To check if this is a problem with the Task Scheduler, I had a look into the implementation. Both the pause and resume functions set upvalues for the scheduler so it’s important to hone in on how the scheduler goes through the queue. Tasks are executed after every render tick (RenderStepped). I’ve therefore concluded in assumption that this can’t be the case because the delta is <<1 second. Made doubly sure by changing to Heartbeat, the upper wait time increased so the pause time range became 3-5 seconds instead of 3-4 seconds.


Curious behaviour. I’m not too familiar with the technicals of Lua or best practices wrt execution times (I’ve been content ignoring them for a decade since I started programming on Roblox) so I’m having a go at being conscious of execution times using advice from several experienced developers but I’m hitting roadblocks and can’t seem to figure them out.

Right, this is part of why I was afraid about the SignalBehavior update. I still don’t really understand it but now I’ve grasped that it changes when things execute. Roblox said this would be relatively invisible and experienced developers seem to be converting easily now but people like me are having trouble making sense of the update or how to adapt new practices wrt new behaviour.

As there are plans to deprecate the Immediate behaviour eventually, it’s really crucial to start learning now how to update my practices so I am ready to convert and won’t be affected at all when Deferred becomes the standard for programming. I can also convert old projects to adhere to said standards. The problem is now trying to apply some of that advice into my projects. Tests that should make sense aren’t making any sense and even going back to Immediate SignalBehavior gives me illogical results.

:confused:

1 Like

I think I might be getting closer to finding the odd discrepancy here. The Promise and Task Scheduler implementations might be fine and it might be my test code. If the problem does indeed lie in my test code, then that circles back to my original question but with the added acknowledgement that the way I’m currently trying to pause between tasks is bad.

This time, I tried adding a quick benchmarker function at the top of my code using os.clock.

Test code with benchmarking
local lastBench = os.clock()

local function pushBench(label)
	local now = os.clock()
	print(tostring(label) .. ": " .. tostring(now - lastBench) .. " seconds")
	lastBench = now
end

pushBench("Test start / benchmarker setup time")

local ReplicatedFirst = game:GetService("ReplicatedFirst")
local TaskScheduler = require(ReplicatedFirst.TaskScheduler)
local Promise = require(ReplicatedFirst.Promise)

local PAUSE_TIME = 2

pushBench("Make declarations")

local scheduler = TaskScheduler:CreateScheduler(60)

pushBench("Create 60 FPS scheduler")

local firstTaskScheduleTime
local secondTaskScheduleTime

scheduler:QueueTask(function ()
	print("First task ran.")
	Promise.try(scheduler.Pause, scheduler):andThen(function ()
		return Promise.delay(PAUSE_TIME):andThenCall(scheduler.Resume, scheduler)
	end)
	firstTaskScheduleTime = os.clock()
end)
pushBench("First task queue time")
scheduler:QueueTask(function ()
	print("Second task ran.")
	secondTaskScheduleTime = os.clock()
end)
pushBench("Second task queue time")
scheduler:QueueTask(function ()
	print("Time between tasks: " .. tostring(secondTaskScheduleTime - firstTaskScheduleTime) .. " seconds")
end)
pushBench("Third task queue time")

Raw results:

21:36:33.246  Test start / benchmarker setup time: 2.0000152289867e-07 seconds
21:36:33.248  Make declarations: 0.0018344999989495 seconds
21:36:33.262  Create 60 FPS scheduler: 0.014190900023095 seconds
21:36:37.048  First task ran.
21:36:37.048  First task queue time: 3.7856951999711 seconds
21:36:37.048  Second task queue time: 9.5700030215085e-05 seconds
21:36:37.048  Third task queue time: 8.5399951785803e-05 seconds
21:36:41.032  Second task ran.
21:36:41.032  Time between tasks: 3.9836974000791 seconds

Most of the times here are variable and expected except for the queue time of my first task. To reiterate in plain view what the queueing of my first task looks like (happens immediately after creating the scheduler and declaring two variables to find the delta execution time):

scheduler:QueueTask(function ()
	print("First task ran.")
	Promise.try(scheduler.Pause, scheduler):andThen(function ()
		return Promise.delay(PAUSE_TIME):andThenCall(scheduler.Resume, scheduler)
	end)
	firstTaskScheduleTime = os.clock()
end)

I was close to chalking up the problem to the Promise but that wouldn’t make any sense. The Promise should not execute because the function is not called, thus making my results strange. The second and third task queue pretty much asynchronously as they should with barely time to get them in the scheduler but the first task takes an abysmal amount of time, nearly equivalent to the time I’m waiting to pause for. Commented out the Promise to check, still took 3 seconds to queue.

Next thought was to debug the time it takes to create a scheduler by declaring the current value of os.clock() immediately after TaskScheduler.CreateScheduler is called and print the difference just before the scheduler is returned to the caller script. The time it takes to create the scheduler is less than a tenth of a second, in some cases significantly smaller, so it’s giving me expected time frames.

This is where my problem lies. All my Lua-side code, regardless of SignalBehavior, is running at times that I’m fully expecting. The thing is, that first QueueTask takes three seconds to complete for whatever reason and then the Promise is delaying for 3-5 seconds when I specify it to delay for 2 seconds…

Problem with my machine maybe? Set up a Heartbeat test just in case.

local Heartbeat = game:GetService("RunService").Heartbeat

local PRINT_THRESHOLD = 1

Heartbeat:Connect(function (delta)
	if delta >= PRINT_THRESHOLD then
		print("Delta exceeds threshold (threshold: " .. tostring(PRINT_THRESHOLD) .. ", got " .. tostring(delta) .. ")")
	end
end)

Only ever exceeds the threshold once. Not a problem with my machine. Everything is logical and correct except the time it’s taking to queue the first task and the time Promise is delaying for.

1 Like

Decided to chuck in a wait(10) (gross) before I use CreateScheduler to simulate an environment where the client doesn’t use the TaskScheduler module until a later point in time (utter code smell, the client should be able to use the TS at any time without waiting an absurd amount of time).

I really think I must be misunderstanding how the scheduler works. In recent tests, including this one, it’s showing signs of synchronous behaviour not asynchronous behaviour. That meaning my tasks are running immediately after being queued. It’s not printing the queue time until the task runs. This then fundamentally breaks my problem of trying to figure out how I should add a pauser between tasks.

The results of waiting 10 seconds (again, code smell and sign of bad architecture) decreases the time it takes to queue the first time to 1 second from 3 which is a 2 second improvement but still not good enough. In all cases it should be near-instantaneous. Maybe there’s some initialisation going on that I’m not aware of? Why does it take a considerable amount of time to queue the first task?

Chucked in a wait(5) between TaskScheduler.CreateScheduler and the first QueueTask. No improvement to the former results, still takes a full second to queue the task. Worth noting that at this point I do have Promises commented out even though they should not affect the execution time.

So to reiterate:

  • All the libraries are fine. Working as expected, don’t see anything wrong with them.
  • Suddenly combining them, or even standalone, they are not producing the results I expect. Times are offset half a second to a full five seconds, even though they run on RunService events.

And my problems:

  • Trying to use the task scheduler as an alternative to wait. How do I add time between the execution of both tasks?
  • Now figuring out that the task scheduler takes ~1-3 seconds to queue my first task no matter how much yielding I do beforehand… why?

Considering a different approach of wrapping QueueTask in Promise instead. Not the first one, that shouldn’t need a wrapper at all, but the delayed task instead. I considered the problem that this might be the antithesis of a task scheduler and remove my control but it wouldn’t because this is all queue-based work. The only thing it’d change is going from immediately queueing the task to waiting before I queue the task. Still feels improper.

Found a fundamental flaw in my timing tests: I was declaring the time within the functions given to QueueTask instead of before and after. I do ought to check my code more carefully but this means I can backtrack a little bit with my progress.

Updated my benchmarking labels, removed irrelevant ones and made the labels more descriptive.

Test code with better benchmarks
local lastBench = os.clock()

local function pushBench(label)
	local now = os.clock()
	print(tostring(label) .. ": " .. tostring(now - lastBench) .. " seconds")
	lastBench = now
end

local ReplicatedFirst = game:GetService("ReplicatedFirst")
local TaskScheduler = require(ReplicatedFirst.TaskScheduler)
local Promise = require(ReplicatedFirst.Promise)

local PAUSE_TIME = 2

local scheduler = TaskScheduler:CreateScheduler(60)

pushBench("Script start -> Create scheduler")

local firstTaskScheduleTime
local secondTaskScheduleTime

firstTaskScheduleTime = os.clock()
scheduler:QueueTask(function ()
	print("First task ran.")
	Promise.try(scheduler.Pause, scheduler):andThen(function ()
		return Promise.delay(PAUSE_TIME):andThenCall(scheduler.Resume, scheduler)
	end)
end)
pushBench("Scheduler created -> First task queued")

secondTaskScheduleTime = os.clock()
scheduler:QueueTask(function ()
	print("Second task ran.")
end)
pushBench("First task queued -> Second task queued")

scheduler:QueueTask(function ()
	print("Time between tasks: " .. tostring(secondTaskScheduleTime - firstTaskScheduleTime) .. " seconds")
end)
pushBench("Second task queued -> Third task queued")

Raw results (SignalBehavior Immediate):

22:09:58.142  Time to create scheduler: 0.0017453000182286 (>> TS benchmark)
22:09:58.142  Script start -> Create scheduler: 0.0034701999975368 seconds
22:10:01.803  First task ran.
22:10:01.803  Scheduler created -> First task queued: 3.661091899965 seconds
22:10:01.803  First task queued -> Second task queued: 0.00013050006236881 seconds
22:10:01.804  Second task queued -> Third task queued: 0.00013289996422827 seconds
22:10:04.869  Second task ran.
22:10:04.869  Time between tasks: 3.661125499988 seconds

This one makes a little more sense. I guess the justification here is that it’s the first task in the queue so obviously it’d be ran immediately. Perhaps I should pause it before queueing tasks? Still don’t see why that’d be necessary.

Well, this became a convoluted two-problem thread that roots from attempting to do one thing. Going to take a break here, I don’t seem to be making any progress.

I feel like there is a misunderstanding on the role of the task scheduler, at least based on the use cases you’ve outlined in your original post. The task scheduler’s job is simply to make sure no part of a calculation takes up so much time that you’d see a framerate hit from its execution.

Delaying the queuing of each task until the desired timeframe should be what you need rather than pausing and resuming the scheduler. Pausing the scheduler would serve more to halt the execution of an egregiously large set of tasks to perform some other logic in the meantime, not delay individual task execution. That being said, the task scheduler you linked doesn’t seem to run on its own thread, so it will always produce synchronous results when used in the same thread that “woke it up.” I believe this could be mitigated through the use of promises, but it’s something to keep in mind when queuing multiple tasks up from the same thread. This is also why the first task is completing before being queued according to the output.

The delay you’re seeing in your execution does seem to be related to the establishment of the environment, as you had hypothesized. If I yield the thread for long enough that the environment stabilizes I get expected delays between everything.

I think your best bet for adding delays between each task would be to either queue the tasks using the desired delay with promises or to daisy-chain the delayed promises in each task. The former ensures stronger adherence to the timeframe laid out but does not respect the time it takes to perform each task, whereas the second is dependent on execution time but ensures each task happens appropriately relative to the tasks preceding/following it.

As for the change to deferred SignalBehavior, I don’t believe it should impact these systems too much since Heartbeat is labeled as an invocation point in the new system.

1 Like

I don’t really think that a task scheduler’s only purpose is performance reasons. I should be able to use it for any and all purposes, as does Roblox’s task scheduler. This is all in preparation for Deferred SignalBehavior and additionally gives me control over execution ordering.

For reference, exactly how long did you yield the thread for and where? I had yielded for a total of 15 seconds during initialisation and it still produced a timing that seemed unreasonable. Everything else was, except for Promise.delay which was also upped by a second, fine.

Thank you for the insight otherwise - it too started feeling wrong for me to be pausing and resuming the scheduler over a single task. I will try opting to change the time at which the task is scheduled instead of delaying it from within a task. Sounds like a significantly better plan.

You are correct in that you should be able to use a task scheduler for any purpose. I got hung-up on the performance-monitoring aspect of the linked framework.

In my runs, I yielded the thread for five seconds before the very first line of code, but since the establishment of the environment is dependent on so many internal and external factors, that may or may not be enough time for various machines. Even without yielding, though, the execution time between each task was what was defined with the promise to within about 0-3 Heartbeats (generally 1, which is about what you’d expect with the frameworks involved), just the creation of the task scheduler and the resuming of the main thread after queuing/executing the first task took longer than anticipated.

You had questioned pausing the scheduler before queuing tasks, and given its implementation, that would actually probably be what you’d want to do if you need to do more than one thing in a given “frame.” Heck, you could even modify the source so that it is initialized paused to save some effort if you’d like. Then, after queuing, you could resume the scheduler in either a new thread or the same thread if nothing else needed to be done. I’m honestly a bit surprised that the scheduler doesn’t run on its own thread by default, but I suppose there is some utility in that it gives stronger control over sequencing, maybe. Pausing to queue only helps you when the queue is empty, though, but it likely will be if you’re delaying queuing by anything longer than a few Heartbeats. Either way, the framework exposes GetQueueSize, which will tell you everything you need to make your decisions.

Except pausing to add the delay, there isn’t anything inherently wrong with how you’ve implemented your latest block, but it will feel the impact of its own execution time/throttling more severely over time than with tasks that are queued externally. A typewriter effect (or perhaps some comparable, more computationally-intense effect) may not care if it drifts a bit and would be suitable for the daisy-chain approach, but a choreographed cutscene will need to hit its marks with each set of actions being queued at the appropriate timeframe. It will depend on your use case, but even with externally-queued tasks, you’ll likely want to add drift management into their logic if timing is hyper-critical. Neither implementation will fully absolve that, it’s just that external queuing won’t exacerbate it the same way daisy-chaining will.