Project

General

Profile

Actions

Feature #20276

closed

Introduce Fiber interfaces for Blocking operations on Ractors

Added by forthoney (Seong-Heon Jung) 3 months ago. Updated about 2 months ago.

Status:
Feedback
Assignee:
-
Target version:
-
[ruby-core:116827]

Description

Motivation

I am trying to build a web server with Ractors. The lifecycle for a request in the current implementation is

  1. main ractor sends request to worker ractor
  2. worker ractor handles response
  3. worker ractor sends response to main ractor
  4. main ractor writes response
  5. repeat

The main ractor utilizes the Async gem (specifically async-http) to handle connections concurrently, meaning each request is handled on a separate fiber.
The issue I am running into is after I send a request to a worker ractor, I need to do a blocking wait until I receive a response.
While I am waiting for the response, I cannot take any more connections.

Solution

If the fiber scheduler had a hook for Ractor.receive or Ractor#take (both of which are blocking), the main ractor can send the message, handle other connections while the worker processes the request. When the worker produces a message, it will then take the reqeust and write it in the socket. Specifically, I think the block and unblock hooks should be implemented for Ractors, considering Threads and Mutexes already use them.

Actions #1

Updated by forthoney (Seong-Heon Jung) 3 months ago

  • Subject changed from Introduce Fiber interfaces for Ractors to Introduce Fiber interfaces for Blocking operations on Ractors
  • Description updated (diff)

Updated by ko1 (Koichi Sasada) 3 months ago

  • Status changed from Open to Feedback

I understand the motivation but now the ractor-thread combination is not well-defined yet and combination with Fiber (scheduler) is also earlier (because of Ractor's specification and implementation).

Updated by forthoney (Seong-Heon Jung) 3 months ago

ko1 (Koichi Sasada) wrote in #note-2:

I understand the motivation but now the ractor-thread combination is not well-defined yet and combination with Fiber (scheduler) is also earlier (because of Ractor's specification and implementation).

I actually tried writing out some code to picture how this might be implemented, and I understand the complexity now. In the current implementation rb_fiber_scheduler_block/unblock, if ractor_a were to call ractor_b.take we would need the following process:

  1. ractor_a emits block event to ractor_a's scheduler
  2. ractor_a's current fiber yields, and different fiber runs on ractor_a
  3. ractor_b calls yield
  4. ractor_b emits unblock event to ractor_a's scheduler

With the strict object sharing rules between ractors, I can see why it may require a major rewrite to make Ractor blocking operations a fiber-scheduler event.

How is this alternative?
Instead of relying on automatic scheduling via Fiber scheduler events, we can rely a bit on programmer intervention.
If there is a method like Ractor#take_nonblock which works similar to IO#read_nonblock, the programmer can write code like

loop do
  msg = other_ractor.take_nonblock
rescue Errno::EAGAIN
  task.yield
  retry
end

This may not be 100% efficient since the scheduler would need to continuously "ask", but better than this not being possible at all.

Updated by ioquatix (Samuel Williams) about 2 months ago

I support this proposal.

A simple way to deal with this right now might be the following code (I have not tested it):

Thread.new do
  other_ractor.take
end.value

In general, the block/unblock operations should be sufficient, but it might require the ability for Ractor to invoke functionality across it's boundary OR we might need to implement some RPC mechanism (this is common in Actor based concurrency).

Updated by forthoney (Seong-Heon Jung) about 2 months ago

ioquatix (Samuel Williams) wrote in #note-4:

I support this proposal.

A simple way to deal with this right now might be the following code (I have not tested it):

Thread.new do
  other_ractor.take
end.value

In general, the block/unblock operations should be sufficient, but it might require the ability for Ractor to invoke functionality across it's boundary OR we might need to implement some RPC mechanism (this is common in Actor based concurrency).

Tested on locally and it seems to work, at least with Async. Here's my code.

Async do |task|
  1.upto(3) do
    r = Ractor.new do
      Ractor.recv
      fib = ->(x) { x < 2 ? 1 : fib.call(x - 2) + fib.call(x - 1) }
      puts "fin"
    end
    task.async do 
      Thread.new do
        r.send(nil) # ractor start
        r.take
      end.value
    end
  end
end

Not very scientific but the results are printed at roughly the same time.

Updated by forthoney (Seong-Heon Jung) about 2 months ago

On a side note, I may have unintentionally discovered an IRB bug in the process. If you run the above in IRB and use Ctrl-C to exit, IRB hangs and becomes unresponsive.

Updated by ioquatix (Samuel Williams) about 2 months ago

If I had to take a guess, I'd say it's a bug with waiting on a Ractor while handling signals?

Updated by forthoney (Seong-Heon Jung) about 2 months ago

ioquatix (Samuel Williams) wrote in #note-7:

If I had to take a guess, I'd say it's a bug with waiting on a Ractor while handling signals?

Under closer inspection, the Thread#join workaround should work in theory but actually deadlocks in practice, likely due to an implementation bug. Here's the relevant bug report. IRB is likely unresponsive because of this deadlock.

Actions

Also available in: Atom PDF

Like1
Like0Like0Like0Like0Like0Like0Like0Like0