Cooperative Multitasking Patterns
Lua’s coroutine library gives you cooperative multitasking without any external dependencies. Tasks voluntarily yield control, letting you build non-blocking workflows, custom iterators, and pipelines that feel natural alongside Lua’s table-based data structures.
This guide covers the patterns that show up repeatedly in real Lua code: producer-consumer, round-robin scheduling, and staged pipelines. By the end you’ll know when to reach for each one and how to avoid the pitfalls that catch first-time coroutine users.
how cooperative multitasking works in Lua
Preemptive multitasking (what your OS does with threads) means the scheduler can interrupt any running code at any time. Cooperative multitasking is different: a running task keeps control until it explicitly gives it up by calling coroutine.yield(). Nothing outside the coroutine can pause it.
This has a direct implication: if a coroutine runs an infinite loop and never yields, it blocks everything else. Your scheduler gets no CPU time because the greedy task never yields.
Each coroutine exists in one of four states:
- suspended: created but not yet run, or has yielded
- running: currently executing
- dead: finished; cannot be resumed
- normal: active but running another coroutine from within (rare edge case)
You can inspect a coroutine’s state with coroutine.status(co).
The Core API: create, resume, yield
Three functions form the foundation. coroutine.create(f) wraps a function in a new thread and returns it in the suspended state, so the function does not run yet. To start it, call coroutine.resume(co). Inside the coroutine body, call coroutine.yield() to pause and return control to whoever called resume.
The key behavior most people get wrong: the first resume starts the coroutine from the beginning, not from where it last yielded. Each subsequent resume continues from after the last yield. The values you pass to yield become the return values of the corresponding resume call.
co = coroutine.create(function ()
print("starting")
coroutine.yield()
print("resumed")
return "finished"
end)
coroutine.resume(co) --> starting
coroutine.resume(co) --> resumed
local ok, val = coroutine.resume(co)
print(ok, val) --> true finished
coroutine.wrap(f) gives you a different interface. It returns a callable that, when invoked, resumes the wrapped coroutine. Errors propagate as Lua errors rather than being returned as false, err. Use it when you want a simpler calling convention and your error handling happens at the call site, not via return value inspection.
local wrapped = coroutine.wrap(function (x)
if x < 0 then error("negative") end
return x * 2
end)
print(wrapped(5)) --> 10
pcall(wrapped, -1) --> error: negative
the producer-consumer pattern
This pattern appears constantly: one task generates values, another processes them. The producer yields each value, the consumer drives the producer forward by calling resume repeatedly.
The key insight is that the consumer controls the pace. When the consumer stops calling resume, the producer stops running. This makes backpressure automatic: a slow consumer naturally slows down the producer without any explicit signaling.
local function producer(max)
for i = 1, max do
coroutine.yield(i) -- send one value, pause
end
return nil -- signal completion
end
local function consume(prod)
local results = {}
while true do
local ok, value = coroutine.resume(prod)
if not ok then
error("producer failed: " .. value)
end
if value == nil then
break
end
table.insert(results, value)
end
return results
end
local p = coroutine.create(producer)
local output = consume(p)
for i, v in ipairs(output) do
print("consumed:", v)
end
Notice how the consumer drives the loop entirely by calling resume. When the producer returns nil, the consumer stops. The producer never has to know anything about the consumer’s state.
You can flip this: instead of the consumer pulling values, have the producer push to a consumer coroutine that processes each item as it arrives. Both approaches work; the pull model is easier to reason about, while the push model suits scenarios where latency matters.
a round-robin task scheduler
When you have multiple tasks that should share CPU time, a scheduler runs them in rotation. Each task yields after a small unit of work, and the scheduler cycles through the list, resuming any task that is still suspended.
local function run(tasks)
local running = {}
for _, task_fn in ipairs(tasks) do
local co = coroutine.create(task_fn)
table.insert(running, co)
end
while #running > 0 do
local still_running = {}
for _, co in ipairs(running) do
if coroutine.status(co) == "suspended" then
local ok = coroutine.resume(co)
if ok then
table.insert(still_running, co)
end
end
end
running = still_running
end
end
run({
function ()
for i = 1, 4 do
print("task A:", i)
coroutine.yield()
end
end,
function ()
for i = 1, 4 do
print("task B:", i)
coroutine.yield()
end
end,
})
-- Output:
-- task A: 1
-- task B: 1
-- task A: 2
-- task B: 2
-- task A: 3
-- task B: 3
-- task A: 4
-- task B: 4
This scheduler removes a task from the list as soon as it finishes (status becomes dead after the final resume). When all tasks complete, the loop exits.
Add a task priority by calling resume on higher-priority coroutines more than once per cycle. Or add a quantum: each task gets a fixed number of yields before being moved to the back of the queue. This is how round-robin scheduling works in operating systems, just with coroutines instead of threads.
Building a Pipeline
A pipeline chains stages where each stage is a coroutine that receives input, processes it, and yields output. Stage 1 feeds Stage 2, Stage 2 feeds Stage 3, and so on. Each stage runs concurrently with its neighbors. When Stage 1 yields a result, Stage 2 can be processing the previous result while Stage 3 processes the one before that.
local function pipeline(source, ...)
local stages = {...}
local pipe = source
for _, stage_fn in ipairs(stages) do
local next_pipe = coroutine.create(stage_fn)
local first = true
while true do
local ok, value
if first then
ok, value = coroutine.resume(next_pipe)
first = false
else
ok, value = coroutine.resume(next_pipe, pipe)
end
if not ok then
error("stage error: " .. value)
end
if coroutine.status(next_pipe) == "suspended" then
pipe = value
else
break
end
end
end
return pipe
end
local source = coroutine.create(function ()
for _, v in ipairs({1, 2, 3, 4, 5}) do
coroutine.yield(v)
end
end)
local stage1 = function (input)
local n = coroutine.yield(input * 10)
return n
end
local stage2 = function (input)
return input + 1
end
local result = pipeline(source, stage1, stage2)
print(result) --> 51 (5 * 10 + 1)
The pipeline pattern becomes powerful when stages do I/O. Each stage blocks only on its own I/O while others continue working. An HTTP request handler, a file reader, and a response serializer can all run concurrently within the same pipeline.
common mistakes and how to avoid them
Trying to resume a dead coroutine. Once a coroutine finishes, you cannot resume it again. Calling resume on a dead coroutine returns false, "cannot resume dead coroutine". Check coroutine.status(co) before resuming if you need to handle this case.
Confusing yield/return values. When you call yield(v1, v2, v3), those values are returned by resume on the next call, not the current one. The current resume call returns whatever arguments the previous yield received. Trace through the sequence carefully when writing consumer code.
Forgetting to yield in loops. A coroutine with an infinite loop that never yields blocks the entire scheduler. Always yield inside loops, even if it’s just a condition check:
local function fragile_task()
local running = true
while running do -- never yields!
process_one_item()
end
end
local function safe_task()
local running = true
while running do
local again = process_one_item()
if not again then break end
coroutine.yield() -- give scheduler a chance
end
end
Errors in wrap vs resume. coroutine.wrap turns errors into Lua errors that propagate up the call stack. If you use wrap, wrap the call in pcall or you’ll crash the whole program. Plain resume returns errors as false, err, giving you the chance to handle them gracefully.
deciding between patterns
Use producer-consumer when one side generates values and the other processes them, and the consumer’s pace should control the producer.
Use round-robin scheduling when you have multiple tasks that should make progress in parallel and each task’s work can be split into small chunks that yield regularly.
Use a pipeline when you have a sequence of transformations to apply to a stream of data and each stage is independent. This is especially useful in data processing workflows, game entity update systems, or web request handling chains.
All three patterns share the same underlying mechanism: coroutine yields and resumes. Mixing them is natural. A pipeline stage might contain a producer-consumer sub-pattern. A scheduler might manage pipeline coroutines alongside simple task coroutines.
See Also
- Producer-Consumer with Coroutines — the previous tutorial in this coroutine series
- Coroutine Wrappers and Iterators — the next tutorial in this coroutine series
- Coroutine Basics: Create, Resume, Yield — the foundational coroutine API
- pcall — handling errors around wrapped coroutine calls
- Lua Iterators Guide — building custom iterators with coroutines