Skip to content

thread-as-awaitable #7

@CluelessD3v

Description

@CluelessD3v

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

Image

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions