async_futures 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d1d673d78fe3b8bae70c67b12abf800e58cd79b95a7690c939b3d6d125d4224a
4
+ data.tar.gz: 4806d60aa3da8d213aa286874dbc8c7a90902832acb22e35ed00060595a8419f
5
+ SHA512:
6
+ metadata.gz: 9a3ee85e2d1d7a99feaae35124610c1d6265a43b27da902a3e32f683eb32d159a0b4cb7f6441e71de9e04da8827a30504058f9908976273abf11bec038c97fa8
7
+ data.tar.gz: 11cb824b92cd4dee667e477a0f67b0b8fce864ce5eef5dd3781fc5f345d27cfd0cdedb349c95695ab957506a46027f67bb6b278825f147f5f6a84045ff59e375
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 4.0.5
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-05-07
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,12 @@
1
+ Copyright (C) 2025 Ethan D. Estrada <ethan@misterfidget.com>
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any purpose
4
+ with or without fee is hereby granted.
5
+
6
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
7
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
8
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
9
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
10
+ OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
11
+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
12
+ THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # AsyncFutures
2
+
3
+ ## Launch asynchronous tasks
4
+
5
+ [![CI Status](https://github.com/eestrada/async_futures/actions/workflows/main.yml/badge.svg?branch=master)](https://github.com/eestrada/async_futures/actions/workflows/main.yml)
6
+
7
+ This library is heavily inspired by Python's `concurrent.futures` module.
8
+ It's API (mostly) follows the same one as the Python
9
+ [library](https://docs.python.org/3.13/library/concurrent.futures.html).
10
+ There are some differences to make it more Ruby-ish where it makes sense
11
+ (e.g. taking blocks instead of callable parameters, etc).
12
+ There are also separate submission methods
13
+ for concurrent versus non-concurrent tasks
14
+ for reasons explained later.
15
+
16
+ It has a different name for several reasons:
17
+
18
+ 1. The [concurrent-ruby](https://rubygems.org/gems/concurrent-ruby) library
19
+ already exists and is very popular.
20
+ Naming this library `concurrent-futures`
21
+ would place it under the same `Concurrent` module namespace.
22
+ This would be confusing,
23
+ since this project isn't associated with that one.
24
+ 2. This library does not _require_ that `Executor` implementations support concurrency,
25
+ only asynchrony.
26
+ (See Loris Cro's excellent article
27
+ [Asynchrony is not Concurrency](https://kristoff.it/blog/asynchrony-is-not-concurrency/)
28
+ to understand the way these terms are used in this project's documentation).
29
+ Consequently, this library implements (and supports) `Executor` implementations
30
+ that conform to an asynchronous interface,
31
+ but can in reality run immediately in synchronous modes.
32
+ This is still logically correct based on Loris Cro's definition of asynchrony:
33
+ the possibility for tasks to run out of order
34
+ and still be correct.
35
+ This means tasks run strictly in order
36
+ (i.e. synchronously)
37
+ are _also_ correct.
38
+ 3. The more straightforward gem names [future](https://rubygems.org/gems/future)
39
+ and [futures](https://rubygems.org/gems/futures)
40
+ were already taken.
41
+
42
+ This Gem has multiple `Executor` implementations for creating `Future` instances
43
+ for tasks executed by `Ractor`, `Thread`, and `Fiber` concurrency primitives.
44
+ Users of the library can easily test out the performance differences
45
+ of primitives while only changing their code minimally.
46
+
47
+ Although the base `Executor` module is meant to be used as a mixin interface,
48
+ the module can also be run directly
49
+ to have a synchronous `Executor` implementation
50
+ that runs code immediately
51
+ and returns a completed future at the point of submission.
52
+ Although this may seem pointless, it has the benefit
53
+ that users of this library can trivially change their code execution
54
+ from serial to concurrent and back again
55
+ simply by using different `Executor` implementations.
56
+ In other words, their code does not require multiple complicated code paths
57
+ for correctness: it need only supply a different `Executor` instance
58
+ to get different performance
59
+ (assuming their code logic supports asynchrony
60
+ and doesn't require concurrency for correctness).
61
+
62
+ ### Why wouldn't I just use `concurrent-ruby` for concurrency?
63
+
64
+ `concurrent-ruby` is a good library.
65
+ It is mainly focused on `Thread` primitives and thread safety.
66
+ If that is all you want/need, then you should use it.
67
+
68
+ The focus of this library is different.
69
+ This is meant to be a uniform
70
+ (albeit simple)
71
+ interface around _all_ concurrency/async primitives offered by Ruby.
72
+ You can indicate async versus concurrent intent
73
+ using the `submit` versus `submit_concurrent` methods
74
+ on `Executor` implementations.
75
+ It should also be possible to use the `Future` class
76
+ for things like event based libraries (i.e. async)
77
+ that were not intended to be used in this way.
78
+ Thus the `Executor` interface is not required
79
+ for the use of async futures.
80
+
81
+ ## Installation
82
+
83
+ Install the gem and add to the application's Gemfile by executing:
84
+
85
+ ```bash
86
+ bundle add async_futures
87
+ ```
88
+
89
+ If bundler is not being used to manage dependencies, install the gem by executing:
90
+
91
+ ```bash
92
+ gem install async_futures
93
+ ```
94
+
95
+ ## Usage
96
+
97
+ The test suite and benchmark script
98
+ are probably the best place to see example usage.
99
+
100
+ However, here are some quick introductory examples:
101
+
102
+
103
+ ```ruby
104
+ require 'async_futures'
105
+
106
+ # Base `Executor` mixin module (inline execution):
107
+ bexec = AsyncFutures::Executor
108
+ ftr1 = bexec.submit('world') { |subject| 'hello ' + subject.to_s }
109
+ final_string = ftr1.result
110
+
111
+ begin
112
+ ftr1 = bexec.submit_concurrent('world') { |subject| 'hello ' + subject.to_s }
113
+ rescue AsyncFutures::NoConcurrencyError
114
+ # This will always trigger
115
+ end
116
+
117
+ subjects = ['Alice', 'Bob', 'Charlie']
118
+ final_strings = bexec.map(subjects) { |subject| 'hello ' + subject.to_s }.to_a
119
+
120
+ # `ThreadExecutor`:
121
+ texec = AsyncFutures::ThreadExecutor.new
122
+ ftr1 = texec.submit('world') { |subject| 'hello ' + subject.to_s }
123
+ final_string = ftr1.result
124
+
125
+ begin
126
+ ftr1 = texec.submit_concurrent('world') { |subject| 'hello ' + subject.to_s }
127
+ rescue AsyncFutures::NoConcurrencyError
128
+ # This will not trigger by default
129
+ # but may trigger based on some initialize arguments.
130
+ # See docs for details.
131
+ end
132
+
133
+ subjects = ['Alice', 'Bob', 'Charlie']
134
+ final_strings = texec.map(subjects) { |subject| 'hello ' + subject.to_s }.to_a
135
+
136
+ # `FiberExecutor`:
137
+ Fiber.set_scheduler(some_scheduler)
138
+ fexec = AsyncFutures::FiberExecutor.new
139
+
140
+ # It isn't necessary to submit within a scheduled fiber
141
+ # but if we want to join in the same thread
142
+ # we need to run within the scheduler.
143
+ # However, cross thread joining is safe.
144
+ Fiber.schedule do
145
+ ftr1 = fexec.submit('world') { |subject| 'hello ' + subject.to_s }
146
+ final_string = ftr1.result
147
+
148
+ begin
149
+ ftr1 = fexec.submit_concurrent('world') { |subject| 'hello ' + subject.to_s }
150
+ rescue AsyncFutures::NoConcurrencyError
151
+ # This will trigger by default
152
+ # but can be suppressed by an initialize argument.
153
+ # See docs for details.
154
+ end
155
+
156
+ subjects = ['Alice', 'Bob', 'Charlie']
157
+ final_strings = fexec.map(subjects) { |subject| 'hello ' + subject.to_s }.to_a
158
+ end
159
+
160
+ # Unsetting the scheduler causes it to run to completion.
161
+ # Otherwise, we may need to wait until process exit
162
+ # for it to fully finish running.
163
+ Fiber.set_scheduler(nil)
164
+
165
+ # `RactorExecutor`:
166
+
167
+ # This must be explicitly required
168
+ # since it doesn't run on anything prior to Ruby version 4.x
169
+ # (the API changed a lot between versions so it only targets 4.x for now).
170
+ # Also, the Java based VMs (JRuby and TruffleRuby)
171
+ # don't support Ractor primitives *at all* yet.
172
+ require 'async_futures/ractor_executor'
173
+
174
+ rexec = AsyncFutures::RactorExecutor.new
175
+ ftr1 = rexec.submit('world') { |subject| 'hello ' + subject.to_s }
176
+ final_string = ftr1.result
177
+
178
+ begin
179
+ ftr1 = rexec.submit_concurrent('world') { |subject| 'hello ' + subject.to_s }
180
+ rescue AsyncFutures::NoConcurrencyError
181
+ # This will not currently trigger.
182
+ # This may change in a future version to be more like ThreadExecutor.
183
+ end
184
+
185
+ subjects = ['Alice', 'Bob', 'Charlie']
186
+ final_strings = rexec.map(subjects) { |subject| 'hello ' + subject.to_s }.to_a
187
+ ```
188
+
189
+ ## Development
190
+
191
+ After checking out the repo, run `bin/setup` to install dependencies.
192
+ Then, run `rake test` to run the tests.
193
+ You can also run `bin/console` for an interactive prompt
194
+ that will allow you to experiment.
195
+
196
+ To install this gem onto your local machine, run `bundle exec rake install`.
197
+ To release a new version, update the version number in `version.rb`,
198
+ and then run `bundle exec rake release`,
199
+ which will create a git tag for the version,
200
+ push git commits and the created tag,
201
+ and push the `.gem` file to [rubygems.org](https://rubygems.org).
202
+
203
+ ### Benchmarks
204
+
205
+ There is a simple benchmarking script in this repo at `bin/benchmark`.
206
+ It takes no command line arguments.
207
+ Just read it,
208
+ then run it
209
+ to see the difference in executor performance
210
+ on different types of workloads.
211
+
212
+ ### Documentation
213
+
214
+ The documentation in this repo uses [Semantic Line breaks](https://sembr.org/).
215
+ If you contribute documentation changes, please follow the same convention.
216
+
217
+ ## Contributing
218
+
219
+ Bug reports and pull requests are welcome on Codeberg at
220
+ <https://codeberg.org/eestrada/async_futures>.
data/Rakefile ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'minitest/test_task'
5
+ require 'rubocop/rake_task'
6
+ require 'rdoc/task'
7
+
8
+ Minitest::TestTask.create
9
+
10
+ # https://github.com/simplecov-ruby/simplecov/issues/1032#issuecomment-2087973750
11
+ Minitest::TestTask.create(:coverage) do |t|
12
+ # simplecov can be configured inside the `test/minitest_helper.rb` file, but it
13
+ # needs to be required before minitest, otherwise it's at_exit hook won't be
14
+ # registered in the correct order, which will cause coverage to be missed.
15
+ #
16
+ # Run `rake test:cmd` and `rake coverage:cmd` to see the difference.
17
+ t.test_prelude = 'require "simplecov"'
18
+ t.verbose = true
19
+ end
20
+
21
+ CLEAN << 'coverage'
22
+
23
+ RuboCop::RakeTask.new
24
+
25
+ namespace :rbs do
26
+ desc 'Generate prototypes for all library files'
27
+ task :prototype do
28
+ Dir.chdir('lib') do
29
+ Dir.glob('**/*.rb').each do |e|
30
+ FileUtils.mkdir_p("../sig/#{File.dirname(e)}")
31
+ sh "bundle exec rbs prototype rb '#{e}' > '../sig/#{e}s'"
32
+ end
33
+ end
34
+ end
35
+
36
+ desc 'Validate RBS files'
37
+ task :validate do
38
+ sh 'bundle', 'exec', 'rbs', 'validate'
39
+ end
40
+ end
41
+
42
+ # https://docs.ruby-lang.org/en/3.4/RDoc/Task.html
43
+ RDoc::Task.new do |rdoc|
44
+ rdoc.markup = 'markdown'
45
+ rdoc.main = 'README.md'
46
+ rdoc.rdoc_files.include('README.md', 'lib/**/*.rb')
47
+
48
+ # `docs` directory is what GitHub currently supports as pages source.
49
+ rdoc.rdoc_dir = 'docs'
50
+ end
51
+
52
+ task default: %i[rubocop coverage]
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncFutures
4
+ # Base error class.
5
+ class Error < StandardError; end
6
+
7
+ # Error that signals that concurrency is not available for the requested operation.
8
+ # This may be permanent or temporary.
9
+ class NoConcurrencyError < Error; end
10
+
11
+ # Error raised for all invalid Future states.
12
+ class InvalidStateError < Error
13
+ attr_reader :future, :state
14
+
15
+ def initialize(future, state)
16
+ @future = future
17
+ @state = state
18
+
19
+ super("Unexpected state '#{@state}' for Future: #{@future}")
20
+ end
21
+ end
22
+
23
+ # Error that is raised for invalid operations on a cancelled Future.
24
+ class CancelledError < Error; end
25
+
26
+ # Error for Executor errors related to Ractors.
27
+ class RactorError < Error; end
28
+
29
+ # Error for Future errors related to deadlocks.
30
+ class DeadlockError < Error
31
+ attr_reader :future
32
+
33
+ def initialize(future)
34
+ @future = future
35
+
36
+ super("Future would deadlock: #{@future}")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+ require_relative 'future'
5
+
6
+ require 'timeout'
7
+
8
+ module AsyncFutures
9
+ # `Executor` mixin module.
10
+ # Has a simple implementation
11
+ # that just runs submitted blocks immediately
12
+ # and returns a completed `Future`.
13
+ # Can be used standalone as a stateless `Executor`
14
+ # that runs submitted blocks immediately.
15
+ #
16
+ # Classes using this mixin should override the `submit` method.
17
+ #
18
+ # `shutdown` should be overridden if there is cleanup to be performed.
19
+ #
20
+ # If an implementation wants to signal that it supports true concurrency,
21
+ # it should override the `submit_concurrent` method;
22
+ # this can be as simple as aliasing it
23
+ # to the previously overridden `submit` method.
24
+ #
25
+ # The `map` method should *never* be overridden.
26
+ # This is already logically correct
27
+ # and should work with any `Executor` implementation.
28
+ module Executor
29
+ # Schedules the block
30
+ # to be executed as `block.call(*args, **kwargs)`
31
+ # and returns a `Future` object representing the execution of the block.
32
+ #
33
+ # Some Executor implementations may,
34
+ # under some or all circumstances,
35
+ # run the given block immediately and synchronously
36
+ # and return an already completed `Future` object.
37
+ def submit(...)
38
+ Future.new.tap { |future| future.complete(...) }
39
+ end
40
+
41
+ # Schedules the block, to be executed as `block.call(*args, **kwargs)` and
42
+ # returns a `Future` object representing the execution of the block.
43
+ #
44
+ # Executor must support concurrency otherwise this method will raise the
45
+ # exception `NoConcurrencyError`.
46
+ #
47
+ # This method should *never* run the block to completion before returning.
48
+ # This could cause a serious deadlock condition that cannot be overcome.
49
+ # If an implementation cannot schedule this to run concurrently
50
+ # it is better for it to raise an exception such as `NoConcurrencyError`.
51
+ # This at least allows the caller an opportunity to recover
52
+ # instead of potentially deadlocking.
53
+ def submit_concurrent(...)
54
+ raise NoConcurrencyError unless support_concurrency?
55
+
56
+ submit(...)
57
+ end
58
+
59
+ # Return whether the current `Executor` implementation supports concurrency.
60
+ def support_concurrency?
61
+ false
62
+ end
63
+
64
+ # Similar to `enumerable.map(&block)` except:
65
+ #
66
+ # - `block` is executed asynchronously
67
+ # - several calls to block may be made concurrently
68
+ # - Instead of an `Array`, an `Enumerator::Lazy` is returned
69
+ #
70
+ # Just like `enumerable.map`,
71
+ # args are splatted for the block if there are multiple args.
72
+ # Thus you can do things like this:
73
+ #
74
+ # ```ruby
75
+ # ThreadExecutor.new.map(enum.each_with_index) do |e, i|
76
+ # [e, i]
77
+ # end
78
+ # ```
79
+ #
80
+ # `Future` instances are joined
81
+ # as the returned `Enumerator::Lazy` is enumerated over
82
+ # via a terminal method like `force`, `to_a`, or `each`.
83
+ # The `Future.result` values,
84
+ # and not the `Future` instances themselves,
85
+ # are what is returned.
86
+ #
87
+ # If a `block` call raises an exception,
88
+ # then that exception will be raised
89
+ # when its value is retrieved when enumerating
90
+ # over the `Enumerator::Lazy` instance.
91
+ # Any remaining `Future` instances will attempt to be cancelled
92
+ # in the case of a raised exception.
93
+ # However, because of possible concurrent execution
94
+ # there is no guarantee that they will be cancelled
95
+ # before being picked up, run, and completed.
96
+ #
97
+ # If `timeout` is given and not `nil`,
98
+ # then execution will raise `Timeout::Error`
99
+ # if more than `timeout` seconds elapses.
100
+ # The elapsed time includes both the initial submission of tasks
101
+ # *and* the enumeration of `Future` results from the returned `Enumerator::Lazy`.
102
+ #
103
+ # Keep in mind that an `Enumerator::Lazy` can be enumerated over more than once
104
+ # *and* that the `timeout` value will be evaluated each time it is enumerated
105
+ # *and* that the timeout value will be calculated from the time of first submission.
106
+ # Thus, enumeration could succeed on the first enumeration,
107
+ # but fail with a `Timeout::Error` on a subsequent enumeration.
108
+ # To avoid timing out on an enumeration after the first enumeration,
109
+ # you should save the result of the first enumeration in an `Array` (or similar)
110
+ # using something like `to_a` on the returned `Enumerator::Lazy` instance.
111
+ # If you immediately enumerate the returned `Enumerator::Lazy` only once
112
+ # or you have passed no `timeout` value,
113
+ # then none of this is a concern.
114
+ #
115
+ # Negative `timeout` values are allowed,
116
+ # but they just raise `Timeout::Error` immediately.
117
+ #
118
+ # Do ***not*** call this method with an infinite `Enumerable`
119
+ # and no `timeout` value:
120
+ # the first thing this method does is force it into a finite collection of futures.
121
+ # An infinite `Enumerable` forced into a finite collection
122
+ # will run forever and eventually eat up all memory.
123
+ def map(enumerable, timeout = nil, &block) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
124
+ timeout = nil if timeout&.zero?
125
+
126
+ clock_timeout = Time.now.to_f + timeout if timeout
127
+
128
+ futures = []
129
+ begin
130
+ local_timeout = timeout && (clock_timeout - Time.now.to_f)
131
+ raise Timeout::Error unless timeout.nil? || local_timeout.positive?
132
+
133
+ Timeout.timeout(local_timeout) do
134
+ enumerable.each { |*args| futures << submit(*args, &block) }
135
+ end
136
+ rescue Exception => e # rubocop:disable Lint/RescueException
137
+ futures.each(&:cancel)
138
+ raise e
139
+ end
140
+
141
+ futures.each_with_index.lazy.map do |future, index|
142
+ local_timeout = timeout && (clock_timeout - Time.now.to_f)
143
+ raise Timeout::Error unless timeout.nil? || local_timeout.positive?
144
+
145
+ Timeout.timeout(local_timeout) { future.result }
146
+ rescue Exception => e # rubocop:disable Lint/RescueException
147
+ # If *any* future raises an exception,
148
+ # we need to be sure to cancel the remaining ones.
149
+ # It's ok if we call cancel on already completed ones.
150
+ (index...futures.size).each { |i| futures[i].cancel }
151
+ raise e
152
+ end
153
+ end
154
+
155
+ # Signal the executor that it should free any resources that it is using
156
+ # when the currently pending futures are done executing. Calls to
157
+ # Executor.submit() and Executor.map() made after shutdown will raise
158
+ # RuntimeError.
159
+ #
160
+ # If `wait` is `true` then this method will not return until all the pending
161
+ # futures are done executing and the resources associated with the executor
162
+ # have been freed. If `wait` is `False` then this method will return immediately
163
+ # and the resources associated with the executor will be freed when all
164
+ # pending futures are done executing. Regardless of the value of `wait`, the
165
+ # entire Ruby program will not exit until all pending futures are done
166
+ # executing.
167
+ #
168
+ # If `cancel_futures` is `true`, this method will cancel all pending futures
169
+ # that the executor has not started running. Any futures that are completed
170
+ # or running won't be cancelled, regardless of the value of `cancel_futures`.
171
+ #
172
+ # If both `cancel_futures` and `wait` are `true`, all futures that the executor
173
+ # has started running will be completed prior to this method returning. The
174
+ # remaining futures are cancelled.
175
+ #
176
+ # You can ensure this gets called under all circumstances
177
+ # by calling this method with a block.
178
+ # The block will be called
179
+ # and then any shutdown cleanup logic will be run
180
+ # after the block completes.
181
+ # The block will be passed one parameter: the executor instance.
182
+ #
183
+ # ```ruby
184
+ # ThreadExecutor.new(max_workers: 4).shutdown do |executor|
185
+ # executor.submit('src1.txt', 'dest1.txt', &FileUtils.method(:cp))
186
+ # executor.submit('src2.txt', 'dest2.txt', &FileUtils.method(:cp))
187
+ # executor.submit('src3.txt', 'dest3.txt', &FileUtils.method(:cp))
188
+ # executor.submit('src4.txt', 'dest4.txt', &FileUtils.method(:cp))
189
+ # end
190
+ # ```
191
+ #
192
+ # `shutdown` can be called multiple times.
193
+ # The block given will always be run,
194
+ # but the actual procedure to shutdown afterward will only be called once,
195
+ # on the first time.
196
+ #
197
+ # It is the caller's responsibility
198
+ # to ensure that the passed block can deal with a shutdown executor
199
+ # if there is any possibility
200
+ # of `shutdown` being called more than once with a block.
201
+ # Unless the caller is doing something very out of the ordinary,
202
+ # this is unlikely to be an issue.
203
+ #
204
+ # This method returns the return value of the block,
205
+ # or `nil` if no block is given.
206
+ def shutdown(wait: true, cancel_futures: false, &block) # rubocop:disable Lint/UnusedMethodArgument
207
+ block&.call(self)
208
+ ensure # rubocop:disable Lint/EmptyEnsure
209
+ # Cleanup logic goes here.
210
+ #
211
+ # The mixin has no state,
212
+ # so it has nothing to cleanup.
213
+ #
214
+ # Also, this is the only implementation that will *not* raise
215
+ # an exception when new tasks are submitted after shutdown,
216
+ # precisely because it has no state
217
+ # to even keep track of whether shutdown has previously been called or not.
218
+ end
219
+
220
+ module_function :submit, :submit_concurrent, :support_concurrency?, :map, :shutdown
221
+
222
+ public :submit, :submit_concurrent, :support_concurrency?, :map, :shutdown
223
+ end
224
+ end