Coroutines V.S. Spawn()... Which one should I use?

In the Aquaman event game that evaera and I worked on in 2018, we saw spawn resumes taking as much as 15 seconds or more rather than the next couple of frames due to there being a budget for resuming threads. This is because we spawned a medium number of threads since we used in all Promise(-like) patterns in the game. Performance was not affected at all (game ran at 60FPS otherwise and other subsystem load (such as physics) did not affect the resume delays), the budget just seems very easy to exhaust when you’re doing anything of some complexity.

After switching from spawn to coroutine.wrap / firing a BindableEvent to start a new thread, and using a Heartbeat-based wait rather than wait(), all slowdown issues were resolved because all of the threads we spawned now ran within an expected time frame.

I would link you to engineers mentioning information on how this worked publicly, but unfortunately I think I’ve only talked to them about this in private channels, so I can’t link to any comments. I can assure you there is actually a resume budget and that problems like these can actually happen to you in a production game. It is guaranteed to resume at least 1 thread in the queue every resume cycle, but that’s about all the guarantee you get.

Save yourself some horrible nights of debugging and don’t use spawn / delay / wait. You want your threads to start predictably, the convenience of spawn is not worth it for serious projects.

39 Likes

Coroutines will eat up your stack info. Say goodbye to effective debugging. Use spawn if you can (except please consider @buildthomas’s point above) . If you need it to run instantly, use @Quenty’s trick to do a “fast” spawn by firing a RemoteEvent. I’ve been using this for mostly everything now for the sake of having accurate tracebacks.

22 Likes

What’s that? Never heard of it.

1 Like

I should have called it a stack trace instead. When an error is thrown, it will usually show the stack trace which walks back to where the error occurred and subsequent calls that lead up to the error. You’ll also hear it called a traceback.

When you see an error in Roblox, you will see it in red, and the traceback below it is in blue.

For various reasons, using coroutines really messes with this process and makes it really hard to do effective debugging in larger applications.

17 Likes

I’ve tested by making a lot of spawn and it does indeed becomes slower with a larger amounts of Threads, I didn’t expected coroutine.wrap, coroutine.resume to be superior

This is new info to a lot of us and frankly terrifying to hear such a horrible side effect of Built-In functions that were never ever mentioned officially…

So if I can’t use wait and delay what do I replace it with?
A Custom Heartbeat Wait?

8 Likes

If you need really reliable timing, yes, use a Heartbeat-based wait.

For spawning threads reliably see Crazyman32’s comments above.

3 Likes

Coroutines can only be used once. I’m not sure if it’s the same for spawn as I refrain from using it. Coroutines are probably most useful for your uses.

edit: ran once

if i made a mistake please cut me some slack, i don’t code much with lua.

That sounds like a huge glaring flaw with the budgeter more than anything.

2 Likes

spawn destroys the stack trace the same as coroutines do. Errors that occur after a yield on a new thread will get a new call stack. spawn is always after a yield, which means it always destroys the trace. So this can’t be seen as an advantage of spawn, because they both have this behavior. Never use spawn.

My Promise library has a custom scheduler built-in for Promise.delay. This is what I use in my games if I want to wait an amount of time. The Promise pattern isn’t for everyone, but if you haven’t heard of it I think you should at least look into it.

4 Likes

honestly 90% of the time people use coroutines or spawns, could be made into simple bindable events and functions hooked up to them (even one time functions, they can be disconnected)
i love bindables, other than that i usually use coroutines, heard spawn is just old and never really experimented with it

3 Likes

@buildthomas You mentioned spawning medium amounts of functions/threads. But would performance be negatively impacted by one or two being spawned lightly (what I mean is not very often)?

@ashcide You can run coroutines more than once.
Many guys just go for:

coroutine.wrap(function()
 --ETC
--action happened
coroutine.yield()
end)()

As that’s quick and easy (and doesn’t emit an error message when you forget coroutine.yield() in it)
But other things can be like:

local f = coroutine.create(function() 
                               --ETC 
                           end)
coroutine.resume(f)
--action happened
coroutine.yield(f)
--action happened
coroutine.resume(f)

The one issue I have with coroutine is that I never know if the wrapped ones I make are deleted when they’re yielded/errored.


Looks like the new trend for programmers nowadays is to skip using the old architecture. Might as well make my own personal lib of things I need from Run Service like pause, etc.

Though I will say that maybe the engineers should do something as it would make our lives a little easier if wait() was just hooked up to runservice instead of old tech. That way we wouldn’t need to reference it in every script if we needed reliable threading.

3 Likes

You won’t run into issues with that few amount of threads, but why maintain bad habits? :slightly_smiling_face:

1 Like

Can you please go into more detail on why we shouldn’t use wait() besides “use My Promise library”

13 Likes

As noted previously, spawn waits for 1/30th of a second (equal to wait()) whereas coroutine.create/resume does not. Coroutine.wrap however when called will yield the parent thread until the child coroutine yields or returns a result meaning wrapping does not function as an instantaneous replacement for spawn.

Here is an example of a minimal “fast spawn” implementation (one that does not yield):

local function fastSpawn(func, ...)
	local thread = coroutine.create(func)
	coroutine.resume(thread, ...)
	return thread
end

Coroutines have tons of applications where they may not seem fit. Coroutine.wrap will create a c function which is important to note when creating environments disguised as real Roblox environments. Coroutines also block any environment bubbling (and debug information sadly) from getfenv.

Here’s some more extremely useful coroutine utility stuff: (My favorite is the Pointer function!)

local function Pointer(...) -- A "pointer" to a vararg. Performant, small, and stupidly useful!
	local func = coroutine.wrap(function(func, ...) -- Hold the vararg and wrapped function (to return via yield)
		coroutine.yield(func) -- Yield the wrapped function
		return ... -- Return the full vararg once resumed again
	end) -- Create a wrapped function to hold the vararg in memory
	return func(func, ...) -- Call the function (to start the coroutine and literally hold the vararg in memory)
end
-- Example of above:
local myPtr = Pointer("a", "b", 123)

print(myPtr()) -- Prints each variable ("a", "b", 123)
-- Output: a b 123

local function cwrap(func) -- Wraps a function so it behaves exactly like it did previously but it is now wrapped within a coroutine (and thus appears like a c function to Roblox and user code)
	return coroutine.wrap(function() -- A wrapper function
		local results = Pointer() -- Empty pointer for function results
		while true do
			results = Pointer(func(coroutine.yield(results()))) -- Repeatedly yield results pointer and call a function with custom args
		end
	end)
end
-- Currently, this is most likely impossible for any modern exploits to escape from. Don't go around using this for client security though >:(

local func = cwrap(function(...)
	print(...)
	return true
end)
func("123") -- Prints 123, returns true
func("abc") -- Prints abc, returns true
14 Likes

I would use coroutine.create() and coroutine.resume() rather than spawn() because spawn waits the same time as wait(). That’s also because coroutine is better than spawn is my opinion and my coding style prefers coroutines over spawn.

Make sure you read this article very closely before choosing your approach:

4 Likes

So, after some experimentation and investigation, from what I can tell coroutine and spawn are pretty much equal in terms of performance. The only real difference is that spawn has a fixed budget (0.1s) and threads can only execute every 1/30s at most.

That said, if you use coroutines, you still need to be wary of blowing your implicit frame budgets as well or FPS will drop. So, I’m not sure you’ve really won that much.

In fact, found I had to create a custom task scheduler. Not a hard task, and I definitely prefer mine to the one I can’t control. So, if the win is that you’re forced to write your own custom task scheduler - ok, agreed. Always good for folks to know what they’re doing.

That said - I think spawn for folks who are not doing much processing per second and don’t want to use their own custom task scheduler with frame budgets, it actually works pretty well. Do keep an eye on those values returned by wait() however. wait() lag is less visible than FPS lag so it can sneak up on you as per above. In particular, don’t do more processing than 0.1s per second or 0.015ms per frame, though there is actually flexibility in that TBH. Use the microprofiler to verify what’s happening is what you expect.

Also, to be clear, the problem isn’t so much “threads” (coroutines, whatever) but rather processing time in the threads. I can get up to 500 threads per frame (1/60s) assuming I don’t do anything in the threads. More logic in the threads would reduce this accordingly to fit the 0.015s frame budget.

For this reason, I’m a bit confused by the story above and how you were seeing 15s lag with spawn but when you switched them to coroutine you didn’t see any fps drop.

1 Like

coroutines are the way to go. There have been several instances of large-scale games (namely Vesteria) that used to use spawn() for threading; sometimes there would be up to a 15-second delay in the spawn() function actually begin to run. This is because spawn() only runs the next time that Roblox’s task scheduler updates.

3 Likes

I’d like to see the specific technical details behind that. There must have been a frightful amount of processing for that to happen. I can schedule up to over 100K threads (executing every 3 seconds) via spawn with no wait lag.

Perhaps that was a pre-optimized Lua VM problem?

You can try it here, use microprofiler to verify, click on the top button.

image

612 call counts * 60 is about 36000 different threads called per second, 100K different threads over 3 seconds.

Using spawn for absolutely all threading scenarios is obviously not going to work. Using it correctly for the right scenarios makes perfect sense.

3 Likes

Yes, has anyone here that in the past observed the 15s delays tried any of that old code
post-introduction of Luau? It would be good to know if spawn() is not so bad now.

1 Like