Coroutines and cooperative multitasking
Create, resume, yield and wrap coroutines, build producer-consumer pipelines, and implement the scheduler pattern game engines use.
create, resume, yield and wrap
local co = coroutine.create(function(a, b)
print("started with", a, b)
local c = coroutine.yield(a + b) -- suspends, returns this call's value to resume
print("resumed with", c)
return "done"
end)
print(coroutine.status(co)) --> suspended
print(coroutine.resume(co, 1, 2)) --> started with 1 2 / true 3
print(coroutine.status(co)) --> suspended
print(coroutine.resume(co, 10)) --> resumed with 10 / true done
print(coroutine.status(co)) --> dead
-- resuming a dead coroutine returns false and a message, it does not raise
print(coroutine.resume(co)) --> false cannot resume dead coroutine
-- wrap turns the coroutine into a plain function and rethrows errors
local gen = coroutine.wrap(function()
for i = 1, 3 do coroutine.yield(i) end
end)
print(gen(), gen(), gen()) --> 1 2 3
-- the running coroutine and the main thread
print(coroutine.isyieldable()) --> false in the main thread in 5.4
local main = coroutine.running()
print(type(main))
-- yield inside a nested call still suspends the whole coroutine
local outer = coroutine.create(function()
local function inner() coroutine.yield("from inner") end
inner()
return "after"
end)
print(coroutine.resume(outer)) --> true from innerresumereturnstrueplus the yielded or returned values, orfalseplus an error. It never raises, so every resume needs its first result checked.yieldmay not be called across a C boundary.pcall,table.sortand other library functions historically blocked it; in 5.4 many of them are yieldable, but a metamethod or iterator written in C is still a risk.wrapis convenient because it hides the status check, at the cost of throwing errors instead of returning them.- A coroutine is not a thread. Only one runs at a time, and it runs until it yields. There is no preemption and no parallelism.
Producer and consumer
-- a producer that yields instead of building a huge table
local function lines(path)
return coroutine.wrap(function()
for line in io.lines(path) do
coroutine.yield(line)
end
end)
end
-- a filter stage, also a coroutine
local function grep(pattern, source)
return coroutine.wrap(function()
for line in source do
if line:find(pattern) then coroutine.yield(line) end
end
end)
end
-- the consumer is a plain loop: the pipeline is lazy and uses constant memory
for line in grep("%d%d%d", lines("app.log")) do
print(line)
end
-- a two-way coroutine: a task that receives values and yields results
local calculator = coroutine.create(function()
local total = 0
while true do
local op, value = coroutine.yield(total)
if op == "add" then total = total + value end
if op == "set" then total = value end
end
end)
print(coroutine.resume(calculator)) --> true 0
print(select(2, coroutine.resume(calculator, "add", 5))) --> 5
print(select(2, coroutine.resume(calculator, "add", 3))) --> 8| Pattern | Shape | Trade-off |
|---|---|---|
| Iterator | Coroutine yields each value | Lazy, constant memory, one resume per step |
| Producer/consumer | Two or more coroutines chained | Clear stages, small cost per item |
| Scheduler | Many coroutines resumed each frame | Cooperative: a coroutine that never yields blocks everything |
| Error recovery | resume returns false | The coroutine is dead after an uncaught error |
| Snapshot | coroutine.wrap plus package.loaded | Not serialisable: a coroutine cannot be saved to disk |
A coroutine that errors cannot be resumed again. If a task must survive a failure, catch the error inside it with pcall and continue, or have the scheduler discard it and start a fresh one.
The scheduler pattern
-- the pattern every game framework and async library builds on
local Scheduler = {}
Scheduler.__index = Scheduler
function Scheduler.new()
return setmetatable({ tasks = {}, nextId = 0 }, Scheduler)
end
function Scheduler:spawn(fn, ...)
self.nextId = self.nextId + 1
local id = self.nextId
self.tasks[id] = {
co = coroutine.create(fn),
wakeAt = 0, -- run as soon as possible
args = table.pack(...),
}
return id
end
function Scheduler:cancel(id)
self.tasks[id] = nil
end
-- yield from inside a task to ask the scheduler for a delay
function Scheduler.sleep(seconds)
coroutine.yield("sleep", seconds) -- returns control to update()
end
function Scheduler:update(now)
for id, task in pairs(self.tasks) do
if now >= task.wakeAt then
local ok, request, delay = coroutine.resume(task.co, table.unpack(task.args, 1, task.args.n))
task.args = table.pack() -- pass arguments only on the first resume
if not ok then
print("task failed:", request)
self.tasks[id] = nil
elseif coroutine.status(task.co) == "dead" then
self.tasks[id] = nil
elseif request == "sleep" then
task.wakeAt = now + delay
end
end
end
end
-- usage inside a hypothetical game loop
local scheduler = Scheduler.new()
scheduler:spawn(function()
print("step 1")
Scheduler.sleep(1.0)
print("step 2 after a second")
end)
-- in the real loop: for each frame do scheduler:update(os.clock()) end💡
Cooperative scheduling means every task must yield. A task that runs a long loop without yielding freezes every other task and the frame. Add a
yield inside any loop that processes an unbounded amount of work.FAQ
Can I yield across pcall?
In Lua 5.4
pcall is yieldable, so it works. In 5.1 and LuaJIT, yielding across pcall raises an error; the usual workaround is to wrap the coroutine body instead of each call inside it.How much memory does a coroutine cost?
The stack starts small and grows on demand, so an idle coroutine costs relatively little compared with an OS thread. Tens of thousands are practical in a game loop; hundreds of thousands are not.
Related
Functions, closures and multiple returns Performance, garbage collection and LuaJIT
Last refreshed 2026-09-18.