Async Patterns with Coroutines

· 5 min read · Updated March 18, 2026 · intermediate
coroutines async tutorial non-blocking

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.

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).

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

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

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.

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.

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.

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

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 robust async systems.

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

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.

See Also