DEV Community

hungle00
hungle00

Posted on Edited on Originally published at hungle00.github.io AI-assisted

Ruby vs Python: Fiber vs Coroutine

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
Enter fullscreen mode Exit fullscreen mode

And in Python:

thread = threading.Thread(target=do_something)
thread.start()
thread.join()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example, Ruby Fiber can explicitly yield:

Fiber.yield
Enter fullscreen mode Exit fullscreen mode

While Python coroutine typically suspends at an await:

await something()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

So the async nature propagates through the call chain.
Conceptually:

async function_a()
        │
        └── await async function_b()
                    │
                    └── await async function_c()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Python's asyncio follows a more explicit event-loop model:

Python
 │
 ├── Coroutine
 │
 └── Event Loop
          │
          └── Tasks
Enter fullscreen mode Exit fullscreen mode

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 Scheduler can 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
Enter fullscreen mode Exit fullscreen mode

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)