Lua - Coroutines & Cooperative Multitasking

Coroutines & Cooperative Multitasking

Time: ~35 minutes

Learn Lua's powerful coroutine system for cooperative multitasking, generators, and asynchronous-style programming.

Learning Objectives

  • Understand coroutines and cooperative multitasking
  • Master coroutine creation, yielding, and resuming
  • Build generators and iterators with coroutines
  • Implement producer-consumer patterns
  • Create asynchronous-style control flow

Introduction to Coroutines

Coroutines are functions that can suspend their execution and resume later, enabling cooperative multitasking.

Basic Coroutine Operations

-- Create a simple coroutine
local function simple_coroutine()
    print("Coroutine started")
    coroutine.yield("First yield")
    print("Coroutine resumed")
    coroutine.yield("Second yield")
    print("Coroutine finishing")
    return "Final result"
end

-- Create coroutine
local co = coroutine.create(simple_coroutine)

-- Check coroutine status
print("Initial status:", coroutine.status(co))

-- Resume coroutine multiple times
local success, result = coroutine.resume(co)
print("First resume:", success, result)
print("Status after first resume:", coroutine.status(co))

local success, result = coroutine.resume(co)
print("Second resume:", success, result)
print("Status after second resume:", coroutine.status(co))

local success, result = coroutine.resume(co)
print("Third resume:", success, result)
print("Final status:", coroutine.status(co))

Expected Output:

Initial status:	suspended
Coroutine started
First resume:	true	First yield
Status after first resume:	suspended
Coroutine resumed
Second resume:	true	Second yield
Status after second resume:	suspended
Coroutine finishing
Third resume:	true	Final result
Final status:	dead

Coroutine with Parameters

-- Coroutine that receives and processes data
local function data_processor()
    local data = coroutine.yield("Ready for data")
    
    while data do
        print("Processing:", data)
        local result = string.upper(data)
        data = coroutine.yield("Processed: " .. result)
    end
    
    return "Processing complete"
end

local processor = coroutine.create(data_processor)

-- Start coroutine
local success, message = coroutine.resume(processor)
print("Start:", message)

-- Send data to coroutine
local success, result = coroutine.resume(processor, "hello")
print("Result 1:", result)

local success, result = coroutine.resume(processor, "world")
print("Result 2:", result)

local success, result = coroutine.resume(processor, "lua")
print("Result 3:", result)

-- End processing
local success, final = coroutine.resume(processor, nil)
print("Final:", final)

Expected Output:

Start:	Ready for data
Processing:	hello
Result 1:	Processed: HELLO
Processing:	world
Result 2:	Processed: WORLD
Processing:	lua
Result 3:	Processed: LUA
Final:	Processing complete

Generators with Coroutines

Number Sequence Generator

-- Fibonacci sequence generator
local function fibonacci_generator()
    local a, b = 0, 1
    while true do
        coroutine.yield(a)
        a, b = b, a + b
    end
end

-- Helper function to create iterator from coroutine
local function make_iterator(generator)
    local co = coroutine.create(generator)
    return function()
        local success, value = coroutine.resume(co)
        if success and coroutine.status(co) ~= "dead" then
            return value
        end
        return nil
    end
end

-- Test Fibonacci generator
print("First 10 Fibonacci numbers:")
local fib = make_iterator(fibonacci_generator)
for i = 1, 10 do
    print(i .. ":", fib())
end

-- Prime number generator
local function prime_generator()
    local function is_prime(n)
        if n < 2 then return false end
        for i = 2, math.sqrt(n) do
            if n % i == 0 then return false end
        end
        return true
    end
    
    local n = 2
    while true do
        if is_prime(n) then
            coroutine.yield(n)
        end
        n = n + 1
    end
end

-- Test prime generator
print("\nFirst 8 prime numbers:")
local primes = make_iterator(prime_generator)
for i = 1, 8 do
    print(i .. ":", primes())
end

Expected Output:

First 10 Fibonacci numbers:
1:	0
2:	1
3:	1
4:	2
5:	3
6:	5
7:	8
8:	13
9:	21
10:	34

First 8 prime numbers:
1:	2
2:	3
3:	5
4:	7
5:	11
6:	13
7:	17
8:	19

Custom Iterator with Coroutines

-- Tree traversal using coroutines
local function create_tree_iterator(tree)
    local function traverse(node)
        if node then
            -- In-order traversal
            traverse(node.left)
            coroutine.yield(node.value)
            traverse(node.right)
        end
    end
    
    return coroutine.wrap(function()
        traverse(tree)
    end)
end

-- Create a binary tree
local tree = {
    value = 4,
    left = {
        value = 2,
        left = {value = 1},
        right = {value = 3}
    },
    right = {
        value = 6,
        left = {value = 5},
        right = {value = 7}
    }
}

-- Traverse tree using coroutine iterator
print("Tree traversal (in-order):")
for value in create_tree_iterator(tree) do
    print("Node value:", value)
end

-- Range generator with step
local function range(start, stop, step)
    return coroutine.wrap(function()
        local current = start
        step = step or 1
        
        if step > 0 then
            while current <= stop do
                coroutine.yield(current)
                current = current + step
            end
        else
            while current >= stop do
                coroutine.yield(current)
                current = current + step
            end
        end
    end)
end

-- Test range generator
print("\nRange 1 to 10 with step 2:")
for value in range(1, 10, 2) do
    print("Value:", value)
end

print("\nCountdown from 5 to 1:")
for value in range(5, 1, -1) do
    print("Value:", value)
end

Expected Output:

Tree traversal (in-order):
Node value:	1
Node value:	2
Node value:	3
Node value:	4
Node value:	5
Node value:	6
Node value:	7

Range 1 to 10 with step 2:
Value:	1
Value:	3
Value:	5
Value:	7
Value:	9

Countdown from 5 to 1:
Value:	5
Value:	4
Value:	3
Value:	2
Value:	1

Producer-Consumer Pattern

Data Pipeline

-- Producer coroutine
local function data_producer(data_set)
    for _, item in ipairs(data_set) do
        print("Producing:", item)
        coroutine.yield(item)
    end
    return "Production complete"
end

-- Consumer function
local function data_consumer(producer_co)
    local results = {}
    
    while coroutine.status(producer_co) ~= "dead" do
        local success, item = coroutine.resume(producer_co)
        
        if success and item then
            if type(item) == "string" and item ~= "Production complete" then
                -- Process the item
                local processed = string.upper(item) .. "_PROCESSED"
                print("Consuming and processing:", item, "->", processed)
                table.insert(results, processed)
            elseif item == "Production complete" then
                print("Consumer received:", item)
                break
            end
        end
    end
    
    return results
end

-- Test producer-consumer
local data = {"apple", "banana", "cherry", "date"}
local producer = coroutine.create(function() 
    return data_producer(data) 
end)

print("Starting producer-consumer pipeline:")
local results = data_consumer(producer)

print("\nFinal results:")
for i, result in ipairs(results) do
    print(i .. ":", result)
end

Expected Output:

Starting producer-consumer pipeline:
Producing:	apple
Consuming and processing:	apple	->	APPLE_PROCESSED
Producing:	banana
Consuming and processing:	banana	->	BANANA_PROCESSED
Producing:	cherry
Consuming and processing:	cherry	->	CHERRY_PROCESSED
Producing:	date
Consuming and processing:	date	->	DATE_PROCESSED
Consumer received:	Production complete

Final results:
1:	APPLE_PROCESSED
2:	BANANA_PROCESSED
3:	CHERRY_PROCESSED
4:	DATE_PROCESSED

Multi-stage Pipeline

-- Multi-stage processing pipeline using coroutines
local function create_pipeline(...)
    local stages = {...}
    
    return coroutine.wrap(function()
        -- Input stage
        local input_data = {"1", "2", "3", "4", "5"}
        
        for _, item in ipairs(input_data) do
            local current = item
            
            -- Process through each stage
            for stage_num, stage_func in ipairs(stages) do
                current = stage_func(current, stage_num)
                if not current then break end
            end
            
            if current then
                coroutine.yield(current)
            end
        end
    end)
end

-- Pipeline stages
local function parse_number(value, stage)
    local num = tonumber(value)
    print("Stage " .. stage .. " (parse):", value, "->", num)
    return num
end

local function square_number(value, stage)
    local result = value * value
    print("Stage " .. stage .. " (square):", value, "->", result)
    return result
end

local function format_result(value, stage)
    local result = "Result: " .. value
    print("Stage " .. stage .. " (format):", value, "->", result)
    return result
end

-- Create and run pipeline
print("Multi-stage pipeline processing:")
local pipeline = create_pipeline(parse_number, square_number, format_result)

for result in pipeline do
    print("Final output:", result)
    print("---")
end

Expected Output:

Multi-stage pipeline processing:
Stage 1 (parse):	1	->	1
Stage 2 (square):	1	->	1
Stage 3 (format):	1	->	Result: 1
Final output:	Result: 1
---
Stage 1 (parse):	2	->	2
Stage 2 (square):	2	->	4
Stage 3 (format):	4	->	Result: 4
Final output:	Result: 4
---
Stage 1 (parse):	3	->	3
Stage 2 (square):	3	->	9
Stage 3 (format):	9	->	Result: 9
Final output:	Result: 9
---
Stage 1 (parse):	4	->	4
Stage 2 (square):	4	->	16
Stage 3 (format):	16	->	Result: 16
Final output:	Result: 16
---
Stage 1 (parse):	5	->	5
Stage 2 (square):	5	->	25
Stage 3 (format):	25	->	Result: 25
Final output:	Result: 25
---

Cooperative Task Scheduler

Simple Task Scheduler

-- Simple cooperative task scheduler
local TaskScheduler = {}
TaskScheduler.__index = TaskScheduler

function TaskScheduler.new()
    return setmetatable({
        tasks = {},
        current_time = 0
    }, TaskScheduler)
end

function TaskScheduler:add_task(name, task_function)
    local co = coroutine.create(task_function)
    table.insert(self.tasks, {
        name = name,
        coroutine = co,
        last_run = 0
    })
    print("Added task:", name)
end

function TaskScheduler:run_cycle()
    self.current_time = self.current_time + 1
    print("\n--- Scheduler Cycle " .. self.current_time .. " ---")
    
    local active_tasks = {}
    
    for _, task in ipairs(self.tasks) do
        if coroutine.status(task.coroutine) ~= "dead" then
            print("Running task:", task.name)
            local success, result = coroutine.resume(task.coroutine, self.current_time)
            
            if success then
                if result then
                    print("  Task " .. task.name .. " says:", result)
                end
                table.insert(active_tasks, task)
            else
                print("  Task " .. task.name .. " error:", result)
            end
        else
            print("Task " .. task.name .. " completed")
        end
    end
    
    self.tasks = active_tasks
    return #active_tasks > 0
end

function TaskScheduler:run_all()
    while self:run_cycle() do
        -- Continue until no tasks remain
    end
    print("\nAll tasks completed")
end

-- Example tasks
local function counter_task(max_count)
    return function(current_time)
        for i = 1, max_count do
            coroutine.yield("Count: " .. i .. " at time " .. current_time)
        end
        return "Counter finished"
    end
end

local function timer_task(intervals)
    return function(current_time)
        for _, interval in ipairs(intervals) do
            coroutine.yield("Timer: " .. interval .. " seconds at time " .. current_time)
        end
        return "Timer finished"
    end
end

-- Test scheduler
local scheduler = TaskScheduler.new()

scheduler:add_task("Counter", counter_task(3))
scheduler:add_task("Timer", timer_task({5, 10, 15}))
scheduler:add_task("Quick", function(time)
    coroutine.yield("Quick task at time " .. time)
    return "Quick task done"
end)

scheduler:run_all()

Expected Output:

Added task:	Counter
Added task:	Timer
Added task:	Quick

--- Scheduler Cycle 1 ---
Running task:	Counter
  Task Counter says:	Count: 1 at time 1
Running task:	Timer
  Task Timer says:	Timer: 5 seconds at time 1
Running task:	Quick
  Task Quick says:	Quick task at time 1

--- Scheduler Cycle 2 ---
Running task:	Counter
  Task Counter says:	Count: 2 at time 2
Running task:	Timer
  Task Timer says:	Timer: 10 seconds at time 2
Running task:	Quick
  Task Quick says:	Quick task done
Task Quick completed

--- Scheduler Cycle 3 ---
Running task:	Counter
  Task Counter says:	Count: 3 at time 3
Running task:	Timer
  Task Timer says:	Timer: 15 seconds at time 3

--- Scheduler Cycle 4 ---
Running task:	Counter
  Task Counter says:	Counter finished
Task Counter completed
Running task:	Timer
  Task Timer says:	Timer finished
Task Timer completed

All tasks completed

Best Practices

  • Clear yielding: Yield at logical points in execution
  • Error handling: Always check coroutine.resume() success
  • Resource management: Clean up resources in coroutines
  • Documentation: Document yield points and expected values
  • Testing: Test all coroutine states and transitions
Architect Tip: Coroutines are excellent for implementing parsers, state machines, and event-driven systems. Use them when you need to maintain complex state between function calls.

Common Pitfalls

  • Forgetting to yield: Infinite loops without yield points
  • Status confusion: Not checking coroutine status before resume
  • Memory leaks: Keeping references to completed coroutines
  • Error propagation: Not handling errors from resumed coroutines
  • Complex state: Making coroutine logic too complicated

Checks for Understanding

  1. What are the possible states of a coroutine?
  2. What happens if you resume a dead coroutine?
  3. How do you pass data to a coroutine?
  4. When should you use coroutines vs regular functions?
Show Answers
  1. suspended, running, dead, and normal (when calling another coroutine).
  2. coroutine.resume() returns false and an error message.
  3. Pass data as arguments to coroutine.resume() after the first call.
  4. When you need to maintain state between calls, implement generators, or create cooperative multitasking.

Exercises

  1. Create a lexical analyzer using coroutines to tokenize source code.
  2. Build an asynchronous HTTP client simulator using cooperative scheduling.
  3. Implement a game loop with multiple concurrent game objects using coroutines.