New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use Fiber.transfer instead of Fiber.yield #23
Comments
I will try it |
I will probably work on this in the next week, but if you are interested, do you think you can make a failing test case to demonstrate the problem? I got the general idea from Ruby bug tracker. |
I will try. |
Don't try to fix this issue as it's pretty deeply embedded in the current API, but if you can provide a failing test case using enumerator it gives us a goal to work towards. |
I was not quite right: Enumerator uses Fiber.yield only in "external iterator" mode. I added test in #24 |
Thank you so much this is awesome! |
I will make separate branch for this. There might be some limitations of transfer because it has a direct relationship between reactor and task, but in some cases the relationship is a bit more complex. |
Okay, so I've played around with this a bit. There are some areas where it might be feasible to use However, now that I think about it, why doesn't |
Here is an example of why this might be impossible: Async.run do |parent| # Fiber.new.resume
parent.async do # Fiber.new.resume
# Two kind of implementation:
reactor.sleep # Implemented by Fiber.yield -> back to parent
reactor.sleep # Implemented by Reactor.transfer -> back to reactor, parent never resumed.
# Another example:
input.read # Implemented by Fiber.yield -> back to parent
input.read # Implemented by Reactor.transfer -> back to reactor, parent never resumed.
end
end In order to use Let me know if you have any thoughts. |
Here is my attempt to make an #!/usr/bin/env ruby
require 'fiber'
module Async
class Enumerator
def initialize(&block)
@fiber = Fiber.new do
block.call do |*args|
self.transfer(*args)
end
end
end
def transfer(*args)
@back.transfer(*args)
end
def next
@back = Fiber.current
if @fiber.alive?
@fiber.transfer
else
raise StopIteration
end
end
end
end
e = Async::Enumerator.new do |&block|
block.call(1)
Fiber.yield 2
block.call(3)
end
3.times do |i|
puts "#{i} #{e.next}"
end The result I got was not what I expected. |
It seems like it is possible to emulate #!/usr/bin/env ruby
require 'fiber'
module Async
class Reactor
def initialize
@fiber = nil
@ready = []
end
def yield
@fiber.transfer
end
def resume(fiber)
previous = @fiber
@fiber = Fiber.current
fiber.transfer
@fiber = previous
end
def async(&block)
fiber = Fiber.new do
block.call
self.yield
end
resume(fiber)
end
# Wait for some event...
def wait
@ready << Fiber.current
self.yield
end
def run
while @ready.any?
fiber = @ready.pop
resume(fiber)
end
end
end
end
reactor = Async::Reactor.new
reactor.async do
puts "Hello World"
reactor.async do
puts "Goodbye World"
reactor.wait
puts "I'm back!"
end
puts "Foo Bar"
end
puts "Running"
reactor.run
puts "Finished" It prints out
Which is the expected order, using |
Here is my old attempt: https://gist.github.com/funny-falcon/2023354
I think you picked the right way. |
I almost got this working, but it require some breaking changes to I think there will be a performance hit too. There is one other solution. To make special async enumerator which behaves like normal enumerator but preserves asynchronous semantics. The My idea to make this change manageable is to introduce new concept |
Okay, I tried several more options including overriding Enumerator. Unfortunately Enumerator doesn't accept duck type. I think the only solution to this is as above, waiting for |
Can you at least mention, which difficulties and backward incompatibilities you've found? |
module Async
class Enumerator < ::Enumerator
def initialize(method, *args)
@method = method
@args = args
current = Task.current
@task = @yielder = nil
puts "Calling super"
super do |yielder|
puts "Setting up @yielder"
@yielder = Fiber.current
puts "Starting task"
current.async do |task|
puts "Setting up @task"
@task = Fiber.current
task.yield while @yielder.nil?
@method.call(task, *@args) do |*args|
@yielder.transfer(args)
end
@yielder.transfer(nil)
end
while value = @task.transfer
yielder << value.first
end
end
end
def dup
Enumerator.new(@method, *@args)
end
end
end |
I hope this help you understand a bit more what I was trying to do. I had another idea for a solution but I will implement example on |
Just for the xref: ruby/ruby@e938076 |
I don't see, why |
|
@funny-falcon I've been thinking more about this issue. Do you think it would make sense for |
I don't know :-( It is hard to decide for already existed feature. |
I've started working on updating Enumerator to use transfer. With this change, this should no longer be a problem. |
Hopefully it would be out in Ruby 2.6. |
Okay. So, it's more tricky than I imagined. Essentially, just using transfer is impossible, and composing a reactor and enumerator together which use transfer is, I think, impossible. Because internally, if you use transfer, you must some how manage transferring back. That being said, it's possible to make #!/usr/bin/env ruby
require 'fiber'
class Fiberator
def initialize(&block)
@caller = nil
@buffer = []
@fiber = Fiber.new(&block)
end
def next
return nil unless @fiber.alive?
@caller = Fiber.current
@value = nil
while @fiber.alive? and @buffer.empty?
*result = @fiber.resume(self)
if @buffer.any?
return @buffer.shift
else
Fiber.yield(*result) if @fiber.alive?
end
end
end
def << value
@buffer << value
Fiber.yield
end
end
e = Fiberator.new do |s|
s << 10
Fiber.yield 30
s << 20
end
f = Fiber.new do
while v = e.next
puts "next: #{v.inspect}"
end
puts "done."
40
end
2.times do
puts "f.resume: #{f.resume}"
end Firstly, you seem pretty knowledgeable about this stuff, so feel free to correct me. The way this work is Essentially, |
@nobu just FYI, I am hoping to make a PR to make this work with |
Okay, I made a basic PR against MRI to make this work. It implements in C, the same as the Ruby pseudo-code above. Feel free to give me your feedback. If you think you have a better idea, please let me know. |
|
Yes, agreed.
Unfortunately, async is not that simple, and it does use yield/resume for flow control between different tasks, not just to reactor and back. |
That is huge mistake, and it will bite you. |
Why? |
You break your own abstractions. It will inevitably lead to puzzles. Attempts to solve that puzzles will lead to hard to follow code. And finally, there will be unsolvable one. (Like this one with Enumerator. But I claim, there will be more). Probably I missed something, and your abstractions are more complex. But doubdfully more complex abstractions lead to better architecture. Such thing happens, but quite rare. |
The puzzle here is that Enumerator changes the behaviour of The abstraction is not any more complex than Fiber |
I hope you right. But I don't believe. |
I invite you to try the code I posted above, and I invite you to try and solve the Enumerator problem. We don't need to depend on beliefs, but we can solve these problems with actual code and discuss the merits of different solutions. The reality is, Enumerator is a problem for any code which uses |
After further discussion with @ko1 I found that |
This issue should be fixed in Ruby 3 using the thread scheduler as the Enumerator will become a blocking context to preserve existing behaviour. We may introduce some kind of "non-blocking" enumerator (or make that the default). |
@ioquatix any plans for this now that ruby 3 is out? |
After considering this issue further, I think we need to explore using |
Ruby 3 now allows us to mix @funny-falcon thanks for your original discussion here. I think we've finally come full circle. |
Ruby 3 allows mixing `Fiber#resume/#yield` and `Fiber#transfer`. We can take advantage of that to minimise the impact of non-blocking operations on user flow control. Previously, non-blocking operations would invoke `Fiber.yield` and this was a user-visible side-effect. We did take advantage of it, but it also meant that integration of Async with existing usage of Fiber could be problematic. We tracked the most obvious issues in `enumerator_spec.rb`. Now, non-blocking operations transfer directly to the scheduler fiber and thus don't impact other usage of resume/yield.
FWIW, I've been using a fun little "trampoline fiber" trick for this in my projects since at least 2.4 (ruby 3.0 makes it unnecessary). I'd looked into submitting a PR several times, but concluded that switching async from a yield/resume scheduler to a transferring scheduler was a bigger change than I had time for. But I'll take a look at your commit this week. If you've done all of that architectural work, then I have a feeling we can probably make it work with Ruby 2.x with maybe a dozen lines of fiber trampoline code. |
Essentially:
This entire trampoline-continuation dance allows us to transfer out of and back into a resumed fiber, without "breaking" it (marking it as transferring). IOW, it provides similar semantics to ruby 3.0 resume/transfer. The ruby is shorter/simpler than the English. 😉 |
This is not a bad idea and I've actually implemented a similar idea for Enumerator to avoid breaking it when calling |
Ruby 3 allows mixing `Fiber#resume/#yield` and `Fiber#transfer`. We can take advantage of that to minimise the impact of non-blocking operations on user flow control. Previously, non-blocking operations would invoke `Fiber.yield` and this was a user-visible side-effect. We did take advantage of it, but it also meant that integration of Async with existing usage of Fiber could be problematic. We tracked the most obvious issues in `enumerator_spec.rb`. Now, non-blocking operations transfer directly to the scheduler fiber and thus don't impact other usage of resume/yield.
Ruby 3 allows mixing `Fiber#resume/#yield` and `Fiber#transfer`. We can take advantage of that to minimise the impact of non-blocking operations on user flow control. Previously, non-blocking operations would invoke `Fiber.yield` and this was a user-visible side-effect. We did take advantage of it, but it also meant that integration of Async with existing usage of Fiber could be problematic. We tracked the most obvious issues in `enumerator_spec.rb`. Now, non-blocking operations transfer directly to the scheduler fiber and thus don't impact other usage of resume/yield.
Ruby 3 allows mixing `Fiber#resume/#yield` and `Fiber#transfer`. We can take advantage of that to minimise the impact of non-blocking operations on user flow control. Previously, non-blocking operations would invoke `Fiber.yield` and this was a user-visible side-effect. We did take advantage of it, but it also meant that integration of Async with existing usage of Fiber could be problematic. We tracked the most obvious issues in `enumerator_spec.rb`. Now, non-blocking operations transfer directly to the scheduler fiber and thus don't impact other usage of resume/yield.
Fiber.yield
is used by Enumerator, so if enumerator will call to some io, it will return to calling fiber instead of being scheduled.But if scheduler will use
fiber.transfer
, than it will play nicely with Enumerator.The text was updated successfully, but these errors were encountered: