Performance

Lua Coroutines: Spread Work Across Deliberate Steps

Use a cooperative worker to process a bounded batch, handle resume failures, and understand what yielding does not guarantee.

Green and orange tracks crossing a stone bridge beside an hourglass
Original tutorial artwork · PixelDevHub
PixelDevHub editorial desk · Intermediate · 5 min read · Core Lua 5.4

Solve a scheduling problem you can measure

A long task can make a game feel unresponsive when it performs too much work before returning control. Before introducing coroutines, measure what the task actually spends time doing. A dense loop over cheap operations is a different problem from one expensive blocking service call. This tutorial uses a simple list-processing task because its boundaries are visible. The goal is to split the task into deliberate batches while keeping progress and completion easy for the caller to understand.

Coroutines provide cooperative execution. They are not a promise that the work runs in parallel or moves onto another processor core. They also do not supply an engine frame scheduler. Your host integration must decide when to resume the worker, how much time is available, and what to do when the owning scene closes.

Make each yield point a clear boundary

The worker below sums six values and yields after each complete group of two, except at the very end. Each yielded value reports progress rather than pretending the operation is finished. The final return contains the completed sum. A real game might replace the addition with a small preprocessing step, but the maximum cost of one step must still be understood. A single expensive operation can exceed your time budget even when the batch contains only one item.

local values = { 2, 4, 6, 8, 10, 12 }
local batchSize = 2
local worker = coroutine.create(function()
    local total = 0
    for index, value in ipairs(values) do
        total = total + value
        if index % batchSize == 0 and index < #values then
            coroutine.yield({ completed = index, partial = total })
        end
    end
    return total
end)

local ok, progress = coroutine.resume(worker)
assert(ok and progress.completed == 2 and progress.partial == 6)
ok, progress = coroutine.resume(worker)
assert(ok and progress.completed == 4 and progress.partial == 20)
local result
ok, result = coroutine.resume(worker)
assert(ok and result == 42)
assert(coroutine.status(worker) == "dead")

Treat resume as a fallible operation

Inspect the status returned by coroutine.resume before using the subsequent result. A worker can fail because its input is invalid or because an operation inside it raises an error. The result in that case is an error object, not a progress record. Keep failure handling near the owner of the task so you can stop scheduling it and communicate a useful outcome. Do not keep resuming a completed or failed worker as though it were waiting for another batch.

Connect to a real update loop deliberately

The example manually resumes the worker three times to make the sequence understandable. Calling resume repeatedly in a tight loop would still complete everything in one uninterrupted pass. To distribute work over updates, your host must schedule later resumes at appropriate times. Track whether a resume is already in progress and avoid giving multiple owners control of the same worker. Give the task a clear lifecycle, including a policy for scene changes, cancelled requests, and replaced work.

Protect consistency between batches

A yielded task can expose partial progress. Decide whether other systems may observe and modify that partial state. For an initial design, prepare results in private data and publish the final result only when all steps succeed. If incremental updates are necessary, make each published batch independently valid. Define what happens if the input collection changes while the task is suspended. A snapshot, a version check, or a controlled ownership rule can be easier to reason about than silently accepting arbitrary changes.

Exercise: add cancellation without leaking ownership

Add a cancellation flag that the worker checks at each batch boundary. Distinguish cancellation from a successful final result and from an execution error. Then test an empty list, a single item, an exact batch multiple, an injected failure, and cancellation after the first progress update. Keep cleanup in the owning integration: abandoning a coroutine does not automatically release unrelated listeners or scene resources. Use measurements from your actual device targets to choose the batch size instead of treating two items as a universal performance recommendation.

Language reference

These examples use core Lua rather than an engine API. Consult the official Lua 5.4 reference manual for the language contract. Embedded runtimes can differ in available libraries, loaders, and scheduling behavior.

← Explore the Lua library

Design the progress value as a small protocol

The owner of a cooperative task needs to distinguish progress from completion and failure. Keep the yielded value small and documented, with fields such as completed count and an operation identifier. Do not expose a large mutable work table unless callers are supposed to modify it. If several workers report to the same interface, use a consistent structure so the display does not depend on each implementation's private variables. The final result should also be distinguishable from a progress record. A simple, stable protocol makes the scheduling code easier to review and lets the worker change internally without forcing every consumer to learn its sequence of intermediate steps.

Decide what cancellation guarantees

Checking a cancellation flag at a batch boundary means cancellation is cooperative rather than immediate. Explain which work may already have happened when the owner requests a stop. If a task owns temporary data, decide where that data is released. If it publishes partial results, define whether those results remain valid after cancellation. Do not promise that abandoning a coroutine reverses scene changes or disconnects unrelated callbacks. Keep ownership in an integration object that can perform cleanup whether the worker finishes, fails, or is cancelled. This makes cancellation a documented state transition instead of an unexplained disappearance of work that another part of the game was waiting to receive.

Choose batch size from representative measurements

A fixed item count is convenient, but it should be checked against the actual cost of each item. Measure typical and unusually expensive batches on devices that reflect your audience. Include the overhead of preparing progress information and updating the interface. A very small batch can reduce individual pauses while creating excessive scheduling overhead; a very large one can preserve the original responsiveness problem. Keep the chosen value configurable within reasonable bounds and record why it was selected. Revisit the measurement when the task's implementation changes. The useful abstraction is a controlled amount of work between opportunities to yield, not a belief that any function wrapped in a coroutine automatically becomes inexpensive.

Find your next tool or idea

Search tools, guides, and Lua resources.

↑ ↓ Browse resultsESC to close

Choose a tool