Skip to content

A Fiber is a coroutine you resume by hand

Level: 301 · anyone who has written a Python generator and wants the object Ruby built Enumerator#next on

One line: Fiber.new { } makes a block that runs only when you resume it and stops wherever it calls Fiber.yield; values travel in through resume and out through yield, nothing switches fibers except those two calls, and a finished fiber raises FiberError — the protocol of a Python generator's next, send and throw, on an object that is not tied to a def with yield in it.

A fiber is a coroutine: a block with its own stack that runs only while somebody is resuming it. The first resume(x) starts the block with x as its parameter; inside, Fiber.yield(v) suspends the fiber and makes v the value of that resume call in the caller; the next resume(w) continues the block with w as the value of the Fiber.yield expression (rows 1 and 3). When the block ends, its last expression is the value of the final resume (row 4) and alive? turns false (row 5); resuming again is a FiberError (row 6), and so is calling Fiber.yield from the root fiber, which has nowhere to yield to (row 7). Fiber#raise resumes the fiber by raising inside it, at its Fiber.yield (row 8); Fiber#kill (Ruby 3.3) unwinds it, running its ensure blocks (row 13). Several values go through as several arguments and come back as an array (row 14).

There is no scheduler and no preemption. Row 12 logs an outer fiber resuming an inner one twice: every line lands exactly where the resume and Fiber.yield calls put it, and no timer ever moves control. A fiber runs on whatever thread resumes it (row 10) and is not a thread — Fiber.current inside the block is a new object, while at the top level it is the root fiber the thread started with (row 9). That makes a fiber a generator (row 11: an endless loop that yields Fibonacci numbers, resumed eight times) and, with a scheduler installed through Fiber.set_scheduler, the unit of non-blocking I/O that the async gem builds on — Fiber.scheduler is nil here because nothing was installed (row 15) (Not machine-checked here.). The next page shows that Enumerator#next is exactly this: a fiber wrapped around the enumerator's block.

Verified output of fibers_are_coroutines_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. resume(1): the block starts, yields x * 2            2
 2. alive? while suspended at a yield                    true
 3. resume(10): Fiber.yield returned 10, yields y + 1    11
 4. resume(100): the block ends; its last value          "done 100"
 5. alive? after the block ended                         false
 6. resume a finished fiber                              FiberError: attempt to resume a terminated fiber
 7. Fiber.yield outside any fiber                        FiberError: attempt to yield on a not resumed fiber
 8. fiber.raise delivers an exception at the yield       "rescued RuntimeError: stop", alive? false
 9. Fiber.current: the root fiber's class / inside is root? Fiber / false
10. a fiber runs on the thread that resumed it           true
11. a generator: 8 resumes of a Fibonacci fiber          [0, 1, 1, 2, 3, 5, 8, 13]
12. no preemption: control moves only at resume/yield    ["outer 1", "inner 1", "outer 2", "inner 2", "outer 3"]
13. Fiber#kill unwinds the fiber; ensure runs            alive? false, ["ensure ran"]
14. several values: resume(1, 2) / Fiber.yield 1, 2      [1, 2] / [1, 2]
15. Fiber.scheduler (hook for non-blocking fibers)       nil

Reading the output

Rows 1 to 5 are one fiber's life. resume(1) gives the block x = 1 and gets back 2 from Fiber.yield x * 2; resume(10) makes that Fiber.yield return 10, so y + 1 is 11; resume(100) makes the second yield return 100 and the block ends with "done 100", which is the value of that last resume. Between yields alive? is true; after the block ends it is false. Rows 6 and 7 print the two messages: attempt to resume a terminated fiber and attempt to yield on a not resumed fiber.

Row 8 shows Fiber#raise: the fiber was suspended at Fiber.yield 1, the RuntimeError appeared there, the block's rescue returned a string, and that string is the value of the raise call — after which the fiber is finished. Rows 9 and 10 place a fiber: Fiber.current at the top level is the root fiber, inside the block it is another Fiber, and both run on the main thread. Row 11 is the generator idiom, row 12 the proof that nothing moves control but resume and yield, row 13 that kill runs the ensure, and row 14 that resume(1, 2) becomes two block parameters while Fiber.yield 1, 2 comes back as [1, 2]. Row 15 prints nil: no fiber scheduler is installed.

Compared with Python

A Python generator is the same coroutine with yield as the marker: next(g) is the first resume, g.send(v) a resume(v), and the yield expression inside evaluates to what send passed (row 3). The differences are at the ends. A generator's return value does not come back from send — it rides on StopIteration.value (row 4), and every later next raises StopIteration again with None (row 6); Ruby gives the last value directly and then raises FiberError. There is no alive?: inspect.getgeneratorstate answers with GEN_CREATED, GEN_SUSPENDED or GEN_CLOSED (rows 2 and 5). g.throw(exc) is Fiber#raise (row 8); g.close() is Fiber#kill, raising GeneratorExit at the yield so that finally runs (row 13). A yield outside a function is a SyntaxError at compile time (row 7), where Ruby's Fiber.yield at the top level fails at run time.

A generator is not a separate object from its frame — there is no Fiber.current, only a generator whose gi_frame is a suspended frame (row 9), on the caller's thread (row 10). Everything else lines up: the Fibonacci generator (row 11), the nested log with no preemption (row 12), and several values as one tuple each way (row 14). Row 15 is Python's extra: an async def coroutine is the same machine, and c.send(None) drives it to its StopIteration without an event loop — asyncio is a scheduler for these objects, as Ruby's Fiber.scheduler is for fibers.

Verified output of fibers_are_coroutines_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. next(): the frame starts, yields x * 2               2
 2. getgeneratorstate() while suspended                  GEN_SUSPENDED
 3. send(10): the yield expression was 10, yields y + 1  11
 4. send(100): return raises StopIteration; its .value   'done 100'
 5. getgeneratorstate() after the return                 GEN_CLOSED
 6. next() on a finished generator                       StopIteration again, value None
 7. yield outside any function                           SyntaxError at compile time
 8. gen.throw() delivers an exception at the yield       'rescued RuntimeError: stop', state GEN_CLOSED
 9. no Fiber object: type / gi_frame is not None?        generator / True
10. a generator runs on the thread that resumed it       True
11. a generator: 8 next() calls on a Fibonacci generator [0, 1, 1, 2, 3, 5, 8, 13]
12. no preemption: control moves only at next/yield      ['outer 1', 'inner 1', 'outer 2', 'inner 2', 'outer 3']
13. gen.close() unwinds the frame; finally runs          state GEN_CLOSED, ['finally ran']
14. several values: `yield 1, 2` / send((3, 4))          (1, 2) / (3, 4)
15. an async def coroutine driven by send(None)          StopIteration.value 5 (no asyncio needed)

Try it

  1. Write a fiber that keeps a running total of the numbers it is resumed with and yields the total after each one. Resume it with 1 to 5 and print each total; then print whether the fiber is still alive and say why.
  2. Build a two-fiber ping-pong: fiber A yields "ping n" and fiber B yields "pong n", and a loop in the root fiber resumes them alternately five times, printing each value. Then explain why neither fiber can resume the other directly while the root fiber is resuming it (Fiber#transfer is the answer to look up).
Solution to 1

fibers_are_coroutines_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: a fiber that keeps a running total of the numbers it is resumed with.
# The first resume hands the block its parameter; every later resume becomes
# the value of Fiber.yield inside the loop.
total = Fiber.new do |n|
  sum = 0
  loop do
    sum += n
    n = Fiber.yield sum
  end
end

(1..5).each { |i| puts "resume(#{i}) -> running total #{total.resume(i)}" }
puts "alive? #{total.alive?} (the loop never ends; the fiber is only suspended)"

Verified output of fibers_are_coroutines_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

resume(1) -> running total 1
resume(2) -> running total 3
resume(3) -> running total 6
resume(4) -> running total 10
resume(5) -> running total 15
alive? true (the loop never ends; the fiber is only suspended)

See also