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
- Continue the series with coroutine wrappers and iterators to learn how
coroutine.wrapsimplifies common async patterns. - Explore the cooperative multitasking tutorial for a deeper dive into scheduling multiple coroutines.
See also
- Producer-consumer with coroutines — The producer-consumer pattern explained with coroutine pipelines
- Lua closures guide — Understanding closures deepens your grasp of coroutine scoping
- Lua 5.4 Reference Manual - Coroutines — Official documentation for the coroutine library