luaguides

Async Patterns with Coroutines

Coroutines are one of Lua’s most powerful features, enabling you to write asynchronous code that feels synchronous. Unlike threads in other languages, Lua coroutines are cooperative—they voluntarily yield control, making them perfect for handling non-blocking operations without the complexity of preemption.

This tutorial covers the fundamentals of coroutines and shows you practical async patterns you can use in your Lua projects.

Understanding coroutines

At their core, coroutines are functions that can pause and resume their execution. Think of them like bookmarking your place in a function and coming back to it later.

Creating and running a coroutine

The simplest way to understand coroutines is to see one in action:

local co = coroutine.create(function()
  print("First line")
  coroutine.yield()
  print("Third line")
end)

print("Before resume")
coroutine.resume(co)  -- Prints: First line
print("After first resume")
coroutine.resume(co)  -- Prints: Third line

The coroutine.create() function creates a new coroutine, and coroutine.resume() starts or resumes it. When the coroutine hits coroutine.yield(), it pauses and returns control to the caller.

Notice that the first resume call only reaches the yield statement — it never hits the print("Third line") line. You need a second resume call to continue past the yield point and reach the end of the function. After the final resume, the coroutine transitions to a dead state and cannot be resumed again.

Checking coroutine status

You can always check what a coroutine is doing:

local co = coroutine.create(function()
  coroutine.yield()
end)

print(coroutine.status(co))  -- Prints: suspended
coroutine.resume(co)
print(coroutine.status(co))  -- Prints: running
coroutine.resume(co)
print(coroutine.status(co))  -- Prints: dead

A coroutine can be in one of four states: suspended, running, dead, or normal (when it’s resumed another coroutine). Checking the status before calling resume prevents errors from attempting to resume a dead coroutine, which produces a false, "cannot resume dead coroutine" return.

Exchanging data between coroutines

One of the most useful features is the ability to pass data both ways through resume and yield.

Passing arguments to coroutines

local co = coroutine.create(function(a, b, c)
  print("Received:", a, b, c)
end)

coroutine.resume(co, 1, 2, 3)  -- Prints: Received: 1 2 3

Arguments travel from the resumer into the coroutine on the first call to resume. Any extra arguments beyond what the coroutine function expects are silently discarded — Lua does not complain about excess arguments. On subsequent resumes, those extra arguments become the return values of coroutine.yield() inside the coroutine, which is how the data flow reverses direction. Getting comfortable with this push-pull mechanism is essential before moving to patterns that pass data in both directions simultaneously.

Receiving data from yield

local co = coroutine.create(function()
  local values = coroutine.yield()
  print("Got:", values[1], values[2])
end)

coroutine.resume(co)
coroutine.resume(co, {"hello", "world"})  -- Prints: Got: hello world

Passing a table through resume lets you send structured data into a suspended coroutine. The values arrive as the return of coroutine.yield(), which is why the code unpacks values[1] and values[2] from a table. Lua tables offer a natural container for bundling multiple values, and coroutines accept them without any special wrapping.

This bidirectional data flow is the foundation for building sophisticated async patterns.

Producer-consumer pattern

One of the most common async patterns is the producer-consumer model, where one coroutine generates data and another processes it.

-- Producer: generates items
local function producer()
  for i = 1, 5 do
    local item = string.format("item-%d", i)
    print("Producing:", item)
    coroutine.yield(item)  -- Yield the item to consumer
  end
end

-- Consumer: processes items
local function consumer(producer_co)
  while coroutine.status(producer_co) ~= "dead" do
    local _, item = coroutine.resume(producer_co)
    if item then
      print("Consuming:", item)
    end
  end
end

-- Run the pattern
local producer_co = coroutine.create(producer)
consumer(producer_co)

This pattern is useful for pipeline processing, where you have stages that each handle one item at a time. The consumer checks coroutine.status() before each resume to avoid calling resume on a dead coroutine, which would raise an error. Because coroutines are cooperative, the producer yields after generating each item, giving the consumer a chance to process it before the next item arrives.

Building an event loop

For more complex async scenarios, you can build an event loop that manages multiple coroutines:

local EventLoop = {}
EventLoop.__index = EventLoop

function EventLoop.new()
  local self = setmetatable({}, EventLoop)
  self.events = {}
  return self
end

function EventLoop:add(fn)
  local co = coroutine.create(fn)
  table.insert(self.events, co)
end

function EventLoop:run()
  while #self.events > 0 do
    -- Process each event
    local co = table.remove(self.events, 1)
    if coroutine.status(co) ~= "dead" then
      coroutine.resume(co)
      -- If the coroutine yielded (not dead), add it back
      if coroutine.status(co) == "suspended" then
        table.insert(self.events, co)
      end
    end
  end
end

-- Usage example
local loop = EventLoop.new()

loop:add(function()
  print("Task A: Step 1")
  coroutine.yield()
  print("Task A: Step 2")
end)

loop:add(function()
  print("Task B: Start")
  coroutine.yield()
  print("Task B: Done")
end)

print("Running event loop...")
loop:run()

This simple event loop lets multiple async tasks share the CPU without blocking. Each task yields voluntarily, and the loop moves to the next task in the queue. Dead coroutines are removed automatically, while suspended ones cycle back to the end of the list. Because Lua coroutines never preempt one another, you avoid race conditions entirely — only one task runs at any given moment, and context switches happen at explicit yield points.

Simulating async/await

If you’ve used async/await in JavaScript or Python, you can simulate the same pattern in Lua:

-- Helper functions to simulate async/await
function async(func)
  return coroutine.create(func)
end

function await(co)
  local status, result = coroutine.resume(co)
  if not status then
    error("Coroutine error: " .. tostring(result))
  end
  -- Return results from the coroutine
  return select(2, coroutine.resume(co))
end

-- Example: async database fetch
function fetchUserData(userId)
  return async(function()
    print("Fetching user " .. userId)
    coroutine.yield()  -- Simulate network delay
    
    print("Fetching posts for user " .. userId)
    coroutine.yield()  -- Simulate another network call
    
    return { user = userId, posts = {"post1", "post2"} }
  end)
end

-- Usage
local task = fetchUserData(42)
local result = await(task)
print("Got result:", result.user, #result.posts)

This pattern makes async code look sequential, greatly improving readability. The async helper wraps a function body into a coroutine, while await resumes it and collects any values it returns or yields. A key detail in the await implementation is the use of select(2, ...) to skip the first return value (the status boolean) and gather only the coroutine’s actual results. If the coroutine raises an error, resume returns false plus the error message, and the await helper propagates it as a Lua error.

Non-blocking file operations

When working with files, you can wrap blocking operations in coroutines:

function readFileAsync(filename)
  return async(function()
    local file, err = io.open(filename, "r")
    if not file then
      return nil, err
    end
    
    local content = file:read("*a")
    file:close()
    return content
  end)
end

-- Usage
local task = readFileAsync("myfile.txt")
local content, err = await(task)

if err then
  print("Error:", err)
else
  print("File length:", #content)
end

The file-reading example reuses the same async/await helpers from the previous section, demonstrating how patterns compose cleanly in Lua. The io.open call uses Lua’s standard error-reporting convention: it returns nil plus an error message on failure, which the function propagates back to the caller. Since the code wraps the entire operation in a coroutine, the caller can treat file I/O like any other async task in the event loop without special-casing it.

Error handling

Coroutine errors are caught by resume and returned as values rather than crashing your program:

local co = coroutine.create(function()
  error("Something went wrong!")
end)

local ok, err = coroutine.resume(co)
if not ok then
  print("Caught error:", err)  -- Prints: Caught error: Something went wrong!
end

This protected mode makes it easy to build reliable async systems. Unlike Lua’s pcall, which wraps function calls in protected mode, coroutine error handling is built into resume automatically; you get error isolation for free. This design means you can run many coroutines concurrently, and a failure in one never brings down the others or the main program.

Common pitfalls

Forgetting to resume

A suspended coroutine stays suspended forever unless you resume it:

local co = coroutine.create(function()
  print("This never runs")
  coroutine.yield()
end)

-- Oops, forgot to resume!
-- The coroutine just sits there in "suspended" state

Forgetting to resume a coroutine has no visible side effects in Lua: the suspended coroutine simply sits in memory until garbage collection eventually cleans it up. In a long-running program, however, accumulating abandoned coroutines can lead to memory pressure. Always track your active coroutines and call resume on each one until it reaches a dead state, especially when building event loops or pipeline handlers that spawn coroutines dynamically.

Infinite loops

Since coroutines are cooperative, a runaway coroutine will block everything:

local co = coroutine.create(function()
  while true do
    -- This loop never yields!
    -- It will block the entire program
  end
end)

coroutine.resume(co)  -- Program hangs here

Always ensure your coroutines yield periodically.

Summary

Coroutines in Lua provide a powerful foundation for async programming:

  • Create coroutines with coroutine.create(fn)
  • Resume them with coroutine.resume(co)
  • Yield control with coroutine.yield(values)
  • Check status with coroutine.status(co)
  • Handle errors through resume’s return values

These primitives let you build producer-consumer pipelines, event loops, and async/await patterns that make non-blocking code readable and maintainable.

In the next tutorial, we’ll explore how to combine coroutines with socket programming to build responsive network servers.

Next steps

See also