diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b016200 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: Ruby tests + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - '3.4.0-preview2' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run Ruby tests + run: bundle exec rake test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17476f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.bundle/ +/tmp/ +*.gem diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..115ce7d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## [Unreleased] + +## [0.1.0] - 2024-10-13 + +- Initial release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..67fe8ce --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..4e7e920 --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "rake", "~> 13.0" + +gem "minitest", "~> 5.16" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..e31ea9d --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,45 @@ +PATH + remote: . + specs: + fiber-cfs (0.1.0) + async-dns (~> 1.3) + nio4r (~> 2.7) + red-black-tree (~> 0.1) + +GEM + remote: https://rubygems.org/ + specs: + async (2.17.0) + console (~> 1.26) + fiber-annotation + io-event (~> 1.6, >= 1.6.5) + async-dns (1.3.0) + async-io (~> 1.15) + async-io (1.43.2) + async + console (1.27.0) + fiber-annotation + fiber-local (~> 1.1) + json + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.0) + io-event (1.7.3) + json (2.7.4) + minitest (5.25.1) + nio4r (2.7.3) + rake (13.2.1) + red-black-tree (0.1.5) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + fiber-cfs! + minitest (~> 5.16) + rake (~> 13.0) + +BUNDLED WITH + 2.6.0.dev diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f847c2b --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Joshua Young + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..53fc49a --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# FiberCFS + +(WIP) A Completely Fair Fiber Scheduler + +## Installation + +Install the gem and add to the application's Gemfile by executing: + +```bash +bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +``` + +If bundler is not being used to manage dependencies, install the gem by executing: + +```bash +gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +``` + +## Usage + +```ruby +Fiber.set_scheduler FiberCFS::Scheduler.new +``` + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the +tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/joshuay03/fiber-cfs. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +## Code of Conduct + +Everyone interacting in the RedBlackTree project's codebases, issue trackers, chat rooms and mailing lists is expected +to follow the [code of conduct](https://github.com/joshuay03/red-black-tree/blob/main/CODE_OF_CONDUCT.md). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..474cef6 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "minitest/test_task" + +Minitest::TestTask.create + +task default: %i[test] diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..140c7a2 --- /dev/null +++ b/bin/console @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "fiber-cfs" + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..383cc19 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# frozen_string_literal: true + +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install diff --git a/fiber-cfs.gemspec b/fiber-cfs.gemspec new file mode 100644 index 0000000..0daabad --- /dev/null +++ b/fiber-cfs.gemspec @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "lib/fiber_cfs/version" + +Gem::Specification.new do |spec| + spec.name = "fiber-cfs" + spec.version = FiberCFS::VERSION + spec.authors = ["Joshua Young"] + spec.email = ["djry1999@gmail.com"] + + spec.summary = "A Completely Fair Fiber Scheduler" + spec.homepage = "https://github.com/joshuay03/fiber-cfs" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.4.0-preview2" + + # spec.metadata["documentation_uri"] = "https://joshuay03.github.io/fiber-cfs/" + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + + spec.files = Dir["{lib}/**/*", "**/*.{gemspec,md,txt}"] + spec.require_paths = ["lib"] + + spec.add_dependency "nio4r", "~> 2.7" + spec.add_dependency "red-black-tree", "~> 0.1" + spec.add_dependency "async-dns", "~> 1.3" +end diff --git a/lib/fiber-cfs.rb b/lib/fiber-cfs.rb new file mode 100644 index 0000000..c72c91c --- /dev/null +++ b/lib/fiber-cfs.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "fiber_cfs/version" +require_relative "fiber_cfs/scheduler" diff --git a/lib/fiber_cfs/blocked_node.rb b/lib/fiber_cfs/blocked_node.rb new file mode 100644 index 0000000..aabfe9e --- /dev/null +++ b/lib/fiber_cfs/blocked_node.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module FiberCFS + class BlockedNode < RedBlackTree::Node + def <=> other + (self.data.timeout_time || Float::INFINITY) <=> (other.data.timeout_time || Float::INFINITY) + end + end +end diff --git a/lib/fiber_cfs/node_data.rb b/lib/fiber_cfs/node_data.rb new file mode 100644 index 0000000..ec27c06 --- /dev/null +++ b/lib/fiber_cfs/node_data.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module FiberCFS + class NodeData < Data.define :fiber, :vruns, :ready, :timeout_time, :monitor + class Error < StandardError; end + + def initialize fiber:, vruns:, ready: false, timeout_time: nil, monitor: nil + super + end + end +end diff --git a/lib/fiber_cfs/runnable_node.rb b/lib/fiber_cfs/runnable_node.rb new file mode 100644 index 0000000..07961eb --- /dev/null +++ b/lib/fiber_cfs/runnable_node.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module FiberCFS + class RunnableNode < RedBlackTree::Node + def <=> other + if self.data.ready == other.data.ready + self.data.vruns <=> other.data.vruns + elsif self.data.ready + -1 + elsif other.data.ready + 1 + end + end + end +end diff --git a/lib/fiber_cfs/scheduler.rb b/lib/fiber_cfs/scheduler.rb new file mode 100644 index 0000000..61f3558 --- /dev/null +++ b/lib/fiber_cfs/scheduler.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require 'nio' +require 'red-black-tree' +require 'resolv' + +require_relative 'node_data' +require_relative 'runnable_node' +require_relative 'waiting_node' +require_relative 'blocked_node' + +module FiberCFS + class Scheduler + def initialize + @fiber = Fiber.current + + @selector = NIO::Selector.new + + @mutex = Thread::Mutex.new + + @runnable = RedBlackTree.new + @waiting = RedBlackTree.new + @blocked = RedBlackTree.new + end + + private + + def run + while @waiting.any? || @blocked.min&.timeout_time || @runnable.min&.ready + if @waiting.any? + ready_monitors = @selector.select next_timeout + ready_monitors&.each do |monitor| + if (deregister_or_update monitor).closed? + waiting_nodes = @waiting.select { |node| node.monitor.io == monitor.io } + waiting_nodes.each do |waiting_node| + @waiting.delete! waiting_node + + data_attrs = waiting_node.data.to_h + data_attrs.merge! vruns: (data_attrs[:vruns] + 1), ready: true, timeout_time: nil, monitor: nil + @runnable << (RunnableNode.new NodeData.new **data_attrs) + end + end + end + end + + while timed_out_node = min_timeout_time_nodes.find { |node| node.timeout_time && node.timeout_time <= current_time } + if timed_out_node.monitor + deregister_or_update timed_out_node.monitor, force: true + @waiting.delete! timed_out_node + else + @blocked.delete! timed_out_node + end + + data_attrs = timed_out_node.data.to_h + data_attrs.merge! vruns: (data_attrs[:vruns] + 1), ready: true, timeout_time: nil, monitor: nil + @runnable << (RunnableNode.new NodeData.new **data_attrs) + end + + while @runnable.min&.ready + ready_node = @runnable.shift + fiber = ready_node.fiber + + data_attrs = ready_node.data.to_h + data_attrs.merge! ready: false + @runnable << (RunnableNode.new NodeData.new **data_attrs) + + fiber.transfer if fiber.alive? + end + end + end + + def next_timeout + if min_timeout_time = min_timeout_time_nodes.filter_map(&:timeout_time).min + [min_timeout_time - current_time, 0].max + end + end + + def min_timeout_time_nodes + [@waiting.min, @blocked.min].compact + end + + def current_time + Process.clock_gettime Process::CLOCK_MONOTONIC + end + + def register_or_update io, interests + if @selector.registered? io + @waiting.search { |node| node.monitor.io == io }.monitor.tap do |current_monitor| + current_monitor.add_interest interests + end + else + @selector.register io, interests + end + end + + def deregister_or_update monitor, force: false + if force || monitor.readiness == monitor.interests + @selector.deregister monitor.io + else + interest_to_remove = monitor.readiness == :r ? :w : :r + monitor.tap do |current_monitor| + current_monitor.remove_interest interest_to_remove + end + end + end + + def events_interests events + "".tap do |interests| + interests << "r" if (events & IO::READABLE).nonzero? + interests << "w" if (events & IO::WRITABLE).nonzero? + end.to_sym + end + + def blocking &block + Fiber.blocking &block + end + + module Implementation + def address_resolve hostname + Resolv.getaddresses hostname.sub /%.*/, '' + end + + def block _blocker, timeout = nil + fiber = Fiber.current + timeout_time = current_time + timeout if timeout + + @runnable.delete! runnable_node = @runnable.search { |node| node.fiber == fiber } + + data_attrs = runnable_node.data.to_h + data_attrs.merge! timeout_time: timeout_time + @blocked << blocked_node = (BlockedNode.new NodeData.new **data_attrs) + + @fiber.transfer + end + + def close + run + end + + def fiber &block + fiber = Fiber.new &block + vruns = 1 + + @runnable << (RunnableNode.new NodeData.new fiber:, vruns:) + + fiber.transfer + + fiber + end + + def io_pread io, buffer, from, length, offset + fiber = Fiber.current + timeout_time = current_time + io.timeout if io.timeout + monitor = register_or_update io, :r + + @runnable.delete! runnable_node = @runnable.search { |node| node.fiber == fiber } + + data_attrs = runnable_node.data.to_h + data_attrs.merge! timeout_time: timeout_time, monitor: monitor + @waiting << waiting_node = (WaitingNode.new NodeData.new **data_attrs) + + @fiber.transfer + + unless monitor.readable? + raise IO::TimeoutError, "Timeout (#{io.timeout}s) while waiting for IO to become readable!" + end + + blocking { buffer.pread io, from, buffer.size - offset, offset } + end + + def io_pwrite io, buffer, from, length, offset + fiber = Fiber.current + timeout_time = current_time + io.timeout if io.timeout + monitor = register_or_update io, :w + + @runnable.delete! runnable_node = @runnable.search { |node| node.fiber == fiber } + + data_attrs = runnable_node.data.to_h + data_attrs.merge! timeout_time: timeout_time, monitor: monitor + @waiting << waiting_node = (WaitingNode.new NodeData.new **data_attrs) + + @fiber.transfer + + unless monitor.writable? + raise IO::TimeoutError, "Timeout (#{io.timeout}s) while waiting for IO to become writable!" + end + + blocking { buffer.pwrite io, from, buffer.size - offset, offset } + end + + def io_read io, buffer, length, offset + fiber = Fiber.current + timeout_time = current_time + io.timeout if io.timeout + monitor = register_or_update io, :r + + @runnable.delete! runnable_node = @runnable.search { |node| node.fiber == fiber } + + data_attrs = runnable_node.data.to_h + data_attrs.merge! timeout_time: timeout_time, monitor: monitor + @waiting << waiting_node = (WaitingNode.new NodeData.new **data_attrs) + + @fiber.transfer + + unless monitor.readable? + raise IO::TimeoutError, "Timeout (#{io.timeout}s) while waiting for IO to become readable!" + end + + blocking { buffer.read io, buffer.size - offset, offset } + end + + def io_select readables, writables, exceptables, timeout + raise NotImplementedError + end + + def io_wait io, events, timeout + fiber = Fiber.current + timeout_time = current_time + timeout if timeout + monitor = register_or_update events_interests events + + @runnable.delete! runnable_node = @runnable.search { |node| node.fiber == fiber } + + data_attrs = runnable_node.data.to_h + data_attrs.merge! timeout_time: timeout_time, monitor: monitor + @waiting << waiting_node = (WaitingNode.new NodeData.new **data_attrs) + + @fiber.transfer + end + + def io_write io, buffer, length, offset + fiber = Fiber.current + timeout_time = current_time + io.timeout if io.timeout + monitor = register_or_update io, :w + + @runnable.delete! runnable_node = @runnable.search { |node| node.fiber == fiber } + + data_attrs = runnable_node.data.to_h + data_attrs.merge! timeout_time: timeout_time, monitor: monitor + @waiting << waiting_node = (WaitingNode.new NodeData.new **data_attrs) + + @fiber.transfer + + unless monitor.writable? + raise IO::TimeoutError, "Timeout (#{io.timeout}s) while waiting for IO to become writable!" + end + + blocking { buffer.write io, buffer.size - offset, offset } + end + + def kernel_sleep duration = nil + block :sleep, duration + end + + def process_wait pid, flags + Thread.new { Process::Status.wait pid, flags }.value + end + + def timeout_after duration, exception_class, *exception_arguments, &block + raise NotImplementedError + end + + def unblock _blocker, fiber + @mutex.synchronize do + @blocked.delete! blocked_node = @blocked.search { |node| node.fiber == fiber } + + data_attrs = blocked_node.data.to_h + data_attrs.merge! vruns: (data_attrs[:vruns] + 1), ready: true, timeout_time: nil + @runnable << (RunnableNode.new NodeData.new **data_attrs) + end + end + end + + self.include Implementation + end +end diff --git a/lib/fiber_cfs/version.rb b/lib/fiber_cfs/version.rb new file mode 100644 index 0000000..f7aa316 --- /dev/null +++ b/lib/fiber_cfs/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module FiberCFS + VERSION = "0.1.0" +end diff --git a/lib/fiber_cfs/waiting_node.rb b/lib/fiber_cfs/waiting_node.rb new file mode 100644 index 0000000..e262ab5 --- /dev/null +++ b/lib/fiber_cfs/waiting_node.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module FiberCFS + class WaitingNode < RedBlackTree::Node + def <=> other + (self.data.timeout_time || Float::INFINITY) <=> (other.data.timeout_time || Float::INFINITY) + end + end +end diff --git a/test/fiber_cfs/test_scheduler.rb b/test/fiber_cfs/test_scheduler.rb new file mode 100644 index 0000000..f818b2b --- /dev/null +++ b/test/fiber_cfs/test_scheduler.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "test_helper" + +class FiberCFS::TestScheduler < Minitest::Test + def test_new + scheduler = FiberCFS::Scheduler.new + assert_instance_of FiberCFS::Scheduler, scheduler + end +end diff --git a/test/test_fiber_cfs.rb b/test/test_fiber_cfs.rb new file mode 100644 index 0000000..38a1d17 --- /dev/null +++ b/test/test_fiber_cfs.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestFiberCFS < Minitest::Test + def test_that_it_has_a_version_number + refute_nil FiberCFS::VERSION + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..f5a4fb1 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "fiber-cfs" + +require "minitest/autorun"