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 inner
  • resume returns true plus the yielded or returned values, or false plus an error. It never raises, so every resume needs its first result checked.
  • yield may not be called across a C boundary. pcall, table.sort and 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.
  • wrap is 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
PatternShapeTrade-off
IteratorCoroutine yields each valueLazy, constant memory, one resume per step
Producer/consumerTwo or more coroutines chainedClear stages, small cost per item
SchedulerMany coroutines resumed each frameCooperative: a coroutine that never yields blocks everything
Error recoveryresume returns falseThe coroutine is dead after an uncaught error
Snapshotcoroutine.wrap plus package.loadedNot 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.

Functions, closures and multiple returns Performance, garbage collection and LuaJIT

Last refreshed 2026-09-18.