thread-as-awaitable
Summary
Allow thread values returned by task.spawn, task.defer, and similar
functions to be passed directly to awaiting utilities like resumeAfterAll,
so that callers can synchronize on a thread's completion without any manual
signalling infrastructure.
Motivation
Roblox's task library makes spawning concurrent work easy, but there is no
first-class way to wait for a spawned thread to finish. Right now, if you
want to know when a thread is done, you have to build the notification yourself.
The most common pattern looks like this:
local function asyncFunction()
local ended = LemonSignal.new()
local process = task.defer(function()
while task.wait() do
-- work...
end
ended:Fire()
end)
return {
destroy = function()
task.cancel(process)
ended:Destroy()
end,
ended = ended,
}
end
local process = asyncFunction()
ConnectUtil.resumeAfterAll({ process.ended })
coroutine.yield()
It works, but it's a lot of plumbing for something that should be simple. You
have to create a signal, remember to fire it, remember to destroy it, and expose
it on a wrapper object — all just to say "tell me when this is done."
The goal of this proposal is to make that last step unnecessary. If the RFC is
adopted, the same thing looks like:
local function asyncFunction()
return task.defer(function()
while task.wait() do
-- work...
end
end)
end
ConnectUtil.resumeAfterAll(asyncFunction())
coroutine.yield()
Design
Passing a thread to resumeAfterAll
resumeAfterAll adds a new branch for type(v) == 'thread'. When it sees a
thread, it spawns a lightweight watcher that polls until the thread is dead,
then calls tryResume as normal:
elseif type(v) == 'thread' then
task.defer(function()
while coroutine.status(v) ~= 'dead' do
task.wait()
end
tryResume()
end)
From the caller's perspective, a thread behaves exactly like any other
awaitable — you pass it in, yield, and your code resumes when it's done.
No wrapper object, no signal, no destructor.
What async functions look like
Before this change, an async primitive had to carry a signal to expose its
lifetime. After this change, it can just return its thread:
Before (workaround, still valid for cases that need explicit cancellation):
local function asyncFunction()
local ended = LemonSignal.new()
local process = task.defer(function()
-- work...
ended:Fire()
end)
return {
destroy = function() task.cancel(process); ended:Destroy() end,
ended = ended,
}
end
After (with this RFC):
local function asyncFunction()
return task.defer(function()
-- work...
end)
end
ConnectUtil.resumeAfterAll(asyncFunction(), tween, signal)
coroutine.yield()
Threads mix freely with the other types resumeAfterAll already accepts.
Cancellation behaviour
If a thread is cancelled with task.cancel before it finishes naturally,
coroutine.status returns 'dead' straight away. The watcher sees this
on its next tick and calls tryResume. In other words, cancellation counts
as completion. If you need to tell the difference between "finished" and
"cancelled", the signal-based pattern is still the right tool for that.
Drawbacks
- Polling. The watcher loop ticks at
task.wait() resolution (roughly once
per frame). For a handful of long-running threads this is fine. For a large
number of short-lived threads created all at once it adds up.
- Cancellation is indistinguishable from completion. The API has no way to
surface why a thread ended, only that it did.
Alternatives
Keep the signal wrapper (status quo)
The ended signal pattern is the more explicit and readable option — you get a
named .ended event, a clear .destroy method, and a self-contained object
that describes its own lifetime. For most codebases this is perfectly pleasant
to write and easy to follow.
The argument for this RFC is not that the wrapper is unpleasant — it's that
it carries real cost: a signal allocation, a Destroy obligation, and a wrapper
table on every async primitive, purely to expose information (thread lifetime)
that the runtime already tracks. For performance-conscious developers, that's
plumbing that exists only because threads can't be awaited directly.
Promises / Futures
Do nothing
Callers keep using the wrapper pattern. It's not broken, but the wrapper exists
solely because threads can't be awaited directly — not because it carries any
useful state on its own. Removing that reason to exist is the point of this
proposal.
thread-as-awaitable
Summary
Allow
threadvalues returned bytask.spawn,task.defer, and similarfunctions to be passed directly to awaiting utilities like
resumeAfterAll,so that callers can synchronize on a thread's completion without any manual
signalling infrastructure.
Motivation
Roblox's task library makes spawning concurrent work easy, but there is no
first-class way to wait for a spawned thread to finish. Right now, if you
want to know when a thread is done, you have to build the notification yourself.
The most common pattern looks like this:
It works, but it's a lot of plumbing for something that should be simple. You
have to create a signal, remember to fire it, remember to destroy it, and expose
it on a wrapper object — all just to say "tell me when this is done."
The goal of this proposal is to make that last step unnecessary. If the RFC is
adopted, the same thing looks like:
Design
Passing a thread to
resumeAfterAllresumeAfterAlladds a new branch fortype(v) == 'thread'. When it sees athread, it spawns a lightweight watcher that polls until the thread is dead,
then calls
tryResumeas normal:From the caller's perspective, a thread behaves exactly like any other
awaitable — you pass it in, yield, and your code resumes when it's done.
No wrapper object, no signal, no destructor.
What async functions look like
Before this change, an async primitive had to carry a signal to expose its
lifetime. After this change, it can just return its thread:
Before (workaround, still valid for cases that need explicit cancellation):
After (with this RFC):
Threads mix freely with the other types
resumeAfterAllalready accepts.Cancellation behaviour
If a thread is cancelled with
task.cancelbefore it finishes naturally,coroutine.statusreturns'dead'straight away. The watcher sees thison its next tick and calls
tryResume. In other words, cancellation countsas completion. If you need to tell the difference between "finished" and
"cancelled", the signal-based pattern is still the right tool for that.
Drawbacks
task.wait()resolution (roughly onceper frame). For a handful of long-running threads this is fine. For a large
number of short-lived threads created all at once it adds up.
surface why a thread ended, only that it did.
Alternatives
Keep the signal wrapper (status quo)
The
endedsignal pattern is the more explicit and readable option — you get anamed
.endedevent, a clear.destroymethod, and a self-contained objectthat describes its own lifetime. For most codebases this is perfectly pleasant
to write and easy to follow.
The argument for this RFC is not that the wrapper is unpleasant — it's that
it carries real cost: a signal allocation, a
Destroyobligation, and a wrappertable on every async primitive, purely to expose information (thread lifetime)
that the runtime already tracks. For performance-conscious developers, that's
plumbing that exists only because threads can't be awaited directly.
Promises / Futures
Do nothing
Callers keep using the wrapper pattern. It's not broken, but the wrapper exists
solely because threads can't be awaited directly — not because it carries any
useful state on its own. Removing that reason to exist is the point of this
proposal.