From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: X-Spam-Checker-Version: SpamAssassin 3.4.2 (2018-09-13) on dcvr.yhbt.net X-Spam-Level: X-Spam-ASN: AS4713 221.184.0.0/13 X-Spam-Status: No, score=-3.7 required=3.0 tests=AWL,BAYES_00, HEADER_FROM_DIFFERENT_DOMAINS,MAILING_LIST_MULTI,RCVD_IN_DNSWL_MED, SPF_HELO_NONE,SPF_PASS,UNPARSEABLE_RELAY shortcircuit=no autolearn=ham autolearn_force=no version=3.4.2 Received: from neon.ruby-lang.org (neon.ruby-lang.org [221.186.184.75]) by dcvr.yhbt.net (Postfix) with ESMTP id DBD9F1F5AD for ; Tue, 14 Apr 2020 23:56:03 +0000 (UTC) Received: from neon.ruby-lang.org (localhost [IPv6:::1]) by neon.ruby-lang.org (Postfix) with ESMTP id 8D2BD120977; Wed, 15 Apr 2020 08:55:33 +0900 (JST) Received: from xtrwkhkc.outbound-mail.sendgrid.net (xtrwkhkc.outbound-mail.sendgrid.net [167.89.16.28]) by neon.ruby-lang.org (Postfix) with ESMTPS id 5AEE0120975 for ; Wed, 15 Apr 2020 08:55:31 +0900 (JST) Received: by filterdrecv-p3iad2-8ddf98858-rpdh9 with SMTP id filterdrecv-p3iad2-8ddf98858-rpdh9-19-5E964D85-1B 2020-04-14 23:55:49.259107938 +0000 UTC m=+1723695.009547349 Received: from herokuapp.com (unknown) by geopod-ismtpd-3-2 (SG) with ESMTP id eQQJU0slQX-dTktFhe922g for ; Tue, 14 Apr 2020 23:55:49.208 +0000 (UTC) Date: Tue, 14 Apr 2020 23:55:49 +0000 (UTC) From: samuel@oriontransfer.net Message-ID: References: Mime-Version: 1.0 X-Redmine-MailingListIntegration-Message-Ids: 73647 X-Redmine-Project: ruby-master X-Redmine-Issue-Tracker: Feature X-Redmine-Issue-Id: 16786 X-Redmine-Issue-Author: ioquatix X-Redmine-Sender: ioquatix X-Mailer: Redmine X-Redmine-Host: bugs.ruby-lang.org X-Redmine-Site: Ruby Issue Tracking System X-Auto-Response-Suppress: All Auto-Submitted: auto-generated X-SG-EID: =?us-ascii?Q?cjxb6GWHefMLoR50bkJBcGo6DRiDl=2FNYcMZdY+Wj30RCON6HDelzYOTBHRRALI?= =?us-ascii?Q?UA1Auwe7uBOvq5ITFIQV8VfuAG=2FfQasOSqOMucs?= =?us-ascii?Q?onoeAvFmhZBi4QUc15mDndMQLNz02Rcp1zaAxh5?= =?us-ascii?Q?BzIte218q=2FxgaetqxpKl4p5lDYi4K3ezHL1UVkc?= =?us-ascii?Q?nqkHMm4+AuroMrnwINw=2Fb43Flrh1Tx3FHh2CFqU?= =?us-ascii?Q?JYzWGWzP=2FAIZmz544=3D?= To: ruby-core@ruby-lang.org X-ML-Name: ruby-core X-Mail-Count: 97886 Subject: [ruby-core:97886] [Ruby master Feature#16786] Light-weight scheduler for improved concurrency. X-BeenThere: ruby-core@ruby-lang.org X-Mailman-Version: 2.1.15 Precedence: list Reply-To: Ruby developers List-Id: Ruby developers List-Unsubscribe: , List-Post: List-Help: List-Subscribe: , Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: ruby-core-bounces@ruby-lang.org Sender: "ruby-core" Issue #16786 has been updated by ioquatix (Samuel Williams). > Why not differentiate what we think of as Fiber today with this new type of Fiber (e.g. ScheduledFiber)? >From the user's point of view, it's still a fiber, and can be scheduled like a fiber: `resume`/`yield`/`transfer` and so on. The scheduler also sees it as a fiber and uses fiber methods for scheduling. > Scheduler API should pass IO objects, not file descriptors We really only have two options that I can think of: - Internally have a table of `fd` -> `IO` and use this, although there are C extensions where this still won't work because there was never an `IO` instance so we still need to construct it. The details of constructing an IO instance in this case are trivial but there is still a cost. - Expose this detail in the scheduler design and leave it up to the implementation. Most scheduler designs just need the file descriptor and don't care about `IO` so there is little value in reconstructing the full IO object when it's immediately discarded or unused. > Don't introduce Fiber() I'm okay with this. In `Async` we already have constructs that users are familiar with. I'll have to defer to @matz for specifically what kind of interface he wants to expose. This interface was based on our initial discussion at RWC2019. There are two benefits from introducing such a name: - It hides the implementation of symmetric/asymmetric switching by the scheduler. - It provides a uniform interface which high level libraries like Async and EventMachine can hook into. They do not need to use framework-specific constructs/methods for task construction. > Mutex will have to be addressed The entire Async stack including Falcon works without depending on Mutex working the way you suggest it needs to. So I respectfully disagree with your assertions. The only place it's used is in signal handling setup IIRC. Semantically, the proposed implementation doesn't change the behaviour of Mutex. We want to avoid introducing changes that break user code. > Given that context switches between threads can now occur on any IO operation That's simply wrong. ---------------------------------------- Feature #16786: Light-weight scheduler for improved concurrency. https://bugs.ruby-lang.org/issues/16786#change-85108 * Author: ioquatix (Samuel Williams) * Status: Open * Priority: Normal ---------------------------------------- # Abstract We propose to introduce a light weight fiber scheduler, to improve the concurrency of Ruby code with minimal changes. # Background We have been discussing and considering options to improve Ruby scalability for several years. More context can be provided by the following discussions: - https://bugs.ruby-lang.org/issues/14736 - https://bugs.ruby-lang.org/issues/13618 The final Ruby Concurrency report provides some background on the various issues considered in the latest iteration: https://www.codeotaku.com/journal/2020-04/ruby-concurrency-final-report/index # Proposal We propose to introduce the following concepts: - A `Scheduler` interface which provides hooks for user-supplied event loops. - Non-blocking `Fiber` which can invoke the scheduler when it would otherwise block. ## Scheduler The per-thread fiber scheduler interface is used to intercept blocking operations. A typical implementation would be a wrapper for a gem like EventMachine or Async. This design provides separation of concerns between the event loop implementation and application code. It also allows for layered schedulers which can perform instrumentation, enforce constraints (e.g. during testing) and provide additional logging. You can see a [sample implementation here](https://github.com/socketry/async/pull/56). ```ruby class Scheduler # Wait for the given file descriptor to become readable. def wait_readable(fd) end # Wait for the given file descriptor to become writable. def wait_writable(fd) end # Wait for the given file descriptor to match the specified events within # the specified timeout. # @param event [Integer] a bit mask of +IO::WAIT_READABLE+, # `IO::WAIT_WRITABLE` and `IO::WAIT_PRIORITY`. # @param timeout [#to_f] the amount of time to wait for the event. def wait_for_single_fd(fd, events, timeout) end # Sleep the current task for the specified duration, or forever if not # specified. # @param duration [#to_f] the amount of time to sleep. def wait_sleep(duration = nil) end # The Ruby virtual machine is going to enter a system level blocking # operation. def enter_blocking_region end # The Ruby virtual machine has completed the system level blocking # operation. def exit_blocking_region end # Intercept the creation of a non-blocking fiber. def fiber(&block) Fiber.new(blocking: false, &block).resume end # Invoked when the thread exits. def run # Implement event loop here. end end ``` A thread has a non-blocking fiber scheduler. All blocking operations on non-blocking fibers are hooked by the scheduler and the scheduler can switch to another fiber. If any mutex is acquired by a fiber, then a scheduler is not called; the same behaviour as blocking Fiber. Schedulers can be written in Ruby. This is a desirable property as it allows them to be used in different implementations of Ruby easily. To enable non-blocking fiber switching on blocking operations: - Specify a scheduler: `Thread.current.scheduler = Scheduler.new`. - Create several non-blocking fibers: `Fiber.new(blocking:false) {...}`. - As the main fiber exits, `Thread.current.scheduler.run` is invoked which begins executing the event loop until all fibers are finished. ### Time/Duration Arguments Tony Arcieri suggested against using floating point values for time/durations, because they can accumulate rounding errors and other issues. He has a wealth of experience in this area so his advice should be considered carefully. However, I have yet to see these issues happen in an event loop. That being said, round tripping between `struct timeval` and `double`/`VALUE` seems a bit inefficient. One option is to have an opaque argument that responds to `to_f` as well as potentially `seconds` and `microseconds` or some other such interface (could be opaque argument supported by `IO.select` for example). ### File Descriptor Arguments There is a good case for prefering `IO` instances over file descriptors. However because of the public C interface we may need to support both. ```c int rb_io_wait_readable(int); int rb_io_wait_writable(int); int rb_wait_for_single_fd(int fd, int events, struct timeval *tv); ``` Internally, in CRuby, it may be possible to map from `fd` -> `IO` instance. Another option is to simply support both interfaces and leave it up to the scheduler to decide how to handle it, e.g. ```ruby class Scheduler def wait_readable_fd(fd) # wait_readable_io(IO.from_fd(fd)) end def wait_readable_io(io) # wait_readable_fd(io.fileno) end end ``` We would like to be flexible, without imposing a performance burden on any particular implementation. This is a good point for further discussion. ## Non-blocking Fiber We propose to introduce per-fiber flag `blocking: true/false`. A fiber created by `Fiber.new(blocking: true)` (the default `Fiber.new`) becomes a "blocking Fiber" and has no changes from current Fiber implementation. A fiber created by `Fiber.new(blocking: false)` becomes a "non-blocking Fiber" and it will be scheduled by the per-thread scheduler when the blocking operations (blocking I/O, sleep, and so on) occurs. ```ruby Fiber.new(blocking: false) do puts Fiber.current.blocking? # false # May invoke `Thread.scheduler&.wait_readable`. io.read(...) # May invoke `Thread.scheduler&.wait_writable`. io.write(...) # Will invoke `Thread.scheduler&.wait_sleep`. sleep(n) end.resume ``` Non-blocking fibers also supports `Fiber#resume`, `Fiber#transfer` and `Fiber.yield` which are necessary to create a scheduler. ### Fiber Method We also introduce a new method which simplifes the creation of these non-blocking fibers: ```ruby Fiber do puts Fiber.current.blocking? # false end ``` This method invokes `Scheduler#fiber(...)`. The purpose of this method is to allow the scheduler to internally decide the policy for when to start the fiber, and whether to use symmetric or asymmetric fibers. If no scheduler is specified, it creates a normal blocking fiber. An alternative is to make it an error. ## Non-blocking I/O `IO#nonblock` is an existing interface to control whether I/O uses blocking or non-blocking system calls. We can take advantage of this: - `IO#nonblock = false` prevents that particular IO from utilising the scheduler. This should be the default for `stderr`. - `IO#nonblock = true` enables that particular IO to utilise the scheduler. We should enable this where possible. As proposed by Eric Wong, we believe that making I/O non-blocking by default is the right approach. We have expanded his work in the current implementation. By doing this, when the user writes `Fiber do ... end` they are guaranteed the best possible concurrency possible, without any further changes to code. As an example, one of the tests shows `Net::HTTP.get` being used in this way with no further modifications required. To support this further, consider the counterpoint, that `Net::HTTP.get(..., blocking: false)` is required for concurrent requests. Library code may not expose the relevant options, sevearly limiting the user's ability to improve concurrency, even if that is what they desire. # Implementation We have an evolving implementation here: https://github.com/ruby/ruby/pull/3032 which we will continue to update as the proposal changes. # Evaluation This proposal provides the hooks for scheduling fibers. With regards to performance, there are several things to consider: - The impact of the scheduler design on non-concurrent workloads. We believe it's acceptable. - The impact of the scheduler design on concurrent workloads. Our results are promising. - The impact of different event loops on throughput and latency. We have independent tests which confirm the scalability of the approach. We can control for the first two in this proposal, and depending on the design we may help or hinder the wrapper implementation. In the tests, we provide a basic implementation using `IO.select`. As this proposal is finalised, we will introduce some basic benchmarks using this approach. # Discussion The following points are good ones for discussion: - Handling of file descriptors vs `IO` instances. - Handling of time/duration arguments. - General design and naming conventions. - Potential platform issues (e.g. CRuby vs JRuby vs TruffleRuby, etc). The following is planned to be described by @eregon in another design document: - Semantics of non-blocking mutex (e.g. `Mutex.new(blocking: false)` or some other approach). In the future we hope to extend the scheduler to handle other blocking operations, including name resolution, file I/O (by `io_uring`) and others. -- https://bugs.ruby-lang.org/