About
This post is a beginner-friendly introduction to mruby-task, a gem bundled with mruby that adds cooperative multitasking with preemptive scheduling. It lets you run multiple tasks concurrently inside a single mruby VM, each with its own stack and priority. The scheduler decides which task runs next, and you don't have to think about it most of the time. You just write your code as separate tasks and let the scheduler handle the switching.
Background
What is cooperative multitasking?
Cooperative multitasking means that tasks voluntarily yield control to
let other tasks run. If a task never yields, it runs forever. With
preemptive scheduling, the scheduler enforces a timeslice. If a task runs
for too long without yielding, the scheduler preempts it and switches to
another task. mruby-task combines both: you can yield explicitly with
Task.pass, and the scheduler will also preempt a task after
its timeslice expires (every 12ms by default).
What problem does it solve?
mruby normally runs one sequence of bytecode at a time. If you want to fetch data from a network while also responding to user input, you would normally need operating-system threads or processes. mruby-task solves this within a single VM: one task handles the event loop while another fetches data in the background. The two tasks share memory directly, so there is no need for inter-process communication or serialization.
Example #1
Hello, tasks
The simplest possible example is creating two tasks that print messages and yield to each other:
Task.new(name: "alice") do
3.times do |i|
puts "Alice: #{i}"
Task.pass
end
end
Task.new(name: "bob") do
3.times do |i|
puts "Bob: #{i}"
Task.pass
end
end
Task.run
Explanation
Task.new(name: "alice") { ... }
Creates a task named "alice" with the given block. The task starts running immediately after creation.Task.pass
Yields control to the scheduler so another task can run. Without this, the first task would run to completion before the second one starts.Task.run
Starts the scheduler. It blocks until all tasks have finished. Without this call, tasks are created but nothing executes.
When you run this, you'll see the output alternate between Alice and Bob.
Example #2
Priorities
Tasks have a priority from 0 (highest) to 255 (lowest), defaulting to 128. The scheduler always picks the highest-priority ready task:
Task.new(name: "urgent", priority: 0) do
puts "Urgent task runs first"
end
Task.new(name: "lazy", priority: 255) do
puts "Lazy task runs last"
end
Task.run
Explanation
priority: 0
Gives the task the highest possible priority. The scheduler will run it before any task with a lower (higher-number) priority.priority: 255
The lowest priority. This task runs only after all higher-priority tasks have yielded or completed.
Priorities are useful when you have background work that should not delay more important tasks, such as keeping the UI responsive while a network request completes in the background.
Example #3
Sleep and yielding
When a task calls sleep, it is moved to a waiting queue
and the scheduler picks the next ready task. No busy-waiting or polling
is needed:
Task.new(name: "ticker") do
loop do
puts "tick"
sleep 1
end
end
Task.new(name: "reporter") do
sleep 0.5
puts "The ticker above me prints every second"
end
Task.run
Explanation
sleep 1
Puts the task to sleep for one second. The task is moved from the ready queue to the waiting queue. When the time expires, the scheduler moves it back and it resumes where it left off.sleep 0.5
The reporter task sleeps for half a second, then prints its message. The ticker continues running in the background.
mruby-task also provides sleep_ms(milliseconds) and
usleep(microseconds) for finer-grained control.
Example #4
Task::Queue (producer and consumer)
The Task::Queue class lets tasks communicate with each
other. One task pushes items into the queue, and another task pops them.
When the queue is empty, the consumer task blocks automatically. No
polling:
q = Task::Queue.new
results = []
Task.new(name: "producer") do
["a", "b", "c"].each do |v|
q.push(v)
sleep 0.1
end
q.close
end
Task.new(name: "consumer") do
loop do
item = q.pop
break if item.nil?
results << item
end
end
Task.run
puts results.inspect # => ["a", "b", "c"]
Explanation
Task::Queue.new
Creates a new queue. The queue is a FIFO (first-in, first-out) data structure that is safe to use from multiple tasks.q.push(v)
Adds an item to the back of the queue. If a consumer task is blocked waiting onpop, it is woken up and rescheduled.q.pop
Blocks the current task until an item is available. The task is moved to the waiting queue and does not consume CPU. When an item is pushed, the task is moved back to the ready queue and continues.q.close
Signals that no more items will be pushed. After close,popreturnsnilwhen the queue is empty, allowing the consumer to break out of its loop.
Non-blocking pops are also possible by passing true:
q.pop(true). This raises Task::Error if the
queue is empty.
Example #5
Joining tasks
join blocks the current task until another task finishes.
This is useful when you need the result of a background computation:
worker = Task.new(name: "worker") do
sleep 1
42
end
Task.new(name: "main") do
puts "Waiting for worker..."
worker.join
puts "Worker returned 42"
end
Task.run
Explanation
worker.join
The current task (main) blocks until the worker task finishes execution. During this time, the scheduler can run other tasks. When the worker completes, the main task is woken up and continues.
Note that join can only be called from within a task, and
a task cannot join itself.
Configuration
Build setup
To use mruby-task in your project, add it to your build configuration:
MRuby::Build.new do |conf|
conf.gem :core => 'mruby-task'
# other gems...
end
Explanation
conf.gem :core => 'mruby-task'
Adds the mruby-task gem to your build. This definesMRB_USE_TASK_SCHEDULERat compile time, which enables the scheduler in the VM.
Timing
The scheduler fires a timer every 4ms (the tick unit) and gives each task a timeslice of 3 ticks (12ms). These can be overridden at compile time:
#define MRB_TICK_UNIT 4 // Tick period in milliseconds
#define MRB_TIMESLICE_TICK_COUNT 3 // Ticks per timeslice
Explanation
MRB_TICK_UNIT
How often the timer fires. Lower values give finer-grained scheduling but increase overhead from context switching.MRB_TIMESLICE_TICK_COUNT
How many ticks a task gets before the scheduler preempts it. A task that runs longer thanMRB_TICK_UNIT * MRB_TIMESLICE_TICK_COUNT(12ms by default) is preempted and another task gets to run.
Task states
Each task can be in one of five states:
- DORMANT - Not started or finished. Tasks start here and return here when done.
- READY - Ready to run. The scheduler picks from this queue.
- RUNNING - Currently executing. Only one task is running at a time.
- WAITING - Sleeping, waiting on a queue, or joined to another task.
- SUSPENDED - Manually paused via
task.suspend. Can be resumed withtask.resume.
You can inspect the state of all tasks with
Task.stat:
stats = Task.stat
puts stats[:ready][:count] # number of ready tasks
puts stats[:waiting][:count] # number of waiting tasks
Conclusion
mruby-task provides a simple, single-VM concurrency model that is well-suited for mruby applications that need to juggle multiple concerns: a UI event loop, background network requests, periodic timers, and so on. Task::Queue eliminates polling for inter-task communication, priorities let you control which work is more important, and the preemptive scheduler ensures that no single task can monopolise the CPU.
Further topics worth exploring include the Task::Queue
blocking pop mechanic (which cooperatively yields the task while
waiting), the mrb_task_run_once C API for integrating with
foreign event loops, and writing custom HAL ports for platforms that are
not POSIX or Windows.