Giving my own question a better answer than before.
The API reference says this of Fiber#resume:
There are no provisions for resuming the current fiber (where this method is called). Unless it is explicitly added for rescheduling (for example using #enqueue
) the current fiber won’t ever reach any instructions after the call to this method.
I missed this at first, and, even after noticing it, only thought of it as an annoyance that I had to work around. In actuality, this apparent limitation is a feature which provides the very functionality I was asking for and sought to implement.
Consider:
main = Fiber.current
n = 0
coro = spawn do
4.times do
puts "in coro, n = #{n}"
n += 1
main.resume
end
end
4.times do
puts "in main, n = #{n}"
n += 1
coro.resume
end
Thus, Fiber#resume allows us to use Fibers as unmanaged coroutines / resumable functions, and Fiber.yield allows us to use them as lightweight threads.
EDIT 3: Consolidated “wrong” answers into the below collapsible:
Several attempts to work around a problem that didn't exist
The only thing I can think of to look out for is this: Fibers are immediately enqueued upon construction, so if the managing Fiber yields control to the scheduler with Fiber.yield after constructing another Fiber, but before ever yielding control to it with Fiber#resume for the first time, it’s possible the scheduler will select the constructed Fiber, which violates the assumption we usually make about unmanaged coroutines that this can never happen. Depending on how we plan to use the constructed Fiber, this violation may sometimes be catastrophic.
A workaround, which is much more lightweight than my Channel-based approach but should be just as robust, is to have the body of every intended-function-like Fiber start with main.resume, and also immediately resume every intended-function-like Fiber upon construction:
coro = spawn do
main.resume
# ...
end
coro.resume
This will make the Fiber do nothing for its first run but remove itself from the scheduler’s pool and hand control back. For an intended-thread-like Fiber, this is obviously not ideal, so we can just not do that for them.
EDIT: Upon typing this out, I was immediately suspicious of it. Turns out only Fiber.yield can remove a competing Fiber from the scheduler’s pool; Fiber#resume cannot. So my suggestion in the second code block doesn’t work:
main = Fiber.current
coro = spawn do
main.resume
puts "shouldn't happen"
end
coro.resume
Fiber.yield # => "shouldn't happen"
To truly protect an intended-function-like Fiber from behaving as thread-like, we have to do this instead:
main = Fiber.current
started = false
coro = spawn do
started = true
main.resume
puts "shouldn't happen"
end
until started; Fiber.yield; end
This means constructing an intended-function-like Fiber, even before ever resuming it, is always a breakpoint for the calling Fiber. I’m not crazy about that, but it’s probably okay.
We can wrap this in a function:
def coroutine(&block)
caller = Fiber.current
started = false
coro = spawn do
started = true
caller.resume
block.call
end
until started; Fiber.yield; end
return coro
end
EDIT 2: Ok, final answer:
coro = Fiber.new do
puts "shouldn't happen"
end
Fiber.yield # => nothing
If Fibers are constructed with Fiber.new instead of with spawn, they are never enqueued in the first place. My bad, it’s been as simple as that all along.