luaguides

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