Previously, I wrote about Fiber in Ruby, one of the mechanisms used to build asynchronous programming in Ruby. In this post, I want to compare Ruby Fiber with Python coroutine and see where these two concepts are similar and where they differ.
From a language perspective, Ruby and Python share quite a few similarities. Both are scripting languages, dynamic languages, and provide several ways to implement concurrency.
1. Ruby vs Python: Threads
At the OS level, both Ruby and Python support Threads
For example, in Ruby:
thread = Thread.new do
# do something
end
thread.join
And in Python:
thread = threading.Thread(target=do_something)
thread.start()
thread.join()
The scheduling model used by the OS is called preemptive scheduling. The OS can switch between threads and decide which thread gets CPU time, instead of requiring threads to explicitly give up the CPU.
However, there is one important detail.
Both Ruby MRI and CPython have a global lock, which affects how multiple threads can execute interpreter code within the same process.
- Ruby MRI uses the Global VM Lock (GVL).
- CPython uses the Global Interpreter Lock (GIL).
Both GVL and GIL ensure that, under their traditional execution models, only one thread at a time can execute interpreter bytecode within a process. Because of this, Threads are generally more useful for I/O-bound workloads than CPU-bound workloads.
For example:
Thread A
│
└── waiting for HTTP response
↓
CPU is available
↓
Thread B can run
While Thread A is waiting for a network response, Thread B can use the available CPU time and continue running.
2. So What Are Fibers and Coroutines?
Ruby Fiber and Python coroutine are both concepts for implementing concurrency at the language/runtime level. The general idea is:
Instead of creating an OS thread for every task, we can create lighter execution units and let the application/runtime manage how they switch between each other.
You can think it like this:
Thread
│
├── Fiber / Coroutine A
├── Fiber / Coroutine B
└── Fiber / Coroutine C
A single thread can manage multiple Fibers or coroutines. At any given moment, only one of them is actually running, but a task can suspend itself and let another task run.
There are two important ideas behind this:
2.1. Language/runtime-level concurrency
A Thread is an OS-level execution unit, The OS creates and schedules threads. With Fiber or coroutine, the execution units are managed at a higher level by the language/runtime:
Application
│
├── Fiber / Coroutine A
├── Fiber / Coroutine B
└── Fiber / Coroutine C
↓
Runtime / Scheduler
This makes them much lighter than OS threads and allows us to have many concurrent tasks without creating a thread for each one.
2.2. Cooperative scheduling
This is probably the most important similarity. Threads normally use preemptive scheduling. The OS can interrupt a running thread and switch to another one.
Fiber and coroutine use a different model: cooperative scheduling. The running task has to reach a point where it can suspend and give control back:
Task A
│
├── running
│
├── yield / await
│
▼
Task B
│
├── running
│
├── yield / await
│
▼
Task A resumes
For example, Ruby Fiber can explicitly yield:
Fiber.yield
While Python coroutine typically suspends at an await:
await something()
3. Fiber vs Coroutine: What Is Different?
Although Ruby Fiber and Python coroutine share the same cooperative-concurrency idea, they are quite different in how they are designed and used.
3.1 Fiber is a lower-level execution primitive
Ruby's Fiber is a relatively low-level execution primitive. You can think of it as a lightweight execution context that you can create and control yourself.
For example, creating a Fiber looks quite similar to creating a Thread:
fiber = Fiber.new do
puts "A"
Fiber.yield
puts "B"
end
fiber.resume
fiber.resume
You create a Fiber, give it some code to execute, and explicitly start it with resume. Fiber can be suspended and later resumed from where it stopped.
Conceptually:
Fiber
│
├── execution context
│
├── run
│
├── suspend
│
└── resume
This makes Fiber a useful building block for building higher-level concurrency abstractions.
Python coroutine is more tightly tied to the async/await model:
async def fetch_data():
result = await fetch_something()
return result
You don't normally create and control the execution context in the same direct way as you do with Ruby Fiber. Instead, the coroutine is designed to be executed and scheduled by an async runtime such as asyncio.
3.2 Fiber vs Async Call Chain
This is probably one of the most interesting differences when coming from Python.
With Ruby Fiber, normal functions don't necessarily need to know that they are running inside a Fiber.
For example:
def function_a
function_b
end
def function_b
function_c
end
def function_c
Fiber.yield
end
fiber = Fiber.new do
function_a
end
fiber.resume
function_a, function_b, and function_c are just normal Ruby functions.
They don't need to be declared as something like async def. The Fiber provides the execution context, and the code inside it can eventually suspend.
This gives us a relatively transparent model:
Fiber
│
└── function_a()
│
└── function_b()
│
└── function_c()
│
└── Fiber.yield
The entire call chain doesn't have to be explicitly converted into "async functions".
Python takes a different approach. If a function needs to await something, it needs to be an async function:
async def function_a():
await function_b()
async def function_b():
await function_c()
async def function_c():
await something()
So the async nature propagates through the call chain.
Conceptually:
async function_a()
│
└── await async function_b()
│
└── await async function_c()
This doesn't mean that every function called from an async function must itself be async. A normal synchronous function can still be called from an async function.
The important point is that any function that needs to suspend with await must participate in the coroutine model.
3.3 Scheduler: Fiber Scheduler vs Event Loop
The last difference is how these execution units are scheduled.
Ruby provides the Fiber Scheduler API, which allows a scheduler to control how Fibers interact with blocking operations such as network I/O. Higher-level libraries can then build asynchronous programming models on top of Fiber.
Conceptually:
Ruby
│
├── Fiber
│
└── Fiber Scheduler
│
└── Async
Python's asyncio follows a more explicit event-loop model:
Python
│
├── Coroutine
│
└── Event Loop
│
└── Tasks
The event loop is responsible for running coroutines/tasks and deciding which task should continue when another task is waiting for I/O.
So there is a difference in abstraction:
Ruby:
Fiber is the primitive, and a
Fiber Schedulercan be used to build higher-level async abstractions.
Python:
Coroutine works together with
async/await, and the event loop provides the runtime for scheduling those coroutines.
In practice, this is why Python async code often feels more explicit. While Ruby can hide more of the scheduling details behind Fiber and the scheduler.
In Short
If we reduce the differences to three points:
| Ruby Fiber | Python Coroutine | |
|---|---|---|
| Abstraction | Lower-level execution primitive | Higher-level async abstraction |
| Call chain | Normal functions can run inside a Fiber | Functions that need await must be async |
| Scheduling | Fiber Scheduler / libraries such as Async |
asyncio Event Loop |
Conclusion
The easiest way to remember the relationship is:
Concurrency
│
┌──────────┴──────────┐
│ │
Thread Fiber / Coroutine
│ │
OS scheduling Cooperative scheduling
│ │
Ruby Thread Ruby Fiber
Python Thread Python Coroutine
│
Runtime/Application
scheduler
│
┌─────────┴─────────┐
│ │
Ruby Fiber Python asyncio
Scheduler Event Loop
Concept mapping:
| Ruby | Python |
|---|---|
| Thread | Thread |
| Fiber | Coroutine |
Fiber.yield |
await |
Fiber.resume |
resume/schedule coroutine |
| Fiber Scheduler | Event Loop |
Fiber.schedule |
create/schedule Task |
Async gem |
asyncio |
async-http |
aiohttp / httpx
|
| Falcon | Uvicorn / Hypercorn |
Top comments (0)