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.
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
- Lua 5.4 Reference Manual - Coroutines — Official documentation for the coroutine library
- Programming in Lua - Coroutines — In-depth coverage of coroutine basics
- Socket Programming with Lua — Using sockets for network operations (upcoming tutorial)