├── .commit ├── config.yml └── data │ └── releases.yml ├── .github └── workflows │ ├── ci.yml │ └── commit-tools.yml ├── .gitignore ├── .rspec ├── .standard.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── benchmarks └── channels.rb ├── examples ├── buffers.rb ├── channels.rb ├── errors.rb ├── memory.rb ├── results.rb ├── server.rb ├── simple.rb ├── sleeper.rb └── uuids.rb ├── goru.gemspec ├── lib ├── goru.rb └── goru │ ├── bridge.rb │ ├── bridges │ ├── readable.rb │ └── writable.rb │ ├── channel.rb │ ├── reactor.rb │ ├── routine.rb │ ├── routines │ ├── channel.rb │ ├── channels │ │ ├── readable.rb │ │ └── writable.rb │ └── io.rb │ ├── scheduler.rb │ └── version.rb ├── profiling ├── channel.rb └── worker.rb └── spec ├── features ├── channel │ ├── buffered_spec.rb │ ├── clearing_spec.rb │ ├── closing_spec.rb │ ├── inspection_spec.rb │ └── passing_values_spec.rb ├── io │ ├── bridging_spec.rb │ ├── closing_spec.rb │ └── non_blocking_spec.rb ├── routine │ ├── error_handling_spec.rb │ ├── finished_spec.rb │ ├── finishing_spec.rb │ ├── observing_spec.rb │ ├── pausing_spec.rb │ ├── result_spec.rb │ ├── sleeping_spec.rb │ ├── state_spec.rb │ └── status_spec.rb └── scheduler │ ├── configuring_spec.rb │ ├── running_spec.rb │ ├── stopping_spec.rb │ └── waiting_spec.rb ├── initialize.rb ├── initializers ├── configure.rb └── matchers.rb └── support ├── delegate.rb └── server.rb /.commit/config.yml: -------------------------------------------------------------------------------- 1 | commit: 2 | changelogs: 3 | - label: "commit.changelog" 4 | destination: "./CHANGELOG.md" 5 | 6 | changetypes: 7 | - label: "commit.type.add" 8 | name: "add" 9 | - label: "commit.type.chg" 10 | name: "chg" 11 | - label: "commit.type.fix" 12 | name: "fix" 13 | - label: "commit.type.dep" 14 | name: "dep" 15 | 16 | includes: 17 | - ruby-gem 18 | - ruby-rspec 19 | - ruby-standard 20 | - oss 21 | - git 22 | 23 | externals: 24 | - repo: "bryanp/commit-templates" 25 | private: true 26 | 27 | license: 28 | slug: mpl 29 | name: "MPL-2.0" 30 | 31 | project: 32 | slug: "goru" 33 | description: "Concurrent routines for Ruby." 34 | 35 | author: 36 | name: "Bryan Powell" 37 | email: "bryan@bryanp.org" 38 | homepage: "https://github.com/bryanp/goru/" 39 | 40 | copyright: 41 | attribution: "Bryan Powell" 42 | year: 2022 43 | 44 | ruby: 45 | gem: 46 | namespace: "Goru" 47 | version: "3.2.0" 48 | extra: |-2 49 | spec.files = Dir["CHANGELOG.md", "README.md", "LICENSE", "lib/**/*"] 50 | spec.require_path = "lib" 51 | 52 | spec.add_dependency "core-extension", "~> 0.5" 53 | spec.add_dependency "core-handler", "~> 0.2" 54 | spec.add_dependency "core-global", "~> 0.3" 55 | spec.add_dependency "nio4r", "~> 2.5" 56 | spec.add_dependency "timers", "~> 4.3" 57 | standard: 58 | extra: |-2 59 | ignore: 60 | - 'examples/memory.rb': 61 | - Style/GlobalVars 62 | - 'examples/simple.rb': 63 | - Style/MixinUsage 64 | -------------------------------------------------------------------------------- /.commit/data/releases.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - version: v0.5.0 3 | date: '2023-12-24' 4 | link: https://github.com/bryanp/goru/releases/tag/v0.5.0 5 | changes: 6 | - id: 26 7 | type: chg 8 | summary: Bump core dependencies 9 | link: https://github.com/bryanp/goru/pull/26 10 | author: 11 | username: bryanp 12 | link: https://github.com/bryanp 13 | - version: v0.4.2 14 | date: '2023-09-17' 15 | link: https://github.com/bryanp/goru/releases/tag/v0.4.2 16 | changes: 17 | - id: 24 18 | type: fix 19 | summary: Close the io object when the routine is finished 20 | link: https://github.com/bryanp/goru/pull/24 21 | author: 22 | username: bryanp 23 | link: https://github.com/bryanp 24 | - id: 25 25 | type: fix 26 | summary: Handle IOError in the scheduler and reactor 27 | link: https://github.com/bryanp/goru/pull/25 28 | author: 29 | username: bryanp 30 | link: https://github.com/bryanp 31 | - version: v0.4.1 32 | date: '2023-07-29' 33 | link: https://github.com/bryanp/goru/releases/tag/v0.4.1 34 | changes: 35 | - id: 23 36 | type: fix 37 | summary: Handle `IOError` in io routines 38 | link: https://github.com/bryanp/goru/pull/23 39 | author: 40 | username: bryanp 41 | link: https://github.com/bryanp 42 | - version: v0.4.0 43 | date: '2023-07-16' 44 | link: https://github.com/bryanp/goru/releases/tag/v0.4.0 45 | changes: 46 | - id: 22 47 | type: add 48 | summary: Add reactor as a reader to `Goru::Routine` 49 | link: https://github.com/bryanp/goru/pull/22 50 | author: 51 | username: bryanp 52 | link: https://github.com/bryanp 53 | - id: 21 54 | type: add 55 | summary: Improve statuses 56 | link: https://github.com/bryanp/goru/pull/21 57 | author: 58 | username: bryanp 59 | link: https://github.com/bryanp 60 | - id: 20 61 | type: add 62 | summary: Add observer pattern to routines 63 | link: https://github.com/bryanp/goru/pull/20 64 | author: 65 | username: bryanp 66 | link: https://github.com/bryanp 67 | - id: 19 68 | type: add 69 | summary: Add ability to pause and resume a routine 70 | link: https://github.com/bryanp/goru/pull/19 71 | author: 72 | username: bryanp 73 | link: https://github.com/bryanp 74 | - id: 18 75 | type: chg 76 | summary: Remove the unused `observer` writer from `Goru::Channel` 77 | link: https://github.com/bryanp/goru/pull/18 78 | author: 79 | username: bryanp 80 | link: https://github.com/bryanp 81 | - version: v0.3.0 82 | date: '2023-07-10' 83 | link: https://github.com/bryanp/goru/releases/tag/v0.3.0 84 | changes: 85 | - id: 16 86 | type: chg 87 | summary: Improve control flow 88 | link: https://github.com/bryanp/goru/pull/16 89 | author: 90 | username: bryanp 91 | link: https://github.com/bryanp 92 | - id: 17 93 | type: fix 94 | summary: Handle `IOError` from closed selector 95 | link: https://github.com/bryanp/goru/pull/17 96 | author: 97 | username: bryanp 98 | link: https://github.com/bryanp 99 | - id: 15 100 | type: chg 101 | summary: Rename `default_scheduler_count` 102 | link: https://github.com/bryanp/goru/pull/15 103 | author: 104 | username: bryanp 105 | link: https://github.com/bryanp 106 | - id: 14 107 | type: chg 108 | summary: Improve cold start of scheduler 109 | link: https://github.com/bryanp/goru/pull/14 110 | author: 111 | username: bryanp 112 | link: https://github.com/bryanp 113 | - id: 13 114 | type: chg 115 | summary: Go back to selector-based reactor 116 | link: https://github.com/bryanp/goru/pull/13 117 | author: 118 | username: bryanp 119 | link: https://github.com/bryanp 120 | - id: 12 121 | type: chg 122 | summary: Refactor bridges (again) 123 | link: https://github.com/bryanp/goru/pull/12 124 | author: 125 | username: bryanp 126 | link: https://github.com/bryanp 127 | - id: 11 128 | type: dep 129 | summary: Change responsibilities of routine sleep behavior to be more clear 130 | link: https://github.com/bryanp/goru/pull/11 131 | author: 132 | username: bryanp 133 | link: https://github.com/bryanp 134 | - id: 10 135 | type: chg 136 | summary: Optimize how reactor status is set 137 | link: https://github.com/bryanp/goru/pull/10 138 | author: 139 | username: bryanp 140 | link: https://github.com/bryanp 141 | - id: 9 142 | type: chg 143 | summary: Refactor bridges 144 | link: https://github.com/bryanp/goru/pull/9 145 | author: 146 | username: bryanp 147 | link: https://github.com/bryanp 148 | - id: 8 149 | type: chg 150 | summary: Cleanup finished routines on next tick 151 | link: https://github.com/bryanp/goru/pull/8 152 | author: 153 | username: bryanp 154 | link: https://github.com/bryanp 155 | - version: v0.2.0 156 | date: '2023-05-01' 157 | link: https://github.com/bryanp/goru/releases/tag/v0.2.0 158 | changes: 159 | - id: 6 160 | type: fix 161 | summary: Finish routines on error 162 | link: https://github.com/bryanp/goru/pull/6 163 | author: 164 | username: bryanp 165 | link: https://github.com/bryanp 166 | - id: 5 167 | type: fix 168 | summary: Correctly set channel status to `finished` when closed 169 | link: https://github.com/bryanp/goru/pull/5 170 | author: 171 | username: bryanp 172 | link: https://github.com/bryanp 173 | - id: 4 174 | type: chg 175 | summary: Only log when routines are in debug mode 176 | link: https://github.com/bryanp/goru/pull/4 177 | author: 178 | username: bryanp 179 | link: https://github.com/bryanp 180 | - id: 3 181 | type: fix 182 | summary: Update `Goru::Channel#full?` to always return a boolean 183 | link: https://github.com/bryanp/goru/pull/3 184 | author: 185 | username: bryanp 186 | link: https://github.com/bryanp 187 | - id: 2 188 | type: dep 189 | summary: Remove ability to reopen channels 190 | link: https://github.com/bryanp/goru/pull/2 191 | author: 192 | username: bryanp 193 | link: https://github.com/bryanp 194 | - version: v0.1.0 195 | date: '2023-03-29' 196 | link: https://github.com/bryanp/goru/releases/tag/v0.1.0 197 | changes: 198 | - id: 1 199 | type: add 200 | summary: Initial implementation 201 | link: https://github.com/bryanp/featuring 202 | author: 203 | username: bryanp 204 | link: https://github.com/bryanp 205 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint-ruby: 10 | runs-on: ubuntu-latest 11 | 12 | name: "lint / ${{ matrix.ruby }}" 13 | 14 | strategy: 15 | matrix: 16 | ruby: 17 | - 3.2.0 18 | 19 | fail-fast: false 20 | 21 | steps: 22 | - uses: actions/checkout@v1 23 | 24 | - name: Setup Ruby 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: "${{matrix.ruby}}" 28 | 29 | - name: Install Dependencies 30 | shell: bash -l -e -o pipefail {0} 31 | run: | 32 | rm -f Gemfile.lock 33 | bundle install --jobs=3 && bundle update --jobs=3 34 | 35 | - name: Run Linter 36 | shell: bash -l -e -o pipefail {0} 37 | run: | 38 | CI=true bundle exec standardrb 39 | 40 | test-ruby: 41 | runs-on: ubuntu-latest 42 | 43 | name: "test / ${{ matrix.ruby }}" 44 | 45 | strategy: 46 | matrix: 47 | ruby: 48 | - 3.2.0 49 | 50 | fail-fast: false 51 | 52 | steps: 53 | - uses: actions/checkout@v1 54 | 55 | - name: Setup Ruby 56 | uses: ruby/setup-ruby@v1 57 | with: 58 | ruby-version: "${{matrix.ruby}}" 59 | 60 | - name: Install Dependencies 61 | shell: bash -l -e -o pipefail {0} 62 | run: | 63 | rm -f Gemfile.lock 64 | bundle install --jobs=3 && bundle update --jobs=3 65 | 66 | - name: Run Tests 67 | shell: bash -l -e -o pipefail {0} 68 | run: | 69 | CI=true bundle exec rspec 70 | -------------------------------------------------------------------------------- /.github/workflows/commit-tools.yml: -------------------------------------------------------------------------------- 1 | name: commit 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | push: 8 | repository_dispatch: 9 | 10 | jobs: 11 | update-changelogs: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | 18 | - name: Checkout commit tools 19 | uses: actions/checkout@v2 20 | with: 21 | repository: metabahn/commit 22 | path: .commit/tools 23 | 24 | - name: Setup ruby 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: '2.7' 28 | 29 | - name: Update changelogs 30 | run: ./.commit/tools/bin/update-changelogs 31 | env: 32 | COMMIT__GIT_EMAIL: "bot@metabahn.com" 33 | COMMIT__GIT_NAME: "Metabahn Bot" 34 | COMMIT__GIT_TOKEN: ${{secrets.COMMIT_TOKEN}} 35 | COMMIT__GIT_USER: "metabahn-bot" 36 | 37 | update-templates: 38 | needs: 39 | - update-changelogs 40 | 41 | runs-on: ubuntu-latest 42 | 43 | steps: 44 | - name: Checkout code 45 | uses: actions/checkout@v2 46 | 47 | - name: Checkout commit tools 48 | uses: actions/checkout@v2 49 | with: 50 | repository: metabahn/commit 51 | path: .commit/tools 52 | 53 | - name: Setup ruby 54 | uses: ruby/setup-ruby@v1 55 | with: 56 | ruby-version: '2.7' 57 | 58 | - name: Update templates 59 | run: ./.commit/tools/bin/update-templates 60 | env: 61 | COMMIT__GIT_EMAIL: "bot@metabahn.com" 62 | COMMIT__GIT_NAME: "Metabahn Bot" 63 | COMMIT__GIT_TOKEN: ${{secrets.COMMIT_TOKEN}} 64 | COMMIT__GIT_USER: "metabahn-bot" 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .commit/tools 2 | *.bundle 3 | *.gem 4 | Gemfile.lock 5 | .ruby-version 6 | 7 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require ./spec/initialize 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | format: progress 2 | parallel: true 3 | 4 | ignore: 5 | - 'examples/memory.rb': 6 | - Style/GlobalVars 7 | - 'examples/simple.rb': 8 | - Style/MixinUsage 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v0.5.0](https://github.com/bryanp/goru/releases/tag/v0.5.0) 2 | 3 | *released on 2023-12-24* 4 | 5 | * `chg` [#26](https://github.com/bryanp/goru/pull/26) Bump core dependencies ([bryanp](https://github.com/bryanp)) 6 | 7 | ## [v0.4.2](https://github.com/bryanp/goru/releases/tag/v0.4.2) 8 | 9 | *released on 2023-09-17* 10 | 11 | * `fix` [#24](https://github.com/bryanp/goru/pull/24) Close the io object when the routine is finished ([bryanp](https://github.com/bryanp)) 12 | * `fix` [#25](https://github.com/bryanp/goru/pull/25) Handle IOError in the scheduler and reactor ([bryanp](https://github.com/bryanp)) 13 | 14 | ## [v0.4.1](https://github.com/bryanp/goru/releases/tag/v0.4.1) 15 | 16 | *released on 2023-07-29* 17 | 18 | * `fix` [#23](https://github.com/bryanp/goru/pull/23) Handle `IOError` in io routines ([bryanp](https://github.com/bryanp)) 19 | 20 | ## [v0.4.0](https://github.com/bryanp/goru/releases/tag/v0.4.0) 21 | 22 | *released on 2023-07-16* 23 | 24 | * `add` [#22](https://github.com/bryanp/goru/pull/22) Add reactor as a reader to `Goru::Routine` ([bryanp](https://github.com/bryanp)) 25 | * `add` [#21](https://github.com/bryanp/goru/pull/21) Improve statuses ([bryanp](https://github.com/bryanp)) 26 | * `add` [#20](https://github.com/bryanp/goru/pull/20) Add observer pattern to routines ([bryanp](https://github.com/bryanp)) 27 | * `add` [#19](https://github.com/bryanp/goru/pull/19) Add ability to pause and resume a routine ([bryanp](https://github.com/bryanp)) 28 | * `chg` [#18](https://github.com/bryanp/goru/pull/18) Remove the unused `observer` writer from `Goru::Channel` ([bryanp](https://github.com/bryanp)) 29 | 30 | ## [v0.3.0](https://github.com/bryanp/goru/releases/tag/v0.3.0) 31 | 32 | *released on 2023-07-10* 33 | 34 | * `chg` [#16](https://github.com/bryanp/goru/pull/16) Improve control flow ([bryanp](https://github.com/bryanp)) 35 | * `fix` [#17](https://github.com/bryanp/goru/pull/17) Handle `IOError` from closed selector ([bryanp](https://github.com/bryanp)) 36 | * `chg` [#15](https://github.com/bryanp/goru/pull/15) Rename `default_scheduler_count` ([bryanp](https://github.com/bryanp)) 37 | * `chg` [#14](https://github.com/bryanp/goru/pull/14) Improve cold start of scheduler ([bryanp](https://github.com/bryanp)) 38 | * `chg` [#13](https://github.com/bryanp/goru/pull/13) Go back to selector-based reactor ([bryanp](https://github.com/bryanp)) 39 | * `chg` [#12](https://github.com/bryanp/goru/pull/12) Refactor bridges (again) ([bryanp](https://github.com/bryanp)) 40 | * `dep` [#11](https://github.com/bryanp/goru/pull/11) Change responsibilities of routine sleep behavior to be more clear ([bryanp](https://github.com/bryanp)) 41 | * `chg` [#10](https://github.com/bryanp/goru/pull/10) Optimize how reactor status is set ([bryanp](https://github.com/bryanp)) 42 | * `chg` [#9](https://github.com/bryanp/goru/pull/9) Refactor bridges ([bryanp](https://github.com/bryanp)) 43 | * `chg` [#8](https://github.com/bryanp/goru/pull/8) Cleanup finished routines on next tick ([bryanp](https://github.com/bryanp)) 44 | 45 | ## [v0.2.0](https://github.com/bryanp/goru/releases/tag/v0.2.0) 46 | 47 | *released on 2023-05-01* 48 | 49 | * `fix` [#6](https://github.com/bryanp/goru/pull/6) Finish routines on error ([bryanp](https://github.com/bryanp)) 50 | * `fix` [#5](https://github.com/bryanp/goru/pull/5) Correctly set channel status to `finished` when closed ([bryanp](https://github.com/bryanp)) 51 | * `chg` [#4](https://github.com/bryanp/goru/pull/4) Only log when routines are in debug mode ([bryanp](https://github.com/bryanp)) 52 | * `fix` [#3](https://github.com/bryanp/goru/pull/3) Update `Goru::Channel#full?` to always return a boolean ([bryanp](https://github.com/bryanp)) 53 | * `dep` [#2](https://github.com/bryanp/goru/pull/2) Remove ability to reopen channels ([bryanp](https://github.com/bryanp)) 54 | 55 | ## [v0.1.0](https://github.com/bryanp/goru/releases/tag/v0.1.0) 56 | 57 | *released on 2023-03-29* 58 | 59 | * `add` [#1](https://github.com/bryanp/featuring) Initial implementation ([bryanp](https://github.com/bryanp)) 60 | 61 | 62 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | group :development do 8 | gem "ruby-prof" 9 | gem "standardrb" 10 | end 11 | 12 | group :test do 13 | gem "get_process_mem" 14 | gem "http" 15 | gem "llhttp" 16 | gem "rspec" 17 | end 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. “Contributor” 6 | 7 | means each individual or legal entity that creates, contributes to the 8 | creation of, or owns Covered Software. 9 | 10 | 1.2. “Contributor Version” 11 | 12 | means the combination of the Contributions of others (if any) used by a 13 | Contributor and that particular Contributor’s Contribution. 14 | 15 | 1.3. “Contribution” 16 | 17 | means Covered Software of a particular Contributor. 18 | 19 | 1.4. “Covered Software” 20 | 21 | means Source Code Form to which the initial Contributor has attached the 22 | notice in Exhibit A, the Executable Form of such Source Code Form, and 23 | Modifications of such Source Code Form, in each case including portions 24 | thereof. 25 | 26 | 1.5. “Incompatible With Secondary Licenses” 27 | means 28 | 29 | a. that the initial Contributor has attached the notice described in 30 | Exhibit B to the Covered Software; or 31 | 32 | b. that the Covered Software was made available under the terms of version 33 | 1.1 or earlier of the License, but not also under the terms of a 34 | Secondary License. 35 | 36 | 1.6. “Executable Form” 37 | 38 | means any form of the work other than Source Code Form. 39 | 40 | 1.7. “Larger Work” 41 | 42 | means a work that combines Covered Software with other material, in a separate 43 | file or files, that is not Covered Software. 44 | 45 | 1.8. “License” 46 | 47 | means this document. 48 | 49 | 1.9. “Licensable” 50 | 51 | means having the right to grant, to the maximum extent possible, whether at the 52 | time of the initial grant or subsequently, any and all of the rights conveyed by 53 | this License. 54 | 55 | 1.10. “Modifications” 56 | 57 | means any of the following: 58 | 59 | a. any file in Source Code Form that results from an addition to, deletion 60 | from, or modification of the contents of Covered Software; or 61 | 62 | b. any new file in Source Code Form that contains any Covered Software. 63 | 64 | 1.11. “Patent Claims” of a Contributor 65 | 66 | means any patent claim(s), including without limitation, method, process, 67 | and apparatus claims, in any patent Licensable by such Contributor that 68 | would be infringed, but for the grant of the License, by the making, 69 | using, selling, offering for sale, having made, import, or transfer of 70 | either its Contributions or its Contributor Version. 71 | 72 | 1.12. “Secondary License” 73 | 74 | means either the GNU General Public License, Version 2.0, the GNU Lesser 75 | General Public License, Version 2.1, the GNU Affero General Public 76 | License, Version 3.0, or any later versions of those licenses. 77 | 78 | 1.13. “Source Code Form” 79 | 80 | means the form of the work preferred for making modifications. 81 | 82 | 1.14. “You” (or “Your”) 83 | 84 | means an individual or a legal entity exercising rights under this 85 | License. For legal entities, “You” includes any entity that controls, is 86 | controlled by, or is under common control with You. For purposes of this 87 | definition, “control” means (a) the power, direct or indirect, to cause 88 | the direction or management of such entity, whether by contract or 89 | otherwise, or (b) ownership of more than fifty percent (50%) of the 90 | outstanding shares or beneficial ownership of such entity. 91 | 92 | 93 | 2. License Grants and Conditions 94 | 95 | 2.1. Grants 96 | 97 | Each Contributor hereby grants You a world-wide, royalty-free, 98 | non-exclusive license: 99 | 100 | a. under intellectual property rights (other than patent or trademark) 101 | Licensable by such Contributor to use, reproduce, make available, 102 | modify, display, perform, distribute, and otherwise exploit its 103 | Contributions, either on an unmodified basis, with Modifications, or as 104 | part of a Larger Work; and 105 | 106 | b. under Patent Claims of such Contributor to make, use, sell, offer for 107 | sale, have made, import, and otherwise transfer either its Contributions 108 | or its Contributor Version. 109 | 110 | 2.2. Effective Date 111 | 112 | The licenses granted in Section 2.1 with respect to any Contribution become 113 | effective for each Contribution on the date the Contributor first distributes 114 | such Contribution. 115 | 116 | 2.3. Limitations on Grant Scope 117 | 118 | The licenses granted in this Section 2 are the only rights granted under this 119 | License. No additional rights or licenses will be implied from the distribution 120 | or licensing of Covered Software under this License. Notwithstanding Section 121 | 2.1(b) above, no patent license is granted by a Contributor: 122 | 123 | a. for any code that a Contributor has removed from Covered Software; or 124 | 125 | b. for infringements caused by: (i) Your and any other third party’s 126 | modifications of Covered Software, or (ii) the combination of its 127 | Contributions with other software (except as part of its Contributor 128 | Version); or 129 | 130 | c. under Patent Claims infringed by Covered Software in the absence of its 131 | Contributions. 132 | 133 | This License does not grant any rights in the trademarks, service marks, or 134 | logos of any Contributor (except as may be necessary to comply with the 135 | notice requirements in Section 3.4). 136 | 137 | 2.4. Subsequent Licenses 138 | 139 | No Contributor makes additional grants as a result of Your choice to 140 | distribute the Covered Software under a subsequent version of this License 141 | (see Section 10.2) or under the terms of a Secondary License (if permitted 142 | under the terms of Section 3.3). 143 | 144 | 2.5. Representation 145 | 146 | Each Contributor represents that the Contributor believes its Contributions 147 | are its original creation(s) or it has sufficient rights to grant the 148 | rights to its Contributions conveyed by this License. 149 | 150 | 2.6. Fair Use 151 | 152 | This License is not intended to limit any rights You have under applicable 153 | copyright doctrines of fair use, fair dealing, or other equivalents. 154 | 155 | 2.7. Conditions 156 | 157 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 158 | Section 2.1. 159 | 160 | 161 | 3. Responsibilities 162 | 163 | 3.1. Distribution of Source Form 164 | 165 | All distribution of Covered Software in Source Code Form, including any 166 | Modifications that You create or to which You contribute, must be under the 167 | terms of this License. You must inform recipients that the Source Code Form 168 | of the Covered Software is governed by the terms of this License, and how 169 | they can obtain a copy of this License. You may not attempt to alter or 170 | restrict the recipients’ rights in the Source Code Form. 171 | 172 | 3.2. Distribution of Executable Form 173 | 174 | If You distribute Covered Software in Executable Form then: 175 | 176 | a. such Covered Software must also be made available in Source Code Form, 177 | as described in Section 3.1, and You must inform recipients of the 178 | Executable Form how they can obtain a copy of such Source Code Form by 179 | reasonable means in a timely manner, at a charge no more than the cost 180 | of distribution to the recipient; and 181 | 182 | b. You may distribute such Executable Form under the terms of this License, 183 | or sublicense it under different terms, provided that the license for 184 | the Executable Form does not attempt to limit or alter the recipients’ 185 | rights in the Source Code Form under this License. 186 | 187 | 3.3. Distribution of a Larger Work 188 | 189 | You may create and distribute a Larger Work under terms of Your choice, 190 | provided that You also comply with the requirements of this License for the 191 | Covered Software. If the Larger Work is a combination of Covered Software 192 | with a work governed by one or more Secondary Licenses, and the Covered 193 | Software is not Incompatible With Secondary Licenses, this License permits 194 | You to additionally distribute such Covered Software under the terms of 195 | such Secondary License(s), so that the recipient of the Larger Work may, at 196 | their option, further distribute the Covered Software under the terms of 197 | either this License or such Secondary License(s). 198 | 199 | 3.4. Notices 200 | 201 | You may not remove or alter the substance of any license notices (including 202 | copyright notices, patent notices, disclaimers of warranty, or limitations 203 | of liability) contained within the Source Code Form of the Covered 204 | Software, except that You may alter any license notices to the extent 205 | required to remedy known factual inaccuracies. 206 | 207 | 3.5. Application of Additional Terms 208 | 209 | You may choose to offer, and to charge a fee for, warranty, support, 210 | indemnity or liability obligations to one or more recipients of Covered 211 | Software. However, You may do so only on Your own behalf, and not on behalf 212 | of any Contributor. You must make it absolutely clear that any such 213 | warranty, support, indemnity, or liability obligation is offered by You 214 | alone, and You hereby agree to indemnify every Contributor for any 215 | liability incurred by such Contributor as a result of warranty, support, 216 | indemnity or liability terms You offer. You may include additional 217 | disclaimers of warranty and limitations of liability specific to any 218 | jurisdiction. 219 | 220 | 4. Inability to Comply Due to Statute or Regulation 221 | 222 | If it is impossible for You to comply with any of the terms of this License 223 | with respect to some or all of the Covered Software due to statute, judicial 224 | order, or regulation then You must: (a) comply with the terms of this License 225 | to the maximum extent possible; and (b) describe the limitations and the code 226 | they affect. Such description must be placed in a text file included with all 227 | distributions of the Covered Software under this License. Except to the 228 | extent prohibited by statute or regulation, such description must be 229 | sufficiently detailed for a recipient of ordinary skill to be able to 230 | understand it. 231 | 232 | 5. Termination 233 | 234 | 5.1. The rights granted under this License will terminate automatically if You 235 | fail to comply with any of its terms. However, if You become compliant, 236 | then the rights granted under this License from a particular Contributor 237 | are reinstated (a) provisionally, unless and until such Contributor 238 | explicitly and finally terminates Your grants, and (b) on an ongoing basis, 239 | if such Contributor fails to notify You of the non-compliance by some 240 | reasonable means prior to 60 days after You have come back into compliance. 241 | Moreover, Your grants from a particular Contributor are reinstated on an 242 | ongoing basis if such Contributor notifies You of the non-compliance by 243 | some reasonable means, this is the first time You have received notice of 244 | non-compliance with this License from such Contributor, and You become 245 | compliant prior to 30 days after Your receipt of the notice. 246 | 247 | 5.2. If You initiate litigation against any entity by asserting a patent 248 | infringement claim (excluding declaratory judgment actions, counter-claims, 249 | and cross-claims) alleging that a Contributor Version directly or 250 | indirectly infringes any patent, then the rights granted to You by any and 251 | all Contributors for the Covered Software under Section 2.1 of this License 252 | shall terminate. 253 | 254 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 255 | license agreements (excluding distributors and resellers) which have been 256 | validly granted by You or Your distributors under this License prior to 257 | termination shall survive termination. 258 | 259 | 6. Disclaimer of Warranty 260 | 261 | Covered Software is provided under this License on an “as is” basis, without 262 | warranty of any kind, either expressed, implied, or statutory, including, 263 | without limitation, warranties that the Covered Software is free of defects, 264 | merchantable, fit for a particular purpose or non-infringing. The entire 265 | risk as to the quality and performance of the Covered Software is with You. 266 | Should any Covered Software prove defective in any respect, You (not any 267 | Contributor) assume the cost of any necessary servicing, repair, or 268 | correction. This disclaimer of warranty constitutes an essential part of this 269 | License. No use of any Covered Software is authorized under this License 270 | except under this disclaimer. 271 | 272 | 7. Limitation of Liability 273 | 274 | Under no circumstances and under no legal theory, whether tort (including 275 | negligence), contract, or otherwise, shall any Contributor, or anyone who 276 | distributes Covered Software as permitted above, be liable to You for any 277 | direct, indirect, special, incidental, or consequential damages of any 278 | character including, without limitation, damages for lost profits, loss of 279 | goodwill, work stoppage, computer failure or malfunction, or any and all 280 | other commercial damages or losses, even if such party shall have been 281 | informed of the possibility of such damages. This limitation of liability 282 | shall not apply to liability for death or personal injury resulting from such 283 | party’s negligence to the extent applicable law prohibits such limitation. 284 | Some jurisdictions do not allow the exclusion or limitation of incidental or 285 | consequential damages, so this exclusion and limitation may not apply to You. 286 | 287 | 8. Litigation 288 | 289 | Any litigation relating to this License may be brought only in the courts of 290 | a jurisdiction where the defendant maintains its principal place of business 291 | and such litigation shall be governed by laws of that jurisdiction, without 292 | reference to its conflict-of-law provisions. Nothing in this Section shall 293 | prevent a party’s ability to bring cross-claims or counter-claims. 294 | 295 | 9. Miscellaneous 296 | 297 | This License represents the complete agreement concerning the subject matter 298 | hereof. If any provision of this License is held to be unenforceable, such 299 | provision shall be reformed only to the extent necessary to make it 300 | enforceable. Any law or regulation which provides that the language of a 301 | contract shall be construed against the drafter shall not be used to construe 302 | this License against a Contributor. 303 | 304 | 305 | 10. Versions of the License 306 | 307 | 10.1. New Versions 308 | 309 | Mozilla Foundation is the license steward. Except as provided in Section 310 | 10.3, no one other than the license steward has the right to modify or 311 | publish new versions of this License. Each version will be given a 312 | distinguishing version number. 313 | 314 | 10.2. Effect of New Versions 315 | 316 | You may distribute the Covered Software under the terms of the version of 317 | the License under which You originally received the Covered Software, or 318 | under the terms of any subsequent version published by the license 319 | steward. 320 | 321 | 10.3. Modified Versions 322 | 323 | If you create software not governed by this License, and you want to 324 | create a new license for such software, you may create and use a modified 325 | version of this License if you rename the license and remove any 326 | references to the name of the license steward (except to note that such 327 | modified license differs from this License). 328 | 329 | 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 330 | If You choose to distribute Source Code Form that is Incompatible With 331 | Secondary Licenses under the terms of this version of the License, the 332 | notice described in Exhibit B of this License must be attached. 333 | 334 | Exhibit A - Source Code Form License Notice 335 | 336 | This Source Code Form is subject to the 337 | terms of the Mozilla Public License, v. 338 | 2.0. If a copy of the MPL was not 339 | distributed with this file, You can 340 | obtain one at 341 | http://mozilla.org/MPL/2.0/. 342 | 343 | If it is not possible or desirable to put the notice in a particular file, then 344 | You may include the notice in a location (such as a LICENSE file in a relevant 345 | directory) where a recipient would be likely to look for such a notice. 346 | 347 | You may add additional accurate notices of copyright ownership. 348 | 349 | Exhibit B - “Incompatible With Secondary Licenses” Notice 350 | 351 | This Source Code Form is “Incompatible 352 | With Secondary Licenses”, as defined by 353 | the Mozilla Public License, v. 2.0. 354 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Concurrent routines for Ruby.** 2 | 3 | Goru is an experimental concurrency library for Ruby. 4 | 5 | * **Lightweight:** Goru routines are not backed by fibers or threads. Each routine creates only ~345 bytes of memory overhead. 6 | * **Explicit:** Goru requires you to describe exactly how a routine behaves. Less magic makes for fewer bugs when writing concurrent programs. 7 | 8 | Goru was intended for low-level programs like http servers and not for direct use in user-facing code. 9 | 10 | ## How It Works 11 | 12 | Routines are defined with initial state and a block that does work and (optionally) updates the state of the routine: 13 | 14 | ```ruby 15 | 3.times do 16 | Goru::Scheduler.go(:running) { |routine| 17 | case routine.state 18 | when :running 19 | routine.update(:sleeping) 20 | routine.sleep(rand) 21 | when :sleeping 22 | puts "[#{object_id}] woke up at #{Time.now.to_f}" 23 | routine.update(:running) 24 | end 25 | } 26 | end 27 | ``` 28 | 29 | Routines run concurrently within a reactor, each reactor running in a dedicated thread. Each eligible routine is called 30 | once on every tick of the reactor it is scheduled to run in. In the example above, the three routines sleep for a random 31 | interval before waking up and printing the current time. Here is some example output: 32 | 33 | ``` 34 | [1840] woke up at 1677939216.379147 35 | [1860] woke up at 1677939217.059535 36 | [1920] woke up at 1677939217.190349 37 | [1860] woke up at 1677939217.6196458 38 | [1920] woke up at 1677939217.935916 39 | [1840] woke up at 1677939218.033243 40 | [1860] woke up at 1677939218.532908 41 | [1920] woke up at 1677939218.8669178 42 | [1840] woke up at 1677939219.379714 43 | [1860] woke up at 1677939219.522777 44 | [1920] woke up at 1677939220.0475688 45 | [1840] woke up at 1677939220.253979 46 | ``` 47 | 48 | Each reactor can only run one routine at any given point in time, but if a routine blocks (e.g. by sleeping or 49 | performing i/o) the reactor calls another eligible routine before returning to the previously blocked routine 50 | on the next tick. 51 | 52 | ## Scheduler 53 | 54 | By default Goru routines are scheduled in a global scheduler that waits at the end of the program for all routines 55 | to finish. While this is useful for small scripts, most use-cases will involve creating your own scheduler and 56 | registering routines directly: 57 | 58 | ```ruby 59 | scheduler = Goru::Scheduler.new 60 | scheduler.go { |routine| 61 | ... 62 | } 63 | scheduler.wait 64 | ``` 65 | 66 | Routines are scheduled to run immediately after registration. 67 | 68 | ### Tuning 69 | 70 | Schedulers default to running a number of reactors matching the number of processors on the current system. Tune 71 | this to your needs with the `count` option when creating a scheduler: 72 | 73 | ```ruby 74 | scheduler = Goru::Scheduler.new(count: 3) 75 | ``` 76 | 77 | ## State 78 | 79 | Routines are initialized with default state that is useful for coordination between ticks. This is perhaps the 80 | oddest part of Goru but the explicitness can make it easier to understand exactly how your routines will behave. 81 | 82 | Take a look at the [examples](./examples) to get some ideas. 83 | 84 | ## Finishing 85 | 86 | Routines will run forever until you say they are finished: 87 | 88 | ```ruby 89 | Goru::Scheduler.go { |routine| 90 | routine.finished 91 | } 92 | ``` 93 | 94 | ### Results 95 | 96 | When finishing a routine you can provide a final result: 97 | 98 | ```ruby 99 | routines = [] 100 | scheduler = Goru::Scheduler.new 101 | routines << scheduler.go { |routine| routine.finished(true) } 102 | routines << scheduler.go { |routine| routine.finished(false) } 103 | routines << scheduler.go { |routine| routine.finished(true) } 104 | scheduler.wait 105 | 106 | pp routines.map(&:result) 107 | # [true, false, true] 108 | ``` 109 | 110 | ## Error Handling 111 | 112 | Unhandled errors within a routine cause the routine to enter an `:errored` state. Calling `result` on an errored 113 | routine causes the error to be re-raised. Routines can handle errors elegantly using the `handle` method: 114 | 115 | ```ruby 116 | Goru::Scheduler.go { |routine| 117 | routine.handle(StandardError) do |event:| 118 | # do something with `event` 119 | end 120 | 121 | ... 122 | } 123 | ``` 124 | 125 | ## Sleeping 126 | 127 | Goru implements a non-blocking version of `sleep` that makes the routine ineligible to be called until the sleep time 128 | has elapsed. It is important to note that Ruby's built-in sleep method will block the reactor and should not be used. 129 | 130 | ```ruby 131 | Goru::Scheduler.go { |routine| 132 | routine.sleep(3) 133 | } 134 | ``` 135 | 136 | Unlike `Kernel#sleep` Goru's sleep method requires a duration. 137 | 138 | ## Channels 139 | 140 | Goru offers buffered reading and writing through channels: 141 | 142 | ```ruby 143 | channel = Goru::Channel.new 144 | 145 | Goru::Scheduler.go(channel: channel, intent: :w) { |routine| 146 | routine << SecureRandom.hex 147 | } 148 | 149 | # This routine is not invoked unless the channel contains data for reading. 150 | # 151 | Goru::Scheduler.go(channel: channel, intent: :r) { |routine| 152 | value = routine.read 153 | } 154 | ``` 155 | 156 | Channels are unbounded by default, meaning they can hold an unlimited amount of data. This behavior can be changed by 157 | initializing a channel with a specific size. Routines with the intent to write will not be invoked unless the channel 158 | has space available for writing. 159 | 160 | ```ruby 161 | channel = Goru::Channel.new(size: 3) 162 | 163 | # This routine is not invoked if the channel is full. 164 | # 165 | Goru::Scheduler.go(channel: channel, intent: :w) { |routine| 166 | routine << SecureRandom.hex 167 | } 168 | ``` 169 | 170 | ## IO 171 | 172 | Goru includes a pattern for non-blocking io. With it you can implement non-blocking servers, clients, etc. 173 | 174 | Routines that involve io must be created with an io object and an intent. Possible intents include: 175 | 176 | * `:r` for reading 177 | * `:w` for writing 178 | * `:rw` for reading and writing 179 | 180 | Here is the beginning of an http server in Goru: 181 | 182 | ```ruby 183 | Goru::Scheduler.go(io: TCPServer.new("localhost", 4242), intent: :r) { |server_routine| 184 | next unless client = server_routine.accept 185 | 186 | Goru::Scheduler.go(io: client, intent: :r) { |client_routine| 187 | next unless data = client_routine.read(16384) 188 | 189 | # do something with `data` 190 | } 191 | } 192 | ``` 193 | 194 | ### Changing Intents 195 | 196 | Intents can be changed after a routine is created, e.g. to switch a routine from reading to writing: 197 | 198 | ```ruby 199 | Goru::Scheduler.go(io: io, intent: :r) { |routine| 200 | routine.intent = :w 201 | } 202 | ``` 203 | 204 | ## Bridges 205 | 206 | Goru supports coordinated buffered io using bridges: 207 | 208 | ```ruby 209 | writer = Goru::Channel.new 210 | 211 | Goru::Scheduler.go(io: io, intent: :r) { |routine| 212 | case routine.intent 213 | when :r 214 | routine.bridge(intent: :w, channel: writer) { |bridge| 215 | bridge << SecureRandom.hex 216 | } 217 | when :w 218 | if (data = writer.read) 219 | routine.write(data) 220 | end 221 | end 222 | } 223 | ``` 224 | 225 | Using bridges, the io routine is only called again when two conditions are met: 226 | 227 | 1. The io object matches the bridged intent (e.g. it is writable). 228 | 2. The channel is in the correct state to reciprocate the intent (e.g. it has data). 229 | 230 | See the [server example](./examples/server.rb) for a more complete use-case. 231 | 232 | ## Credits 233 | 234 | Goru was designed while writing a project in Go and imagining what Go-like concurrency might look like in Ruby. 235 | -------------------------------------------------------------------------------- /benchmarks/channels.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/goru" 4 | 5 | class Reader 6 | include Goru 7 | 8 | def initialize(channel:) 9 | @received = [] 10 | 11 | go(channel: channel, intent: :r) { |routine| 12 | value = routine.read 13 | @received << value 14 | } 15 | end 16 | 17 | attr_reader :received 18 | end 19 | 20 | class Writer 21 | include Goru 22 | 23 | def initialize(channel:, values:) 24 | go(channel: channel, intent: :w) { |routine| 25 | if (value = values.shift) 26 | routine << value 27 | else 28 | channel.close 29 | routine.finished 30 | end 31 | } 32 | end 33 | end 34 | 35 | values = 100_000.times.to_a 36 | channel = Goru::Channel.new 37 | 38 | start = Time.now 39 | Reader.new(channel: channel) 40 | Writer.new(channel: channel, values: values) 41 | 42 | Goru::Scheduler.wait 43 | puts Time.now - start 44 | -------------------------------------------------------------------------------- /examples/buffers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/goru" 4 | 5 | channel = Goru::Channel.new(size: 3) 6 | 7 | class Reader 8 | include Goru 9 | 10 | def initialize(channel:) 11 | @received = [] 12 | 13 | go(:sleep, channel: channel, intent: :r) { |routine| 14 | case routine.state 15 | when :sleep 16 | routine.update(:read) 17 | routine.sleep(rand) 18 | when :read 19 | if (value = routine.read) 20 | @received << value 21 | puts "received: #{value}" 22 | routine.update(:sleep) 23 | else 24 | routine.finished 25 | end 26 | end 27 | } 28 | end 29 | 30 | attr_reader :received 31 | end 32 | 33 | class Writer 34 | include Goru 35 | 36 | def initialize(channel:) 37 | @writable = 10.times.to_a 38 | values = @writable.dup 39 | 40 | go(channel: channel, intent: :w) { |routine| 41 | if (value = values.shift) 42 | routine << value 43 | puts "wrote: #{value}" 44 | else 45 | channel.close 46 | routine.finished 47 | end 48 | } 49 | end 50 | 51 | attr_reader :writable 52 | end 53 | 54 | reader = Reader.new(channel: channel) 55 | writer = Writer.new(channel: channel) 56 | start = Time.now 57 | 58 | loop do 59 | if reader.received == writer.writable 60 | break 61 | elsif Time.now - start > 5 62 | fail "timed out" 63 | else 64 | sleep(0.1) 65 | end 66 | end 67 | 68 | puts "all received after #{Time.now - start}" 69 | Goru::Scheduler.stop 70 | -------------------------------------------------------------------------------- /examples/channels.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/goru" 4 | 5 | channel = Goru::Channel.new 6 | 7 | class Reader 8 | include Goru 9 | 10 | def initialize(channel:) 11 | @received = [] 12 | 13 | go(channel: channel, intent: :r) { |routine| 14 | value = routine.read 15 | @received << value 16 | puts "received: #{value}" 17 | } 18 | end 19 | 20 | attr_reader :received 21 | end 22 | 23 | class Writer 24 | include Goru 25 | 26 | def initialize(channel:) 27 | @writable = 10.times.to_a 28 | values = @writable.dup 29 | 30 | go(:sleep, channel: channel, intent: :w) { |routine| 31 | case routine.state 32 | when :sleep 33 | routine.update(:write) 34 | routine.sleep(rand) 35 | when :write 36 | if (value = values.shift) 37 | routine << value 38 | routine.update(:sleep) 39 | puts "wrote: #{value}" 40 | end 41 | 42 | if values.empty? 43 | channel.close 44 | routine.finished 45 | end 46 | end 47 | } 48 | end 49 | 50 | attr_reader :writable 51 | end 52 | 53 | reader = Reader.new(channel: channel) 54 | writer = Writer.new(channel: channel) 55 | start = Time.now 56 | 57 | loop do 58 | if reader.received == writer.writable 59 | break 60 | elsif Time.now - start > 10 61 | fail "timed out" 62 | else 63 | sleep(0.1) 64 | end 65 | end 66 | 67 | puts "all received after #{Time.now - start}" 68 | Goru::Scheduler.stop 69 | -------------------------------------------------------------------------------- /examples/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/goru" 4 | 5 | class Errored 6 | include Goru 7 | 8 | def initialize 9 | # Custom error handler. 10 | # 11 | go { |routine| 12 | routine.handle(StandardError) do |event:| 13 | puts "!!! #{event}" 14 | end 15 | 16 | fail "[custom] something went wrong: #{SecureRandom.hex}" 17 | } 18 | 19 | # Default error handler. 20 | # 21 | go { |routine| 22 | fail "[default] something went wrong: #{SecureRandom.hex}" 23 | } 24 | end 25 | end 26 | 27 | Errored.new 28 | -------------------------------------------------------------------------------- /examples/memory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/goru" 4 | 5 | def current_memory_usage 6 | `ps -o rss #{Process.pid}`.lines.last.to_i 7 | end 8 | 9 | $size = 10_000 10 | 11 | class Profiler 12 | include Goru 13 | 14 | def initialize(size:) 15 | @size = size 16 | end 17 | 18 | def call 19 | @size.times do 20 | go(true) { |routine| 21 | if routine.state 22 | routine.update(false) 23 | routine.sleep(rand) 24 | else 25 | routine.finished 26 | end 27 | } 28 | end 29 | end 30 | end 31 | 32 | profiler = Profiler.new(size: $size) 33 | 34 | starting_memory_usage = current_memory_usage 35 | 36 | profiler.call 37 | 38 | total_memory_usage_in_bytes = (current_memory_usage - starting_memory_usage).to_f * 1024 39 | puts "#{total_memory_usage_in_bytes / $size} bytes per routine" 40 | -------------------------------------------------------------------------------- /examples/results.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/goru/scheduler" 4 | 5 | routines = [] 6 | scheduler = Goru::Scheduler.new 7 | routines << scheduler.go { |routine| routine.finished(true) } 8 | routines << scheduler.go { |routine| routine.finished(false) } 9 | routines << scheduler.go { |routine| routine.finished(true) } 10 | scheduler.wait 11 | 12 | pp routines.map(&:result) 13 | -------------------------------------------------------------------------------- /examples/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../spec/support/server" 4 | 5 | server = Server.new 6 | server.start 7 | 8 | sleep(1) 9 | 10 | begin 11 | puts "making requests..." 12 | puts "got: #{HTTP.timeout(1).get("http://localhost:4242").status}" 13 | puts "got: #{HTTP.timeout(1).get("http://localhost:4242").status}" 14 | puts "got: #{HTTP.timeout(1).get("http://localhost:4242").status}" 15 | ensure 16 | puts "shutting down..." 17 | server.stop 18 | end 19 | -------------------------------------------------------------------------------- /examples/simple.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/goru" 4 | 5 | include Goru 6 | 7 | go(:foo) { |routine| 8 | routine.finished(true) 9 | } 10 | -------------------------------------------------------------------------------- /examples/sleeper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/goru" 4 | 5 | class Sleeper 6 | include Goru 7 | 8 | def call 9 | 5.times do |index| 10 | go(true) { |routine| 11 | if routine.state 12 | routine.update(false) 13 | routine.sleep(rand) 14 | else 15 | puts "[#{index}] woke up at #{Time.now.to_f}" 16 | routine.update(true) 17 | end 18 | } 19 | end 20 | end 21 | end 22 | 23 | sleeper = Sleeper.new 24 | sleeper.call 25 | -------------------------------------------------------------------------------- /examples/uuids.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "get_process_mem" 4 | require "securerandom" 5 | 6 | require_relative "../lib/goru" 7 | 8 | class Worker 9 | include Goru 10 | 11 | def initialize 12 | @results = [] 13 | end 14 | 15 | attr_reader :results 16 | 17 | def call 18 | 100_000.times do |index| 19 | go(0) { |routine| 20 | state = routine.state 21 | 22 | if state >= 10 23 | routine.finished 24 | else 25 | @results << SecureRandom.uuid 26 | routine.update(state + 1) 27 | end 28 | } 29 | 30 | # Use this to see how fast things are without concurrency. 31 | # 32 | # 10.times do |j| 33 | # @results << SecureRandom.uuid 34 | # end 35 | end 36 | end 37 | end 38 | 39 | def mem 40 | GetProcessMem.new.mb.round(0) 41 | end 42 | 43 | start = Time.now 44 | worker = Worker.new 45 | worker.call 46 | 47 | # Wait before we attempt to get results (ultimately need some kind of future). 48 | # 49 | Goru::Scheduler.wait 50 | 51 | puts "got #{worker.results.count} in #{Time.now - start}, using #{mem}MB" 52 | -------------------------------------------------------------------------------- /goru.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../lib/goru/version", __FILE__) 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "goru" 7 | spec.version = Goru::VERSION 8 | spec.summary = "Concurrent routines for Ruby." 9 | spec.description = spec.summary 10 | 11 | spec.author = "Bryan Powell" 12 | spec.email = "bryan@bryanp.org" 13 | spec.homepage = "https://github.com/bryanp/goru/" 14 | 15 | spec.required_ruby_version = ">= 3.2.0" 16 | 17 | spec.license = "MPL-2.0" 18 | 19 | spec.files = Dir["CHANGELOG.md", "README.md", "LICENSE", "lib/**/*"] 20 | spec.require_path = "lib" 21 | 22 | spec.add_dependency "core-extension", "~> 0.5" 23 | spec.add_dependency "core-handler", "~> 0.2" 24 | spec.add_dependency "core-global", "~> 0.3" 25 | spec.add_dependency "nio4r", "~> 2.5" 26 | spec.add_dependency "timers", "~> 4.3" 27 | end 28 | -------------------------------------------------------------------------------- /lib/goru.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "core/extension" 4 | 5 | module Goru 6 | require_relative "goru/scheduler" 7 | require_relative "goru/version" 8 | 9 | extend Core::Extension 10 | 11 | def go(state = nil, io: nil, channel: nil, intent: nil, &block) 12 | Scheduler.go(state, io: io, channel: channel, intent: intent, &block) 13 | end 14 | end 15 | 16 | at_exit { Goru::Scheduler.wait } 17 | -------------------------------------------------------------------------------- /lib/goru/bridge.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Goru 4 | # [public] 5 | # 6 | class Bridge 7 | def initialize(routine:, channel:) 8 | @routine = routine 9 | @channel = channel 10 | @channel.add_observer(self) 11 | update_status 12 | end 13 | 14 | # [public] 15 | # 16 | STATUS_READY = :ready 17 | 18 | # [public] 19 | # 20 | STATUS_FINISHED = :finished 21 | 22 | # [public] 23 | # 24 | STATUS_IDLE = :idle 25 | 26 | # [public] 27 | # 28 | attr_reader :status 29 | 30 | # [public] 31 | # 32 | private def set_status(status) 33 | @status = status 34 | status_changed 35 | end 36 | 37 | # [public] 38 | # 39 | def update_status 40 | # noop 41 | end 42 | 43 | private def status_changed 44 | case @status 45 | when :STATUS_READY 46 | @routine.bridged 47 | when :STATUS_FINISHED 48 | @channel.remove_observer(self) 49 | @routine.unbridge 50 | end 51 | end 52 | 53 | def channel_received 54 | update_status 55 | end 56 | 57 | def channel_read 58 | update_status 59 | end 60 | 61 | def channel_closed 62 | update_status 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/goru/bridges/readable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../bridge" 4 | 5 | module Goru 6 | module Bridges 7 | class Readable < Bridge 8 | private def update_status 9 | status = if @routine.status == Routine::STATUS_FINISHED 10 | Bridge::STATUS_FINISHED 11 | elsif @channel.full? 12 | Bridge::STATUS_IDLE 13 | elsif @channel.closed? 14 | Bridge::STATUS_FINISHED 15 | else 16 | Bridge::STATUS_READY 17 | end 18 | 19 | set_status(status) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/goru/bridges/writable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../bridge" 4 | 5 | module Goru 6 | module Bridges 7 | class Writable < Bridge 8 | private def update_status 9 | status = if @routine.status == Routine::STATUS_FINISHED 10 | Bridge::STATUS_FINISHED 11 | elsif @channel.any? 12 | Bridge::STATUS_READY 13 | elsif @channel.closed? 14 | Bridge::STATUS_FINISHED 15 | else 16 | Bridge::STATUS_IDLE 17 | end 18 | 19 | set_status(status) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/goru/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "reactor" 4 | 5 | module Goru 6 | class Channel 7 | def initialize(size: nil) 8 | @size = size 9 | @messages = [] 10 | @closed = false 11 | @observers = Set.new 12 | end 13 | 14 | # [public] 15 | # 16 | def <<(message) 17 | raise "closed" if @closed 18 | @messages << message 19 | @observers.each(&:channel_received) 20 | end 21 | 22 | # [public] 23 | # 24 | def read 25 | message = @messages.shift 26 | @observers.each(&:channel_read) 27 | message 28 | end 29 | 30 | # [public] 31 | # 32 | def any? 33 | @messages.any? 34 | end 35 | 36 | # [public] 37 | # 38 | def empty? 39 | @messages.empty? 40 | end 41 | 42 | # [public] 43 | # 44 | def full? 45 | !!@size && @messages.size == @size 46 | end 47 | 48 | # [public] 49 | # 50 | def closed? 51 | @closed == true 52 | end 53 | 54 | # [public] 55 | # 56 | def close 57 | @closed = true 58 | @observers.each(&:channel_closed) 59 | end 60 | 61 | # [public] 62 | # 63 | def clear 64 | @messages.clear 65 | end 66 | 67 | # [public] 68 | # 69 | def length 70 | @messages.length 71 | end 72 | 73 | # [public] 74 | # 75 | def add_observer(observer) 76 | @observers << observer 77 | end 78 | 79 | # [public] 80 | # 81 | def remove_observer(observer) 82 | @observers.delete(observer) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/goru/reactor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "nio" 4 | require "timers/group" 5 | require "timers/wait" 6 | 7 | require_relative "routines/io" 8 | 9 | module Goru 10 | # [public] 11 | # 12 | class Reactor 13 | def initialize(queue:, scheduler:) 14 | @queue = queue 15 | @scheduler = scheduler 16 | @routines = Set.new 17 | @timers = Timers::Group.new 18 | @stopped = false 19 | @status = nil 20 | @selector = NIO::Selector.new 21 | @commands = [] 22 | end 23 | 24 | # [public] 25 | # 26 | STATUS_RUNNING = :running 27 | 28 | # [public] 29 | # 30 | STATUS_FINISHED = :finished 31 | 32 | # [public] 33 | # 34 | STATUS_IDLE = :idle 35 | 36 | # [public] 37 | # 38 | STATUS_STOPPED = :stopped 39 | 40 | # [public] 41 | # 42 | attr_reader :status 43 | 44 | # [public] 45 | # 46 | def run 47 | set_status(STATUS_RUNNING) 48 | 49 | until @stopped 50 | tick 51 | end 52 | ensure 53 | @timers.cancel 54 | @selector.close 55 | set_status(STATUS_FINISHED) 56 | end 57 | 58 | private def tick 59 | # Apply queued commands. 60 | # 61 | while (command = @commands.shift) 62 | action, routine = command 63 | 64 | case action 65 | when :adopt 66 | routine.reactor = self 67 | @routines << routine 68 | routine.adopted 69 | when :cleanup 70 | @routines.delete(routine) 71 | when :register 72 | monitor = @selector.register(routine.io, routine.intent) 73 | monitor.value = routine.method(:wakeup) 74 | routine.monitor = monitor 75 | when :deregister 76 | routine.monitor&.close 77 | routine.monitor = nil 78 | end 79 | end 80 | 81 | # Call each ready routine. 82 | # 83 | @routines.each do |routine| 84 | next unless routine.ready? 85 | 86 | catch :continue do 87 | routine.call 88 | end 89 | end 90 | 91 | # Adopt a new routine if available. 92 | # 93 | if (routine = @queue.pop(true)) 94 | adopt_routine(routine) 95 | end 96 | rescue ThreadError 97 | interval = @timers.wait_interval 98 | 99 | if interval.nil? && @routines.empty? 100 | set_status(STATUS_IDLE) 101 | @scheduler.signal 102 | wait 103 | set_status(STATUS_RUNNING) 104 | elsif interval.nil? 105 | wait unless @routines.any?(&:ready?) 106 | elsif interval > 0 107 | wait(timeout: interval) 108 | end 109 | 110 | @timers.fire 111 | rescue IOError 112 | end 113 | 114 | private def wait(timeout: nil) 115 | @selector.select(timeout) do |monitor| 116 | monitor.value.call 117 | end 118 | end 119 | 120 | # [public] 121 | # 122 | def finished? 123 | @status == STATUS_IDLE || @status == STATUS_STOPPED 124 | end 125 | 126 | # [public] 127 | # 128 | def wakeup 129 | @selector.wakeup 130 | rescue IOError 131 | # nothing to do 132 | end 133 | 134 | # [public] 135 | # 136 | def stop 137 | @stopped = true 138 | wakeup 139 | rescue ClosedQueueError 140 | # nothing to do 141 | end 142 | 143 | # [public] 144 | # 145 | def asleep_for(seconds) 146 | @timers.after(seconds) { 147 | yield 148 | } 149 | end 150 | 151 | # [public] 152 | # 153 | def adopt_routine(routine) 154 | command(:adopt, routine) 155 | end 156 | 157 | # [public] 158 | # 159 | def routine_finished(routine) 160 | command(:cleanup, routine) 161 | end 162 | 163 | # [public] 164 | # 165 | def register(routine) 166 | command(:register, routine) 167 | end 168 | 169 | # [public] 170 | # 171 | def deregister(routine) 172 | command(:deregister, routine) 173 | end 174 | 175 | private def command(action, routine) 176 | @commands << [action, routine] 177 | wakeup 178 | end 179 | 180 | private def set_status(status) 181 | @status = status 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/goru/routine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "core/handler" 4 | 5 | module Goru 6 | # [public] 7 | # 8 | class Routine 9 | include Core::Handler 10 | 11 | handle(StandardError) do |event:| 12 | if @debug 13 | $stderr << <<~ERROR 14 | [goru] routine crashed: #{event} 15 | #{event.backtrace.join("\n")} 16 | ERROR 17 | end 18 | end 19 | 20 | def initialize(state = nil, &block) 21 | @state = state 22 | @block = block 23 | @observers = Set.new 24 | set_status(STATUS_READY) 25 | @result, @error, @reactor = nil 26 | @debug = true 27 | end 28 | 29 | # [public] 30 | # 31 | STATUS_READY = :ready 32 | 33 | # [public] 34 | # 35 | STATUS_FINISHED = :finished 36 | 37 | # [public] 38 | # 39 | STATUS_ERRORED = :errored 40 | 41 | # [public] 42 | # 43 | STATUS_IDLE = :idle 44 | 45 | # [public] 46 | # 47 | STATUS_PAUSED = :paused 48 | 49 | # [public] 50 | # 51 | attr_reader :state, :status, :error, :reactor 52 | 53 | # [public] 54 | # 55 | attr_writer :debug 56 | 57 | # [public] 58 | # 59 | def reactor=(reactor) 60 | @reactor = reactor 61 | status_changed 62 | end 63 | 64 | # [public] 65 | # 66 | def call 67 | @block.call(self) 68 | rescue => error 69 | @error = error 70 | set_status(STATUS_ERRORED) 71 | trigger(error) 72 | end 73 | 74 | # [public] 75 | # 76 | def finished(result = nil) 77 | @result = result 78 | set_status(STATUS_FINISHED) 79 | 80 | throw :continue 81 | end 82 | 83 | # [public] 84 | # 85 | def update(state) 86 | @state = state 87 | end 88 | 89 | # [public] 90 | # 91 | def result 92 | case @status 93 | when STATUS_ERRORED 94 | raise @error 95 | else 96 | @result 97 | end 98 | end 99 | 100 | # [public] 101 | # 102 | def sleep(seconds) 103 | set_status(STATUS_IDLE) 104 | @reactor.asleep_for(seconds) do 105 | set_status(STATUS_READY) 106 | end 107 | 108 | throw :continue 109 | end 110 | 111 | # [public] 112 | # 113 | def ready? 114 | @status == STATUS_READY 115 | end 116 | 117 | # [public] 118 | # 119 | def finished? 120 | @status == STATUS_ERRORED || @status == STATUS_FINISHED 121 | end 122 | 123 | # [public] 124 | # 125 | def pause 126 | set_status(STATUS_PAUSED) 127 | end 128 | 129 | # [public] 130 | # 131 | def resume 132 | set_status(STATUS_READY) 133 | end 134 | 135 | # [public] 136 | # 137 | private def set_status(status) 138 | @status = status 139 | status_changed 140 | end 141 | 142 | # [public] 143 | # 144 | private def status_changed 145 | @observers.each(&:call) 146 | 147 | case @status 148 | when STATUS_ERRORED, STATUS_FINISHED 149 | @reactor&.routine_finished(self) 150 | end 151 | end 152 | 153 | # [public] 154 | # 155 | def adopted 156 | # noop 157 | end 158 | 159 | # [public] 160 | # 161 | def add_observer(observer = nil, &block) 162 | @observers << (block || observer.method(:routine_status_changed)) 163 | end 164 | 165 | # [public] 166 | # 167 | def remove_observer(observer) 168 | @observers.delete(observer) 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/goru/routines/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../routine" 4 | 5 | module Goru 6 | module Routines 7 | # [public] 8 | # 9 | class Channel < Routine 10 | def initialize(state = nil, channel:, &block) 11 | super(state, &block) 12 | 13 | @channel = channel 14 | @channel.add_observer(self) 15 | update_status 16 | end 17 | 18 | private def status_changed 19 | case @status 20 | when Routine::STATUS_READY 21 | @reactor&.wakeup 22 | when Routine::STATUS_FINISHED 23 | @channel.remove_observer(self) 24 | end 25 | 26 | super 27 | end 28 | 29 | def channel_received 30 | update_status 31 | end 32 | 33 | def channel_read 34 | update_status 35 | end 36 | 37 | def channel_closed 38 | update_status 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/goru/routines/channels/readable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../channel" 4 | 5 | module Goru 6 | module Routines 7 | module Channels 8 | # [public] 9 | # 10 | class Readable < Channel 11 | # [public] 12 | # 13 | def read 14 | @channel.read 15 | end 16 | 17 | private def update_status 18 | status = if @channel.any? 19 | Routine::STATUS_READY 20 | elsif @channel.closed? 21 | Routine::STATUS_FINISHED 22 | else 23 | Routine::STATUS_IDLE 24 | end 25 | 26 | set_status(status) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/goru/routines/channels/writable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../channel" 4 | 5 | module Goru 6 | module Routines 7 | module Channels 8 | # [public] 9 | # 10 | class Writable < Channel 11 | # [public] 12 | # 13 | def <<(message) 14 | @channel << message 15 | end 16 | 17 | private def update_status 18 | status = if @channel.full? 19 | Routine::STATUS_IDLE 20 | elsif @channel.closed? 21 | Routine::STATUS_FINISHED 22 | else 23 | Routine::STATUS_READY 24 | end 25 | 26 | set_status(status) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/goru/routines/io.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../routine" 4 | require_relative "../bridges/readable" 5 | require_relative "../bridges/writable" 6 | 7 | module Goru 8 | module Routines 9 | # [public] 10 | # 11 | class IO < Routine 12 | def initialize(state = nil, io:, intent:, event_loop:, &block) 13 | super(state, &block) 14 | 15 | @io = io 16 | @intent = normalize_intent(intent) 17 | @event_loop = event_loop 18 | @status = :orphaned 19 | @monitor = nil 20 | end 21 | 22 | # [public] 23 | # 24 | STATUS_IO_READY = :io_ready 25 | 26 | # [public] 27 | # 28 | attr_reader :io, :intent 29 | 30 | attr_accessor :monitor 31 | 32 | # [public] 33 | # 34 | def adopted 35 | set_status(Routine::STATUS_READY) 36 | end 37 | 38 | # [public] 39 | # 40 | def wakeup 41 | # Keep this io from being selected again until the underlying routine is called. 42 | # Interests are reset in `#call`. 43 | # 44 | @monitor&.interests = nil 45 | 46 | set_status(STATUS_IO_READY) 47 | end 48 | 49 | READY_STATUSES = [STATUS_IO_READY, Routine::STATUS_READY].freeze 50 | READY_BRIDGE_STATUSES = [nil, Bridge::STATUS_READY].freeze 51 | 52 | # [public] 53 | # 54 | def ready? 55 | READY_STATUSES.include?(@status) && READY_BRIDGE_STATUSES.include?(@bridge&.status) 56 | end 57 | 58 | def call 59 | super 60 | 61 | @monitor&.interests = @intent 62 | end 63 | 64 | # [public] 65 | # 66 | def accept 67 | @io.accept_nonblock 68 | rescue Errno::EAGAIN 69 | wait 70 | rescue Errno::ECONNRESET, Errno::EPIPE, EOFError 71 | handle_io_unavailable 72 | rescue IOError 73 | handle_io_unavailable 74 | end 75 | 76 | def wait 77 | set_status(:selecting) 78 | @reactor.register(self) unless @monitor 79 | 80 | throw :continue 81 | end 82 | 83 | # [public] 84 | # 85 | def read(bytes) 86 | @io.read_nonblock(bytes) 87 | rescue Errno::EAGAIN 88 | wait 89 | rescue Errno::ECONNRESET, Errno::EPIPE, EOFError 90 | handle_io_unavailable 91 | rescue IOError 92 | handle_io_unavailable 93 | end 94 | 95 | # [public] 96 | # 97 | def write(data) 98 | @io.write_nonblock(data) 99 | rescue Errno::EAGAIN 100 | wait 101 | rescue Errno::ECONNRESET, Errno::EPIPE, EOFError 102 | handle_io_unavailable 103 | rescue IOError 104 | handle_io_unavailable 105 | end 106 | 107 | private def handle_io_unavailable 108 | finished 109 | nil 110 | end 111 | 112 | # [public] 113 | # 114 | def intent=(intent) 115 | intent = normalize_intent(intent) 116 | validate_intent!(intent) 117 | 118 | @monitor&.interests = intent 119 | @intent = intent 120 | end 121 | 122 | # [public] 123 | # 124 | def bridge(state = nil, intent:, channel:, &block) 125 | raise "routine is already bridged" if @bridge 126 | 127 | intent = normalize_intent(intent) 128 | validate_intent!(intent) 129 | self.intent = intent 130 | 131 | @bridge = case intent 132 | when :r 133 | Bridges::Readable.new(routine: self, channel: channel) 134 | when :w 135 | Bridges::Writable.new(routine: self, channel: channel) 136 | end 137 | 138 | routine = case intent 139 | when :r 140 | Routines::Channels::Readable.new(state, channel: channel, &block) 141 | when :w 142 | Routines::Channels::Writable.new(state, channel: channel, &block) 143 | end 144 | 145 | @reactor.adopt_routine(routine) 146 | @reactor.wakeup 147 | 148 | routine 149 | end 150 | 151 | # [public] 152 | # 153 | def bridged 154 | @reactor.wakeup 155 | end 156 | 157 | # [public] 158 | # 159 | def unbridge 160 | @bridge = nil 161 | @reactor.wakeup 162 | end 163 | 164 | # [public] 165 | # 166 | def finished(...) 167 | @io.close 168 | super 169 | end 170 | 171 | private def status_changed 172 | case @status 173 | when Routine::STATUS_FINISHED 174 | @reactor&.deregister(self) 175 | end 176 | 177 | super 178 | end 179 | 180 | INTENTS = %i[r w].freeze 181 | 182 | private def validate_intent!(intent) 183 | raise ArgumentError, "unknown intent: #{intent}" unless INTENTS.include?(intent) 184 | end 185 | 186 | private def normalize_intent(intent) 187 | intent.to_sym 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/goru/scheduler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "etc" 4 | require "nio" 5 | require "core/global" 6 | 7 | require_relative "channel" 8 | require_relative "reactor" 9 | require_relative "routines/channel" 10 | require_relative "routines/io" 11 | 12 | require_relative "routines/channels/readable" 13 | require_relative "routines/channels/writable" 14 | 15 | module Goru 16 | # [public] 17 | # 18 | class Scheduler 19 | include Core::Global 20 | 21 | class << self 22 | # Prevent issues when including `Goru` at the toplevel. 23 | # 24 | # [public] 25 | # 26 | def go(...) 27 | global.go(...) 28 | end 29 | 30 | # [public] 31 | # 32 | def default_reactor_count 33 | Etc.nprocessors 34 | end 35 | end 36 | 37 | def initialize(count: self.class.default_reactor_count) 38 | super() 39 | 40 | @waiting = false 41 | @stopped = false 42 | @routines = Thread::Queue.new 43 | @selector = NIO::Selector.new 44 | 45 | @reactors = count.times.map { 46 | Reactor.new(queue: @routines, scheduler: self) 47 | } 48 | 49 | @threads = @reactors.map { |reactor| 50 | Thread.new { 51 | Thread.handle_interrupt(Interrupt => :never) do 52 | reactor.run 53 | rescue IOError 54 | end 55 | } 56 | } 57 | end 58 | 59 | # [public] 60 | # 61 | def go(state = nil, io: nil, channel: nil, intent: nil, &block) 62 | raise ArgumentError, "cannot set both `io` and `channel`" if io && channel 63 | 64 | routine = if io 65 | Routines::IO.new(state, io: io, intent: intent, event_loop: @io_event_loop, &block) 66 | elsif channel 67 | case intent 68 | when :r 69 | Routines::Channels::Readable.new(state, channel: channel, &block) 70 | when :w 71 | Routines::Channels::Writable.new(state, channel: channel, &block) 72 | end 73 | else 74 | Routine.new(state, &block) 75 | end 76 | 77 | @routines << routine 78 | @reactors.each(&:wakeup) 79 | 80 | routine 81 | end 82 | 83 | # [public] 84 | # 85 | def wait 86 | @waiting = true 87 | @reactors.each(&:wakeup) 88 | @selector.select while @waiting 89 | rescue IOError, Interrupt 90 | # nothing to do 91 | ensure 92 | stop 93 | end 94 | 95 | # [public] 96 | # 97 | def stop 98 | @stopped = true 99 | @routines.close 100 | @selector.close 101 | @reactors.each(&:stop) 102 | @threads.each(&:join) 103 | end 104 | 105 | # [public] 106 | # 107 | def signal 108 | return unless @waiting && @reactors.all?(&:finished?) 109 | @waiting = false 110 | wakeup 111 | end 112 | 113 | def wakeup 114 | @selector.wakeup 115 | rescue IOError 116 | # nothing to do 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/goru/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Goru 4 | VERSION = "0.5.0" 5 | 6 | # [public] 7 | # 8 | def self.version 9 | VERSION 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /profiling/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ruby-prof" 4 | 5 | require_relative "../lib/goru" 6 | 7 | class Reader 8 | include Goru 9 | 10 | def initialize(channel:) 11 | @received = [] 12 | 13 | go(channel: channel, intent: :r) { |routine| 14 | value = routine.read 15 | @received << value 16 | } 17 | end 18 | 19 | attr_reader :received 20 | end 21 | 22 | class Writer 23 | include Goru 24 | 25 | def initialize(channel:, values:) 26 | go(channel: channel, intent: :w) { |routine| 27 | if (value = values.shift) 28 | routine << value 29 | else 30 | channel.close 31 | routine.finished 32 | end 33 | } 34 | end 35 | end 36 | 37 | values = 10_000.times.to_a 38 | result = RubyProf.profile do 39 | channel = Goru::Channel.new 40 | Reader.new(channel: channel) 41 | Writer.new(channel: channel, values: values) 42 | Goru::Scheduler.wait 43 | end 44 | 45 | printer = RubyProf::FlatPrinter.new(result) 46 | printer.print($stdout, {}) 47 | -------------------------------------------------------------------------------- /profiling/worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ruby-prof" 4 | 5 | require_relative "../lib/goru" 6 | 7 | class Worker 8 | include Goru 9 | 10 | def initialize 11 | @results = [] 12 | end 13 | 14 | attr_reader :results 15 | 16 | def call 17 | 100_000.times do |index| 18 | go(0) { |routine| 19 | state = routine.state 20 | 21 | if state >= 10 22 | routine.finished 23 | else 24 | @results << :result 25 | routine.update(state + 1) 26 | end 27 | } 28 | end 29 | end 30 | end 31 | 32 | worker = Worker.new 33 | 34 | result = RubyProf.profile do 35 | worker.call 36 | Goru::Scheduler.wait 37 | end 38 | 39 | printer = RubyProf::FlatPrinter.new(result) 40 | printer.print($stdout, {}) 41 | -------------------------------------------------------------------------------- /spec/features/channel/buffered_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "using channels as buffers" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | let(:channel) { 11 | Goru::Channel.new(size: 3) 12 | } 13 | 14 | it "reads and writes values" do 15 | count = 10 16 | values = count.times.to_a 17 | received = [] 18 | 19 | scheduler.go(:sleep, channel: channel, intent: :r) { |routine| 20 | case routine.state 21 | when :read 22 | received << [routine.read, Time.now] 23 | routine.finished if received.count == count 24 | routine.update(:sleep) 25 | when :sleep 26 | routine.update(:read) 27 | routine.sleep(rand / 100) 28 | end 29 | } 30 | 31 | scheduler.go(channel: channel, intent: :w) { |routine| 32 | if (value = values.shift) 33 | routine << value 34 | routine.update(:sleep) 35 | else 36 | routine.finished 37 | end 38 | } 39 | 40 | scheduler.wait 41 | 42 | expect(received.count).to eq(count) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/features/channel/clearing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "clearing a channel" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | let(:channel) { 11 | Goru::Channel.new 12 | } 13 | 14 | it "clears the channel" do 15 | count = 10 16 | values = count.times.to_a 17 | received = [] 18 | 19 | scheduler.go(:read, channel: channel, intent: :r) { |routine| 20 | case routine.state 21 | when :read 22 | received << routine.read 23 | routine.finished if channel.empty? 24 | routine.update(:sleep) 25 | when :sleep 26 | routine.update(:read) 27 | routine.sleep(0.1) 28 | end 29 | } 30 | 31 | scheduler.go(channel: channel, intent: :w) { |routine| 32 | if (value = values.shift) 33 | routine << value 34 | else 35 | channel.clear 36 | routine.finished 37 | end 38 | } 39 | 40 | scheduler.wait 41 | 42 | expect(received).to eq([0]) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/features/channel/closing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "closing a channel" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | let(:channel) { 11 | Goru::Channel.new 12 | } 13 | 14 | it "automatically finishes read routines" do 15 | count = 10 16 | values = count.times.to_a 17 | received = [] 18 | 19 | scheduler.go(channel: channel, intent: :r) { |routine| 20 | received << routine.read 21 | } 22 | 23 | scheduler.go(:write, channel: channel, intent: :w) { |routine| 24 | case routine.state 25 | when :write 26 | if (value = values.shift) 27 | routine << value 28 | else 29 | channel.close 30 | routine.finished 31 | end 32 | when :sleep 33 | routine.sleep(rand) 34 | end 35 | } 36 | 37 | scheduler.wait 38 | 39 | expect(received).to eq(count.times.to_a) 40 | end 41 | 42 | it "automatically finishes write routines" do 43 | count = 10 44 | received = [] 45 | 46 | scheduler.go(channel: channel, intent: :r) { |routine| 47 | received << routine.read 48 | channel.close if received.count == count 49 | } 50 | 51 | scheduler.go(:write, channel: channel, intent: :w) { |routine| 52 | case routine.state 53 | when :write 54 | routine << rand 55 | routine.update(:sleep) 56 | when :sleep 57 | routine.update(:write) 58 | routine.sleep(rand / 100) 59 | end 60 | } 61 | 62 | scheduler.wait 63 | 64 | expect(received.count).to eq(count) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/features/channel/inspection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "inspecting a channel" do 6 | let(:channel) { 7 | Goru::Channel.new 8 | } 9 | 10 | describe "#any?" do 11 | it "returns true if the channel has values" do 12 | channel << :foo 13 | 14 | expect(channel.any?).to eq(true) 15 | end 16 | 17 | it "returns false if the channel does not have values" do 18 | expect(channel.any?).to eq(false) 19 | end 20 | end 21 | 22 | describe "#empty?" do 23 | it "returns false if the channel has values" do 24 | channel << :foo 25 | 26 | expect(channel.empty?).to eq(false) 27 | end 28 | 29 | it "returns true if the channel does not have values" do 30 | expect(channel.empty?).to eq(true) 31 | end 32 | end 33 | 34 | describe "#full?" do 35 | it "returns false" do 36 | expect(channel.full?).to eq(false) 37 | end 38 | 39 | context "channel has a defined size" do 40 | let(:channel) { 41 | Goru::Channel.new(size: 3) 42 | } 43 | 44 | it "returns false if the channel still has space" do 45 | expect(channel.full?).to eq(false) 46 | 47 | channel << :foo 48 | 49 | expect(channel.full?).to eq(false) 50 | 51 | channel << :bar 52 | 53 | expect(channel.full?).to eq(false) 54 | end 55 | 56 | it "returns true if the channel is full" do 57 | channel << :foo 58 | channel << :bar 59 | channel << :baz 60 | 61 | expect(channel.full?).to eq(true) 62 | end 63 | end 64 | end 65 | 66 | describe "#length" do 67 | it "returns the number of values" do 68 | expect { 69 | channel << :foo 70 | }.to change { 71 | channel.length 72 | }.from(0).to(1) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/features/channel/passing_values_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "passing values through a channel" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | let(:channel) { 11 | Goru::Channel.new 12 | } 13 | 14 | it "reads and writes values" do 15 | count = 10 16 | values = count.times.to_a 17 | received = [] 18 | 19 | scheduler.go(channel: channel, intent: :r) { |routine| 20 | received << routine.read 21 | routine.finished if received.count == count 22 | } 23 | 24 | scheduler.go(:write, channel: channel, intent: :w) { |routine| 25 | case routine.state 26 | when :write 27 | if (value = values.shift) 28 | routine << value 29 | routine.update(:sleep) 30 | else 31 | routine.finished 32 | end 33 | when :sleep 34 | routine.update(:write) 35 | routine.sleep(rand / 100) 36 | end 37 | } 38 | 39 | scheduler.wait 40 | 41 | expect(received).to eq(count.times.to_a) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/features/io/bridging_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http" 4 | require "socket" 5 | 6 | require "goru/scheduler" 7 | 8 | require_relative "../../support/server" 9 | 10 | RSpec.describe "writes using a bridge" do 11 | let(:server) { 12 | Server.new 13 | } 14 | 15 | it "handles io" do 16 | server.start 17 | 18 | # wait a second for the server to start 19 | sleep(0.25) 20 | 21 | begin 22 | statuses = 3.times.map { 23 | HTTP.timeout(1).get("http://localhost:4242").status.to_i 24 | } 25 | ensure 26 | server.stop 27 | end 28 | 29 | expect(statuses.count).to eq(3) 30 | expect(statuses.uniq.count).to eq(1) 31 | expect(statuses.uniq[0]).to eq(204) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/features/io/closing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "closing the io object" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | let(:io) { 11 | double(:io) 12 | } 13 | 14 | it "closes the io when the routine is finished" do 15 | expect(io).to receive(:close) 16 | 17 | scheduler.go(intent: :read, io: io) { |routine| 18 | routine.finished 19 | } 20 | 21 | scheduler.wait 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/features/io/non_blocking_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http" 4 | require "llhttp" 5 | require "socket" 6 | 7 | require "goru/scheduler" 8 | 9 | require_relative "../../support/delegate" 10 | 11 | class NonBlockingServer 12 | def initialize 13 | @scheduler = Goru::Scheduler.new 14 | end 15 | 16 | def start 17 | @server = TCPServer.new("localhost", 4243) 18 | 19 | @routine = @scheduler.go(io: @server, intent: :r) { |server_routine| 20 | if (client_io = server_routine.accept) 21 | state = {delegate: Delegate.new} 22 | state[:parser] = LLHttp::Parser.new(state[:delegate]) 23 | 24 | @scheduler.go(state, io: client_io, intent: :r) { |client_routine| 25 | if (data = client_routine.read(16384)) 26 | client_routine.state[:parser] << data 27 | 28 | if client_routine.state[:delegate].message_complete? 29 | client_routine.write("HTTP/1.1 204 No Content\r\n") 30 | client_routine.write("content-length: 0\r\n\r\n") 31 | 32 | client_routine.state[:delegate].reset 33 | client_routine.state[:parser].reset 34 | client_routine.finished 35 | end 36 | end 37 | } 38 | end 39 | } 40 | end 41 | 42 | def stop 43 | @scheduler.stop 44 | @server.close 45 | end 46 | end 47 | 48 | RSpec.describe "using non-blocking io" do 49 | let(:server) { 50 | NonBlockingServer.new 51 | } 52 | 53 | it "handles io" do 54 | server.start 55 | 56 | # wait a second for the server to start 57 | sleep(0.25) 58 | 59 | statuses = 100.times.map { 60 | HTTP.get("http://localhost:4243").status.to_i 61 | } 62 | 63 | server.stop 64 | 65 | expect(statuses.count).to eq(100) 66 | expect(statuses.uniq.count).to eq(1) 67 | expect(statuses.uniq[0]).to eq(204) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/features/routine/error_handling_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "handling errors in a routine" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "can handle errors" do 11 | handled = nil 12 | 13 | scheduler.go(:foo) { |routine| 14 | routine.debug = false 15 | routine.handle(StandardError) do |event:| 16 | handled = true 17 | end 18 | 19 | fail 20 | } 21 | 22 | scheduler.wait 23 | 24 | expect(handled).to be(true) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/features/routine/finished_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "checking if a routine is finished" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "is finished if finished" do 11 | routine = scheduler.go { |routine| 12 | routine.finished 13 | } 14 | 15 | scheduler.wait 16 | 17 | expect(routine.finished?).to be(true) 18 | end 19 | 20 | it "is finished if errored" do 21 | routine = scheduler.go { |routine| 22 | routine.debug = false 23 | 24 | fail 25 | } 26 | 27 | scheduler.wait 28 | 29 | expect(routine.finished?).to be(true) 30 | end 31 | 32 | it "is not finished when running" do 33 | is_finished = nil 34 | 35 | scheduler.go { |routine| 36 | is_finished = routine.finished? 37 | routine.finished 38 | } 39 | 40 | scheduler.wait 41 | 42 | expect(is_finished).to be(false) 43 | end 44 | 45 | it "is not finished when idle" do 46 | routine = scheduler.go { |routine| 47 | case routine.state 48 | when :sleeping 49 | routine.finished 50 | else 51 | routine.update(:sleeping) 52 | routine.sleep(0.1) 53 | end 54 | } 55 | 56 | routine.add_observer do 57 | case routine.status 58 | when Goru::Routine::STATUS_IDLE 59 | @finished_when_idle = routine.finished? 60 | end 61 | end 62 | 63 | scheduler.wait 64 | 65 | expect(@finished_when_idle).to be(false) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/features/routine/finishing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "finishing a routine" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "finishes" do 11 | values = [] 12 | 13 | scheduler.go { |routine| 14 | values << rand 15 | routine.finished 16 | } 17 | 18 | scheduler.wait 19 | 20 | expect(values.count).to eq(1) 21 | end 22 | 23 | it "finishes with a value" do 24 | scheduled_routine = scheduler.go { |routine| 25 | routine.finished(:fin) 26 | } 27 | 28 | scheduler.wait 29 | 30 | expect(scheduled_routine.result).to eq(:fin) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/features/routine/observing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "observing a routine" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | context "observer is an object" do 11 | let(:observer) { 12 | Class.new { 13 | def initialize 14 | @calls = [] 15 | end 16 | 17 | attr_reader :calls 18 | 19 | def routine_status_changed 20 | @calls << :call 21 | end 22 | }.new 23 | } 24 | 25 | it "is notified of each status change" do 26 | routine = scheduler.go { |routine| 27 | case routine.state 28 | when :sleeping 29 | routine.finished 30 | else 31 | routine.update(:sleeping) 32 | routine.sleep(0.1) 33 | end 34 | } 35 | 36 | routine.add_observer(observer) 37 | scheduler.wait 38 | 39 | expect(observer.calls.count).to eq(4) 40 | end 41 | end 42 | 43 | context "observer is a block" do 44 | it "is notified of each status change" do 45 | routine = scheduler.go { |routine| 46 | case routine.state 47 | when :sleeping 48 | routine.finished 49 | else 50 | routine.update(:sleeping) 51 | routine.sleep(0.1) 52 | end 53 | } 54 | 55 | statuses = [] 56 | routine.add_observer do 57 | statuses << routine.status 58 | end 59 | 60 | scheduler.wait 61 | 62 | expect(statuses).to eq([ 63 | Goru::Routine::STATUS_READY, 64 | Goru::Routine::STATUS_IDLE, 65 | Goru::Routine::STATUS_READY, 66 | Goru::Routine::STATUS_FINISHED 67 | ]) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/features/routine/pausing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "pausing a routine" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "pauses and resumes" do 11 | values = [] 12 | 13 | routine = scheduler.go { |routine| 14 | values << rand 15 | routine.finished if values.count == 2 16 | routine.pause 17 | } 18 | 19 | sleep(0.1) 20 | routine.resume 21 | scheduler.wait 22 | 23 | expect(values.count).to eq(2) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/features/routine/result_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "getting a routine's result" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | context "routine finished normally" do 11 | it "has a result" do 12 | scheduled_routine = scheduler.go { |routine| 13 | routine.finished(:fin) 14 | } 15 | 16 | scheduler.wait 17 | 18 | expect(scheduled_routine.result).to eq(:fin) 19 | end 20 | end 21 | 22 | context "routine errored" do 23 | it "re-raises the error" do 24 | scheduled_routine = scheduler.go { |routine| 25 | routine.debug = false 26 | fail "something went wrong" 27 | } 28 | 29 | scheduler.wait 30 | 31 | expect { 32 | scheduled_routine.result 33 | }.to raise_error("something went wrong") 34 | end 35 | 36 | it "exposes the error" do 37 | scheduled_routine = scheduler.go { |routine| 38 | routine.debug = false 39 | fail "something went wrong" 40 | } 41 | 42 | scheduler.wait 43 | 44 | expect(scheduled_routine.error.message).to eq("something went wrong") 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/features/routine/sleeping_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "sleeping in a routine" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "sleeps" do 11 | slept_at = nil 12 | 13 | scheduler.go(:sleep) { |routine| 14 | case routine.state 15 | when :sleep 16 | slept_at = Time.now 17 | routine.update(:finished) 18 | routine.sleep(0.1) 19 | when :finished 20 | routine.finished 21 | end 22 | } 23 | 24 | scheduler.wait 25 | 26 | expect(Time.now - slept_at).to be_within(0.01).of(0.1) 27 | end 28 | 29 | it "cannot sleep forever" do 30 | captured_error = nil 31 | 32 | scheduler.go { |routine| 33 | begin 34 | routine.sleep 35 | rescue => error 36 | captured_error = error 37 | ensure 38 | routine.finished 39 | end 40 | } 41 | 42 | scheduler.wait 43 | 44 | expect(captured_error).to be_instance_of(ArgumentError) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/features/routine/state_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "managing routine state" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "sets state" do 11 | scheduled_routine = scheduler.go(:foo) { |routine| 12 | routine.finished 13 | } 14 | 15 | scheduler.wait 16 | 17 | expect(scheduled_routine.state).to eq(:foo) 18 | end 19 | 20 | it "updates state" do 21 | scheduled_routine = scheduler.go(0) { |routine| 22 | routine.update(routine.state + 1) 23 | routine.finished 24 | } 25 | 26 | scheduler.wait 27 | 28 | expect(scheduled_routine.state).to eq(1) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/features/routine/status_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "routine status" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | describe "initial status" do 11 | it "is ready" do 12 | scheduled_routine = scheduler.go { |routine| 13 | routine.finished 14 | } 15 | 16 | expect(scheduled_routine.status).to eq(Goru::Routine::STATUS_READY) 17 | end 18 | end 19 | 20 | context "routine is sleeping" do 21 | it "is idle" do 22 | scheduled_routine = scheduler.go { |routine| 23 | case routine.state 24 | when :sleeping 25 | routine.finished 26 | else 27 | routine.update(:sleeping) 28 | routine.sleep(0.2) 29 | end 30 | } 31 | 32 | sleep(0.1) 33 | 34 | expect(scheduled_routine.status).to eq(Goru::Routine::STATUS_IDLE) 35 | end 36 | end 37 | 38 | context "routine has errored" do 39 | it "is errored" do 40 | scheduled_routine = scheduler.go { |routine| 41 | routine.debug = false 42 | fail 43 | } 44 | 45 | scheduler.wait 46 | 47 | expect(scheduled_routine.status).to eq(Goru::Routine::STATUS_ERRORED) 48 | end 49 | end 50 | 51 | context "routine has finished" do 52 | it "is finished" do 53 | scheduled_routine = scheduler.go { |routine| 54 | routine.finished 55 | } 56 | 57 | scheduler.wait 58 | 59 | expect(scheduled_routine.status).to eq(Goru::Routine::STATUS_FINISHED) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/features/scheduler/configuring_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "configuring the scheduler" do 6 | it "can be configured with a reactor count" do 7 | expect { 8 | Goru::Scheduler.new(count: 1) 9 | }.not_to raise_error 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/features/scheduler/running_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "scheduling and running routines" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "runs a routine until finished" do 11 | values = [] 12 | 13 | scheduler.go { |routine| 14 | values << rand 15 | routine.finished if values.count == 10 16 | } 17 | 18 | scheduler.wait 19 | 20 | expect(values.count).to eq(10) 21 | end 22 | 23 | it "runs two routines until finished" do 24 | values = [] 25 | 26 | scheduler.go { |routine| 27 | values << 0 28 | routine.finished if values.count { |value| 29 | value == 0 30 | } == 5 31 | } 32 | 33 | scheduler.go { |routine| 34 | values << 1 35 | routine.finished if values.count { |value| 36 | value == 1 37 | } == 5 38 | } 39 | 40 | scheduler.wait 41 | 42 | expect(values.count { |value| value == 0 }).to eq(5) 43 | expect(values.count { |value| value == 1 }).to eq(5) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/features/scheduler/stopping_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "stopping the scheduler" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "immediately stops without waiting on routines to finish" do 11 | values = [] 12 | 13 | scheduler.go { |routine| 14 | values << rand 15 | routine.sleep(0.1) 16 | } 17 | 18 | scheduler.stop 19 | 20 | expect(values.count).to eq(0) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/features/scheduler/waiting_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "goru/scheduler" 4 | 5 | RSpec.describe "waiting on the scheduler" do 6 | let(:scheduler) { 7 | Goru::Scheduler.new 8 | } 9 | 10 | it "waits on routines to finish" do 11 | values = [] 12 | slept_at = nil 13 | 14 | scheduler.go(:sleep) { |routine| 15 | case routine.state 16 | when :sleep 17 | slept_at = Time.now 18 | values << rand 19 | routine.update(:finished) 20 | routine.sleep(0.1) 21 | when :finished 22 | routine.finished 23 | end 24 | } 25 | 26 | scheduler.wait 27 | 28 | expect(values.count).to eq(1) 29 | expect(Time.now - slept_at).to be_within(0.01).of(0.1) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/initialize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | 5 | initializers = Pathname.new(File.expand_path("../initializers", __FILE__)) 6 | 7 | if initializers.directory? 8 | initializers.glob("*.rb") do |file| 9 | load(file) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/initializers/configure.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.expect_with :rspec do |expectations| 5 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 6 | end 7 | 8 | config.mock_with :rspec do |mocks| 9 | mocks.verify_partial_doubles = true 10 | end 11 | 12 | config.disable_monkey_patching! 13 | config.warnings = true 14 | config.color = true 15 | 16 | config.order = :random 17 | Kernel.srand config.seed 18 | end 19 | -------------------------------------------------------------------------------- /spec/initializers/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :eq_sans_whitespace do |expected| 4 | match do |actual| 5 | expected.gsub(/\s+/, "") == actual.gsub(/\s+/, "") 6 | end 7 | 8 | diffable 9 | end 10 | 11 | RSpec::Matchers.define :include_sans_whitespace do |expected| 12 | match do |actual| 13 | actual.to_s.gsub(/\s+/, "").include?(expected.to_s.gsub(/\s+/, "")) 14 | end 15 | 16 | diffable 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/delegate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "llhttp" 4 | 5 | class Delegate < LLHttp::Delegate 6 | def initialize 7 | reset 8 | end 9 | 10 | def reset 11 | @message_complete = false 12 | end 13 | 14 | def message_complete? 15 | @message_complete == true 16 | end 17 | 18 | def on_message_complete 19 | @message_complete = true 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http" 4 | require "llhttp" 5 | require "socket" 6 | 7 | require_relative "delegate" 8 | require_relative "../../lib/goru" 9 | 10 | class Server 11 | include Goru 12 | 13 | def initialize 14 | @scheduler = Goru::Scheduler.new 15 | end 16 | 17 | def start 18 | @server = TCPServer.new("localhost", 4242) 19 | 20 | @routine = @scheduler.go(io: @server, intent: :r) { |server_routine| 21 | server_routine.debug = true 22 | 23 | accept(routine: server_routine) 24 | } 25 | end 26 | 27 | def stop 28 | @scheduler.stop 29 | @server.close 30 | end 31 | 32 | def accept(routine:) 33 | if (client_io = routine.accept) 34 | delegate = Delegate.new 35 | parser = LLHttp::Parser.new(delegate) 36 | writer = Goru::Channel.new 37 | state = {delegate: delegate, parser: parser, writer: writer} 38 | 39 | @scheduler.go(state, io: client_io, intent: :r) do |client_routine| 40 | client_routine.debug = true 41 | 42 | case client_routine.intent 43 | when :r 44 | read(routine: client_routine) 45 | when :w 46 | write(routine: client_routine) 47 | end 48 | rescue => error 49 | $stderr << "!!! #{error}\n" 50 | $stderr << "#{error.backtrace.join("\n")}\n" 51 | 52 | client_io.close 53 | client_routine.finished 54 | end 55 | end 56 | end 57 | 58 | def read(routine:) 59 | if (data = routine.read(16_384)) 60 | routine.state[:parser] << data 61 | 62 | if routine.state[:delegate].message_complete? 63 | dispatch(routine: routine) 64 | end 65 | end 66 | end 67 | 68 | def write(routine:) 69 | if (writable = routine.state[:writer].read) 70 | routine.write(writable) 71 | elsif routine.state[:writer].closed? 72 | routine.state[:delegate].reset 73 | routine.state[:parser].reset 74 | routine.finished 75 | else 76 | fail "tried to write but no data was available" 77 | end 78 | end 79 | 80 | def dispatch(routine:) 81 | writer = routine.state[:writer] 82 | 83 | data = [ 84 | "HTTP/1.1 204 No Content\r\n", 85 | "Content-Length: 0\r\n", 86 | "\r\n" 87 | ] 88 | 89 | routine.bridge(intent: :w, channel: writer) { |bridge_routine| 90 | bridge_routine.debug = true 91 | 92 | # Write data 5% of the time... 93 | # 94 | if rand(1..100) <= 5 95 | bridge_routine << data.shift 96 | end 97 | 98 | if data.empty? 99 | bridge_routine.finished 100 | writer.close 101 | end 102 | } 103 | end 104 | end 105 | --------------------------------------------------------------------------------