Lua Coroutines: Sequential Logic Without Threads or Callbacks
Lua coroutines let you write sequential code that suspends and resumes, avoiding callback hell without the overhead of OS threads. Here is how they work and where they fall short.
30 Dec 2025, 01:04 UTC

You need to process a stream of records: read a chunk, transform it, write the result. The I/O layer is non-blocking, so you cannot simply call a function and wait. If you use callbacks, your linear logic fragments into nested closures. If you spawn OS threads, you pay for context switches, locking, and platform-specific debugging. Lua coroutines offer a middle path—functions that can pause mid-execution, hand control back to a scheduler, and resume later exactly where they stopped.
The useful takeaway: coroutines are not threads. They are stackful, first-class functions that suspend only at explicit coroutine.yield points. They make sequential code readable in cooperative environments, but they will not make CPU-bound work faster.
Cooperative, Stackful, and First-Class
A Lua coroutine has its own call stack. That means it can suspend from inside nested function calls, not just at the top level. Because coroutines are first-class values, you can store them in tables, pass them to functions, and return them from iterators.
They are also strictly cooperative. The runtime never preempts a running coroutine. Control changes hands only when the coroutine calls coroutine.yield or when it returns. If a coroutine enters a tight loop or calls a blocking C function, the entire scheduler stalls until that work finishes.
Lua gives you two interfaces. coroutine.create returns a thread object; you drive it with coroutine.resume and coroutine.yield, and every resume returns a success flag plus any yielded values. coroutine.wrap returns a plain function: calling it resumes the coroutine, and errors propagate directly to the caller instead of returning a status flag. Use create when you need to inspect coroutine.status or manage many coroutines; use wrap for simple generator-style iterators.
Worked Example: A Two-Stage Pipeline
Suppose you have a producer that generates tasks and a consumer that processes them. You want the consumer to pause between items so other work can run. With coroutines, both stages look like ordinary sequential code.
-- producer: yields four task strings
local function producer()
for i = 1, 4 do
coroutine.yield("task-" .. i)
end
end
-- consumer: pulls from the producer, yields control after each item
local function consumer(source)
while true do
local ok, task = coroutine.resume(source)
if not ok then error(task) end
if coroutine.status(source) == "dead" then break end
print("consuming", task)
coroutine.yield() -- let the scheduler run other coroutines
end
end
-- driver: alternate between producer and consumer
local src = coroutine.create(producer)
local worker = coroutine.create(consumer)
while coroutine.status(worker) ~= "dead" do
local ok, err = coroutine.resume(worker, src)
if not ok then error(err) end
end
Save this as pipeline.lua and run it with lua pipeline.lua (or lua5.4 pipeline.lua, depending on your installation). No special permissions are required; the script executes entirely in a local interpreter. The expected output is four lines, consuming task-1 through consuming task-4, in order.
The key detail is the status check. After the producer yields its last value, coroutine.status(src) is still "suspended". Only after the final resume—when the producer function returns—does the status become "dead". Checking that flag prevents an extra, empty iteration.
The Limits of Cooperation
Coroutines do not provide parallelism. They run on a single OS thread and a single core. A CPU-intensive coroutine will block every other coroutine until it yields or finishes. If you need to saturate multiple cores, Lua coroutines are the wrong tool; look at separate processes or a C extension that manages native threads.
Yielding across C boundaries is restricted in some Lua versions and embedding contexts. A coroutine that yields from inside a C function—such as certain library metamethods or C-based iterators—may fail. The exact rules vary between Lua 5.1, 5.3, and 5.4, and later versions refined helpers like coroutine.isyieldable and coroutine close semantics. Test any yield-across-C pattern against your target interpreter.
Error handling also behaves differently. When a coroutine raises an error, the error does not unwind the caller's stack. Instead, coroutine.resume returns false followed by the error message. The resumer must decide whether to retry, log, or propagate. Forgetting to check that status flag is a common source of silent failures.
Finally, a suspended coroutine holds its entire stack and any captured upvalues in memory. If you create a coroutine and never resume it to completion, those resources remain allocated until the garbage collector reclaims the coroutine object. In long-running schedulers, track your coroutines and ensure they reach "dead" status.
Checking Your Work
To verify the example locally, add a status print after each resume:
print(coroutine.status(worker))
You should see "suspended" while the worker is alive and "dead" after the loop exits. To test error handling, insert error("boom") inside the producer and confirm that coroutine.resume returns false rather than crashing the driver.
If you are embedding Lua in a C application, write a small test that yields from a coroutine invoked by your C host. If the yield fails, you will need to restructure the call so the coroutine yields only from pure Lua frames, or upgrade to a Lua version whose manual explicitly supports the boundary you need.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.