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

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

Edit: this doesnt work with code that yields so nvm

Possible solution to overcome this

local function Run(Function, ...)
	return coroutine.wrap(Function)(...)
end

local function Spawn(Function, ...)
	local Success, Response = pcall(Run, Function, ...)

	if (not Success) then
		warn(tostring(Response))
		warn(debug.traceback())
	end
	
	return {Success = (Success and true or false), Response = Response}
end

Code i used to test, idk why its not formatting properly

Summary
local function Run(Function, ...)

return coroutine.wrap(Function)(...)

end

local function Spawn(Function, ...)

local Success, Response = pcall(Run, Function, ...)

if (not Success) then

warn(tostring(Response))

warn(debug.traceback())

end

return {Success = (Success and true or false), Response = Response}

end

local function Test1()

print("Example Error")

error("Example Error")

end

local function Test2(...)

print(...)

return ...

end

local function Test3()

print(2 + "a")

end

local Test1Response = Spawn(Test1)

print(Test1Response.Success, Test1Response.Response)

local Test2Response = Spawn(Test2, 1, 2, 3)

print(Test2Response.Success, Test2Response.Response)

local Test3Response = Spawn(Test3)

print(Test3Response.Success, Test3Response.Response)
1 Like

@blazepinnaker
I assume you mean create up to that many threads using spawn or coroutine? Or am I not understanding something?

Because I can literally run @gillern’s script that he posted above (with a few modifications :wink: ) 100k times.

local function Run(Function, ...)
	return coroutine.wrap(Function)(...)
end

local function Spawn(Function, ...)
	local Success, Response = pcall(Run, Function, ...)

	if (not Success) then
		warn(tostring(Response))
		warn(debug.traceback())
	end
	
	return {Success = (Success and true or false), Response = Response}
end


function Hello()
	while wait(1) do
		--print("Hello")
	end
end

for i = 1, 10 do -- Create 10,000 threads 10 times using corotine.wrap = 100,000 threads when complete
	wait(1)
	for i = 1, 10000 do
		Spawn(Hello)
	end
	print("Created: "..(i * 10000).." Threads")
end
print("Done")

With zero noticeable render lag.

Or am I not understanding something?

I think this thread has gotten a little over technical and the bigger picture needs to be looked at. Fundamentally, spawn has a huge issue with it that coroutines don’t. As long as you are programming a game responsibly, you won’t have an issue with coroutines - this isn’t true of spawn.

For what it’s worth, whilst coroutines sound scary to newer developers, they aren’t. You can still keep a very simple pattern, basically the same as spawn, with a minor difference, with coroutines.

--These both do the same thing
spawn(function()
   print("Hello, world!")
end)

coroutine.wrap(function()
   print("Hello, world!")
end)()

The only difference is the () at the end of the coroutine.wrap. Thats because coroutine.wrap returns the thread, so it needs to be called like a function to be executed. But you can basically treat it like an extra bit of syntax you just need to get your code to run.

Watch out for it though, because if you miss out the (), then the code won’t run, but it also won’t error.

10 Likes