Why do Ruby fibers that run sequentially without a scheduler set run concurrently when a scheduler is set?
Asked Answered
B

1

2

I have the following Gemfile:

source "https://rubygems.org"

ruby "3.1.2"

gem "libev_scheduler", "~> 0.2"

and the following Ruby code in a file called main.rb:

require 'libev_scheduler'

set_sched = ARGV[0] == "--set-sched"
if set_sched then
  Fiber.set_scheduler Libev::Scheduler.new
end

N_FIBERS = 5
fibers = []

N_FIBERS.times do |i|
  n = i + 1

  fiber = Fiber.new do
    puts "Beginning calculation ##{n}..."
    sleep 1
  end

  fibers.push({fiber: fiber, n: n})
end

fibers.each do |fiber|
  fiber[:fiber].resume
end

puts "Finished all calculations!"

I'm executing the code with Ruby 3.1.2 installed via RVM.

When I run the program with time bundle exec ruby main.rb, I get the following output:

Beginning calculation #1...
Beginning calculation #2...
Beginning calculation #3...
Beginning calculation #4...
Beginning calculation #5...
Finished all calculations!

real    0m5.179s
user    0m0.146s
sys     0m0.027s

When I run the program with time bundle exec ruby main.rb --set-sched, I get the following output:

Beginning calculation #1...
Beginning calculation #2...
Beginning calculation #3...
Beginning calculation #4...
Beginning calculation #5...
Finished all calculations!

real    0m1.173s
user    0m0.150s
sys     0m0.021s

Why do my fibers only run concurrently when I've set a scheduler? Some older Stack Overflow answers (like this one) state that fibers are a construct for flow control, not concurrency, and that it is impossible to use fibers to write concurrent code. My results seem to contradict this.

My understanding so far of fibers is that they are meant for cooperative concurrency, as opposed to preemptive concurrency. Therefore, in order to get concurrency out of them, you'd need to have them yield to some other code as early as they can (ex. when IO begins) so that the other code can be executed while the fiber waits for its next opportunity to execute.

Based on this understanding, I think I understand why my code without a scheduler isn't able to run concurrently. It sleeps and because it lacks yield statements before and after code in it, there are no points in time where it could yield control to any other code I've written. But when I add a scheduler, it appears to somehow yield to something. Is sleep detecting the scheduler and yielding to it so that my code resuming the fibers is immediately yielded to, making it able to immediately resume all five fibers?

Barytone answered 14/9, 2022 at 21:32 Comment(1)
"answers (like this one) state that it is impossible to use fibers to write concurrent code" – well, that answer you are referring to is from 2010. The concept of non-blocking fibers was introduced 10 years later in Ruby 3.0.Hallsy
A
4

Great question!

As @stefan noted above, Ruby 3.0 introduced the concept of a "non-blocking fiber." The way the actual non-blocking behavior is accomplished is left up to the scheduler implementation. There is no default scheduler as far as I know; per the Ruby docs:

If Fiber.scheduler is not set in the current thread, blocking and non-blocking fibers’ behavior is identical.

Now, to answer your last question:

But when I add a scheduler, it appears to somehow yield to something ... Is sleep detecting the scheduler and yielding to it so that my code resuming the fibers is immediately yielded to, making it able to immediately resume all five fibers?

You're onto something! When you set a fiber scheduler, it's expected to conform to Fiber::SchedulerInterface, which defines several "hooks." One of those hooks is #kernel_sleep, which is invoked by Kernel#sleep (and Mutex#sleep)!

I can't say I've read much libev code, but you can find libev_scheduler's implementation of that hook here.

The idea is (emphasis my own):

The scheduler runs into a wait loop, checking all the blocked fibers (which it has registered on hook calls) and resuming them when the awaited resource is ready (e.g. I/O ready or sleep time elapsed).

So, in summary:

  1. Your fiber calls Kernel#sleep with some duration.
  2. Kernel#sleep calls the scheduler's #kernel_sleep hook with that same duration.
  3. The schedule "somehow registers what the current fiber is waiting on, and yields control to other fibers with Fiber.yield" (quote from the docs there)
  4. "The scheduler runs into a wait loop, checking all the blocked fibers (which it has registered on hook calls) and resuming them when the awaited resource is ready (e.g. I/O ready or sleep time elapsed)."

Hope this helps!

Adlare answered 15/9, 2022 at 11:33 Comment(1)
Definitely helps, yes. I knew there was something going on when I was calling sleep, I just didn't know how all these parts were interacting with each other. I guess how this works is that there are other hooks like the one you described which would be used in other IO situations, like using an HTTP client library. And I'd have to test any library I use to see whether it's using those hooks, making it work concurrently when called by a non-blocking fiber.Barytone

© 2022 - 2024 — McMap. All rights reserved.