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 +7 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +12 -0
- data/README.md +220 -0
- data/Rakefile +52 -0
- data/lib/async_futures/error.rb +39 -0
- data/lib/async_futures/executor.rb +224 -0
- data/lib/async_futures/fiber_executor.rb +140 -0
- data/lib/async_futures/future.rb +553 -0
- data/lib/async_futures/io_async.rb +313 -0
- data/lib/async_futures/logger.rb +12 -0
- data/lib/async_futures/process_executor.rb +155 -0
- data/lib/async_futures/ractor_executor.rb +353 -0
- data/lib/async_futures/thread_executor.rb +286 -0
- data/lib/async_futures/version.rb +6 -0
- data/lib/async_futures.rb +20 -0
- data/sig/async_futures.rbs +4 -0
- metadata +62 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :nocov:
|
|
4
|
+
# The Ractor API was different before version 4.x of Ruby.
|
|
5
|
+
if /^3\./ === RUBY_VERSION
|
|
6
|
+
raise LoadError.new("'async_futures/ractor_executor' is not supported in Ruby version '#{RUBY_VERSION}'")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# :nocov:
|
|
10
|
+
|
|
11
|
+
require_relative 'executor'
|
|
12
|
+
|
|
13
|
+
require 'etc'
|
|
14
|
+
require 'set' # rubocop:disable Lint/RedundantRequireStatement
|
|
15
|
+
|
|
16
|
+
module AsyncFutures
|
|
17
|
+
# `Executor` implementation based on `Thread` primitives
|
|
18
|
+
# that uses a pool of up to `max_workers` to execute calls concurrently.
|
|
19
|
+
#
|
|
20
|
+
# `RactorExecutor` specific submission considerations:
|
|
21
|
+
#
|
|
22
|
+
# For `RactorExecutor` the tasks are never run immediately upon submission.
|
|
23
|
+
# They are placed into a work queue
|
|
24
|
+
# to be picked up later by worker threads.
|
|
25
|
+
#
|
|
26
|
+
# This does _not_ guarantee
|
|
27
|
+
# that any particular task will be run concurrently
|
|
28
|
+
# with any other particular task;
|
|
29
|
+
# that is dependent on how many worker threads and tasks there are
|
|
30
|
+
# at any given point in time.
|
|
31
|
+
class RactorExecutor # rubocop:disable Metrics/ClassLength
|
|
32
|
+
include Executor
|
|
33
|
+
|
|
34
|
+
# Create a new `RactorExecutor`.
|
|
35
|
+
#
|
|
36
|
+
# Uses a pool of up to `max_workers`
|
|
37
|
+
# to execute tasks concurrently.
|
|
38
|
+
# If no value is given for `max_workers`
|
|
39
|
+
# it will default to `[32, Etc.nprocessors + 4].min`.
|
|
40
|
+
# Workers are spawned lazily as needed
|
|
41
|
+
# when tasks are added to the work queue.
|
|
42
|
+
#
|
|
43
|
+
# The parameter `worker_name_prefix` can be used
|
|
44
|
+
# to optionally add a prefix to generated `Thread` names.
|
|
45
|
+
#
|
|
46
|
+
# If the `move_result` keyword argument is `true`,
|
|
47
|
+
# results from worker ractors will be moved instead of copied.
|
|
48
|
+
# Moving is faster than copying,
|
|
49
|
+
# but less safe
|
|
50
|
+
# if the worker ractor keeps the values around for some reason
|
|
51
|
+
# (in a cache, for example).
|
|
52
|
+
# If you aren't doing something like caching inside workers
|
|
53
|
+
# you are probably safe to set this to `true`.
|
|
54
|
+
#
|
|
55
|
+
# If the `move_args` keyword argument is `true`,
|
|
56
|
+
# `args` and `kwargs` will be moved instead of copied
|
|
57
|
+
# from the submitting ractor to the worker ractors.
|
|
58
|
+
# Moving is faster than copying,
|
|
59
|
+
# but is even less safe than `move_result`
|
|
60
|
+
# because the submitting ractor
|
|
61
|
+
# is more likely to have kept references to the submitted values.
|
|
62
|
+
# You should only set this to `true`
|
|
63
|
+
# if you are absolutely certain that submitted values
|
|
64
|
+
# have no remaining references in the submitting ractor
|
|
65
|
+
# otherwise the submitting ractor will error when accessing them later.
|
|
66
|
+
def initialize( # rubocop:disable Metrics/AbcSize,Metrics/ParameterLists
|
|
67
|
+
max_workers: nil,
|
|
68
|
+
worker_name_prefix: nil,
|
|
69
|
+
move_result: false,
|
|
70
|
+
move_args: false,
|
|
71
|
+
make_args_shareable: false,
|
|
72
|
+
copy_args: false
|
|
73
|
+
)
|
|
74
|
+
if copy_args && !make_args_shareable
|
|
75
|
+
raise ArgumentError.new('`copy_args` cannot be true unless `make_args_shareable` is also true')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@max_workers = (max_workers || [32, Etc.nprocessors + 4].min).to_i
|
|
79
|
+
@worker_name_prefix = worker_name_prefix
|
|
80
|
+
|
|
81
|
+
# This value is passed into worker Ractors.
|
|
82
|
+
# If the caller passed something not shareable,
|
|
83
|
+
# it would error when we spawn workers later.
|
|
84
|
+
# Boolean values are always safely shareable
|
|
85
|
+
# and since we only care about the truthiness of this value
|
|
86
|
+
# double negation makes sense here.
|
|
87
|
+
@move_result = !!move_result # rubocop:disable Style/DoubleNegation
|
|
88
|
+
|
|
89
|
+
@move_args = move_args
|
|
90
|
+
@make_args_shareable = make_args_shareable
|
|
91
|
+
@copy_args = copy_args
|
|
92
|
+
@mutex = Thread::Mutex.new
|
|
93
|
+
@condition = Thread::ConditionVariable.new
|
|
94
|
+
@tasks = Thread::Queue.new
|
|
95
|
+
@worker_tasks_ports = Thread::Queue.new
|
|
96
|
+
|
|
97
|
+
# All private variables after this point
|
|
98
|
+
# require synchronization to safely interact with.
|
|
99
|
+
@results_ports = {}
|
|
100
|
+
@worker_to_task_port_map = ObjectSpace::WeakKeyMap.new
|
|
101
|
+
@futures = {}
|
|
102
|
+
|
|
103
|
+
@pool = Set.new
|
|
104
|
+
@worker_count = 0
|
|
105
|
+
|
|
106
|
+
@task_feeder = nil
|
|
107
|
+
@result_feeder = nil
|
|
108
|
+
|
|
109
|
+
# The inter-thread communication between these is necessary for shutdown,
|
|
110
|
+
# so even if nothing is submitted, we still need these to exist for now.
|
|
111
|
+
maybe_spawn_task_feeder
|
|
112
|
+
maybe_spawn_result_feeder
|
|
113
|
+
|
|
114
|
+
at_exit { shutdown(wait: false) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Asynchronously submit a task for execution.
|
|
118
|
+
#
|
|
119
|
+
# See `AsyncFutures::Executor.submit` method for full documentation.
|
|
120
|
+
def submit(*args, **kwargs, &block)
|
|
121
|
+
raise ArgumentError.new('No block given') unless block
|
|
122
|
+
|
|
123
|
+
Future.new.tap do |future|
|
|
124
|
+
# Attempt to make everything shareable upon submit
|
|
125
|
+
# so that if making shareable would raise an exception
|
|
126
|
+
# the caller can know immediately
|
|
127
|
+
# that a given value won't work.
|
|
128
|
+
# Otherwise the errors could easily get swallowed in a background thread
|
|
129
|
+
# and the caller would never know.
|
|
130
|
+
sh_block = Ractor.shareable_proc(&block)
|
|
131
|
+
sh_args = @make_args_shareable ? Ractor.make_shareable(args, copy: @copy_args) : args
|
|
132
|
+
sh_kwargs = @make_args_shareable ? Ractor.make_shareable(kwargs, copy: @copy_args) : kwargs
|
|
133
|
+
ractor_task = [future, sh_block, sh_args, sh_kwargs]
|
|
134
|
+
|
|
135
|
+
@tasks.push(ractor_task)
|
|
136
|
+
maybe_spawn_worker
|
|
137
|
+
maybe_spawn_task_feeder
|
|
138
|
+
maybe_spawn_result_feeder
|
|
139
|
+
rescue ClosedQueueError
|
|
140
|
+
raise 'RactorExecutor instance is shutdown'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# :nocov:
|
|
145
|
+
|
|
146
|
+
# Always returns `true`
|
|
147
|
+
# for `RactorExecutor`.
|
|
148
|
+
def support_concurrency?
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# :nocov:
|
|
153
|
+
|
|
154
|
+
# Shutdown `RactorExecutor` instance.
|
|
155
|
+
#
|
|
156
|
+
# See `AsyncFutures::Executor.shutdown` for full documentation.
|
|
157
|
+
def shutdown(wait: true, cancel_futures: false, &block)
|
|
158
|
+
block&.call(self)
|
|
159
|
+
ensure
|
|
160
|
+
unless check_and_set_shutdown!
|
|
161
|
+
if cancel_futures
|
|
162
|
+
while (task = @tasks.pop)
|
|
163
|
+
future = task[0]
|
|
164
|
+
future.cancel
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
synchronize { wait_until { @worker_tasks_ports.closed? && @worker_tasks_ports.empty? } } if wait
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def synchronize(&block)
|
|
175
|
+
@mutex.synchronize do
|
|
176
|
+
block.call
|
|
177
|
+
ensure
|
|
178
|
+
@condition.broadcast
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def wait_until
|
|
183
|
+
@condition.wait(@mutex) until yield
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Returns the current shutdown state,
|
|
187
|
+
# then sets internal shutdown state to `true`.
|
|
188
|
+
# This is all done atomically to avoid race conditions.
|
|
189
|
+
def check_and_set_shutdown!
|
|
190
|
+
synchronize do
|
|
191
|
+
return true if @tasks.closed?
|
|
192
|
+
|
|
193
|
+
@tasks.close
|
|
194
|
+
return false
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def new_worker_name
|
|
199
|
+
if @worker_name_prefix
|
|
200
|
+
"#{@worker_name_prefix}_#{@worker_count += 1}"
|
|
201
|
+
else
|
|
202
|
+
"#{self.class.name}_#{object_id}_worker_#{@worker_count += 1}"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def maybe_spawn_task_feeder
|
|
207
|
+
synchronize { spawn_task_feeder unless @task_feeder }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def spawn_task_feeder # rubocop:disable Metrics/AbcSize
|
|
211
|
+
@task_feeder = Thread.new("task_feeder_#{object_id}") do |feeder_name|
|
|
212
|
+
Thread.current.name = feeder_name
|
|
213
|
+
|
|
214
|
+
while (task = @tasks.pop)
|
|
215
|
+
future, block, args, kwargs = task
|
|
216
|
+
|
|
217
|
+
next unless future.set_running_or_notify_cancel
|
|
218
|
+
|
|
219
|
+
next_tasks_port = @worker_tasks_ports.pop
|
|
220
|
+
|
|
221
|
+
# `block` was already made shareable
|
|
222
|
+
# in the submitting thread.
|
|
223
|
+
# `args` and `kwargs` _may_ have been made shareable already.
|
|
224
|
+
# `object_id` is an `Integer`
|
|
225
|
+
# and thus is inherently immutable and shareable.
|
|
226
|
+
ractor_task = [future.object_id, block, args, kwargs].freeze
|
|
227
|
+
next_tasks_port.send(ractor_task, move: @move_args)
|
|
228
|
+
|
|
229
|
+
synchronize do
|
|
230
|
+
@futures[future.object_id] = future # rubocop:disable Lint/HashCompareByIdentity
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# once the task queue closes
|
|
235
|
+
# that means the executor is shutdown.
|
|
236
|
+
# We need to shutdown all workers until
|
|
237
|
+
# the worker queue gets closed by the `@results_feeder`.
|
|
238
|
+
while (next_tasks_port = @worker_tasks_ports.pop)
|
|
239
|
+
next_tasks_port.send(:shutdown)
|
|
240
|
+
end
|
|
241
|
+
ensure
|
|
242
|
+
synchronize do
|
|
243
|
+
@task_feeder = nil
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def maybe_spawn_result_feeder
|
|
249
|
+
synchronize { spawn_result_feeder unless @result_feeder }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def spawn_result_feeder # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
253
|
+
@result_feeder = Thread.new("result_feeder_#{object_id}") do |feeder_name|
|
|
254
|
+
Thread.current.name = feeder_name
|
|
255
|
+
|
|
256
|
+
loop do
|
|
257
|
+
break_loop, results_ports_keys = synchronize do
|
|
258
|
+
wait_until { !@results_ports.empty? || (@pool.empty? && @tasks.closed? && @tasks.empty?) }
|
|
259
|
+
|
|
260
|
+
[@results_ports.empty? && @pool.empty? && @tasks.closed? && @tasks.empty?, @results_ports.keys]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
break if break_loop
|
|
264
|
+
|
|
265
|
+
port, msg = Ractor.select(*results_ports_keys)
|
|
266
|
+
|
|
267
|
+
case msg
|
|
268
|
+
when :exited
|
|
269
|
+
synchronize do
|
|
270
|
+
worker = @results_ports[port]
|
|
271
|
+
@pool.delete worker
|
|
272
|
+
@results_ports.delete(port)
|
|
273
|
+
end
|
|
274
|
+
port.close
|
|
275
|
+
else # Must be an Array
|
|
276
|
+
future_id, type, value = msg
|
|
277
|
+
|
|
278
|
+
future = synchronize { @futures.delete(future_id) { raise "future_id not found #{future_id}" } }
|
|
279
|
+
|
|
280
|
+
future.set_exception(value) if type.equal? :exception
|
|
281
|
+
future.set_result(value) if type.equal? :result
|
|
282
|
+
|
|
283
|
+
tasks_port = synchronize do
|
|
284
|
+
worker = @results_ports[port]
|
|
285
|
+
@worker_to_task_port_map[worker]
|
|
286
|
+
end
|
|
287
|
+
@worker_tasks_ports.push tasks_port
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# We synchronize here so that we broadcast the condition afterward.
|
|
292
|
+
synchronize { @worker_tasks_ports.close }
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Only spawn a worker if one is needed.
|
|
297
|
+
def maybe_spawn_worker
|
|
298
|
+
# synchronize when interacting directly with @pool
|
|
299
|
+
spawn_worker if synchronize { @pool.empty? || (!@tasks.empty? && @pool.size < @max_workers) }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Always spawn a worker
|
|
303
|
+
def spawn_worker # rubocop:disable Metrics/AbcSize
|
|
304
|
+
new_results_port = Ractor::Port.new
|
|
305
|
+
|
|
306
|
+
# Coverage doesn't currently work outside the main Ractor,
|
|
307
|
+
# so just skip it for now.
|
|
308
|
+
# :nocov:
|
|
309
|
+
worker = Ractor.new(
|
|
310
|
+
new_results_port,
|
|
311
|
+
@move_result,
|
|
312
|
+
name: new_worker_name
|
|
313
|
+
) do |results_port, move_result|
|
|
314
|
+
tasks_port = Ractor::Port.new
|
|
315
|
+
|
|
316
|
+
results_port.send(tasks_port)
|
|
317
|
+
|
|
318
|
+
Ractor.current.default_port.close
|
|
319
|
+
|
|
320
|
+
loop do
|
|
321
|
+
case (task = tasks_port.receive)
|
|
322
|
+
when :shutdown
|
|
323
|
+
break
|
|
324
|
+
when Array
|
|
325
|
+
future_id, block, args, kwargs = task
|
|
326
|
+
else
|
|
327
|
+
raise RactorError.new("Unknown message received: #{task}")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
begin
|
|
331
|
+
result = block.call(*args, **kwargs)
|
|
332
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
333
|
+
results_port.send([future_id, :exception, e], move: move_result)
|
|
334
|
+
else
|
|
335
|
+
results_port.send([future_id, :result, result], move: move_result)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
ensure
|
|
339
|
+
tasks_port.close
|
|
340
|
+
end
|
|
341
|
+
# :nocov:
|
|
342
|
+
|
|
343
|
+
new_tasks_port = new_results_port.receive
|
|
344
|
+
worker.monitor new_results_port
|
|
345
|
+
synchronize do
|
|
346
|
+
@results_ports[new_results_port] = worker
|
|
347
|
+
@pool.add worker
|
|
348
|
+
@worker_to_task_port_map[worker] = new_tasks_port
|
|
349
|
+
end
|
|
350
|
+
@worker_tasks_ports.push new_tasks_port
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'executor'
|
|
4
|
+
|
|
5
|
+
require 'etc'
|
|
6
|
+
require 'set' # rubocop:disable Lint/RedundantRequireStatement
|
|
7
|
+
|
|
8
|
+
module AsyncFutures
|
|
9
|
+
# `Executor` implementation based on `Thread` primitives
|
|
10
|
+
# that uses a pool of up to `max_workers` to execute calls concurrently.
|
|
11
|
+
#
|
|
12
|
+
# `ThreadExecutor` specific submission considerations:
|
|
13
|
+
#
|
|
14
|
+
# For `ThreadExecutor`, with no arguments passed,
|
|
15
|
+
# the tasks are not run immediately upon submission.
|
|
16
|
+
# They are placed into a work queue
|
|
17
|
+
# to be picked up later by worker threads.
|
|
18
|
+
#
|
|
19
|
+
# This does _not_ guarantee
|
|
20
|
+
# that any particular task will be run concurrently
|
|
21
|
+
# with any other particular task;
|
|
22
|
+
# that is dependent on how many worker threads and tasks there are
|
|
23
|
+
# at any given point in time
|
|
24
|
+
# and whether the `strict_concurrency` argument is passed.
|
|
25
|
+
class ThreadExecutor # rubocop:disable Metrics/ClassLength
|
|
26
|
+
include Executor
|
|
27
|
+
|
|
28
|
+
# Create a new `ThreadExecutor`.
|
|
29
|
+
#
|
|
30
|
+
# Uses a pool of up to `max_workers`
|
|
31
|
+
# to execute tasks concurrently.
|
|
32
|
+
# If no value is given for `max_workers`
|
|
33
|
+
# it will default to `[32, Etc.nprocessors + 4].min`.
|
|
34
|
+
# Workers are spawned lazily as needed
|
|
35
|
+
# when tasks are added to the work queue.
|
|
36
|
+
#
|
|
37
|
+
# The `strict_concurrency` argument
|
|
38
|
+
# changes the behavior of both `submit` and `submit_concurrent`.
|
|
39
|
+
# When the argument is `false`
|
|
40
|
+
# then both methods will just put tasks on a queue
|
|
41
|
+
# and assume they will get picked up later.
|
|
42
|
+
# When the argument is `true` then the following happens:
|
|
43
|
+
#
|
|
44
|
+
# - `submit`: if adding another task to the queue
|
|
45
|
+
# would make more tasks than available (or potential) workers,
|
|
46
|
+
# then the task is run immediately
|
|
47
|
+
# and a completed future is returned to the caller.
|
|
48
|
+
# - `submit_concurrent`: if adding another task to the queue
|
|
49
|
+
# would make more tasks than available (or potential) workers,
|
|
50
|
+
# then a `NoConcurrencyError` is raised.
|
|
51
|
+
#
|
|
52
|
+
# With `strict_concurrency: false`
|
|
53
|
+
# you can do interesting/dangerous things.
|
|
54
|
+
# For example, you can add tasks to the executor
|
|
55
|
+
# from within a executor worker thread,
|
|
56
|
+
# even if the max worker count is only `1`.
|
|
57
|
+
# If you think through this scenario
|
|
58
|
+
# you will realize that joining on the returned future
|
|
59
|
+
# will deadlock the worker thread.
|
|
60
|
+
#
|
|
61
|
+
# This defaults to `false` precisely because
|
|
62
|
+
# scenarios like this are uncommon.
|
|
63
|
+
# The most common scenario is firing of many tasks
|
|
64
|
+
# from the main thread of execution
|
|
65
|
+
# that do not interact other than to return a value
|
|
66
|
+
# to the main thread.
|
|
67
|
+
#
|
|
68
|
+
# A scenario where you might want `strict_concurrency` to be `true`:
|
|
69
|
+
# you have client and server tasks
|
|
70
|
+
# and they *must* run concurrent to each other in order to work correctly.
|
|
71
|
+
#
|
|
72
|
+
# Consider this pseudocode, for example:
|
|
73
|
+
#
|
|
74
|
+
# ```ruby
|
|
75
|
+
# ThreadExecutor.new(max_workers: 1, strict_concurrency: true).shutdown do |executor|
|
|
76
|
+
# # ... other work, potentially using executor ...
|
|
77
|
+
#
|
|
78
|
+
# # This can fail if all workers are busy. Good! We want it to.
|
|
79
|
+
# # It doesn't make sense to run the client code afterward
|
|
80
|
+
# # if the server isn't first running concurrently.
|
|
81
|
+
# executor.submit_concurrent { Server.new.listen() }
|
|
82
|
+
#
|
|
83
|
+
# # The client doesn't *need* to run concurrently;
|
|
84
|
+
# # It is logically correct to run it either concurrently OR immediately,
|
|
85
|
+
# # so we use `submit` instead of `submit_concurrent` for client code.
|
|
86
|
+
# executor.submit do
|
|
87
|
+
# client = Client.new
|
|
88
|
+
# client.ping()
|
|
89
|
+
# # ... interact with `client` while server runs in background concurrently ...
|
|
90
|
+
# ensure
|
|
91
|
+
# client.signal_server_shutdown()
|
|
92
|
+
# end
|
|
93
|
+
#
|
|
94
|
+
# # ... maybe more code after ...
|
|
95
|
+
# end
|
|
96
|
+
# ```
|
|
97
|
+
#
|
|
98
|
+
# If the `reap_after` keyword argument is given,
|
|
99
|
+
# worker threads will be shut down
|
|
100
|
+
# if they haven't received any work after this amount of seconds.
|
|
101
|
+
# If it is `nil` or not given,
|
|
102
|
+
# they will not be reaped until the `ThreadExecutor` instance is `shutdown`.
|
|
103
|
+
#
|
|
104
|
+
# The parameter `worker_name_prefix` can be used
|
|
105
|
+
# to optionally add a prefix to generated `Thread` worker names.
|
|
106
|
+
def initialize(
|
|
107
|
+
max_workers: nil,
|
|
108
|
+
strict_concurrency: false,
|
|
109
|
+
reap_after: nil,
|
|
110
|
+
worker_name_prefix: nil
|
|
111
|
+
)
|
|
112
|
+
@max_workers = (max_workers || [32, Etc.nprocessors + 4].min).to_i
|
|
113
|
+
@strict_concurrency = strict_concurrency
|
|
114
|
+
@reap_after = reap_after
|
|
115
|
+
@worker_name_prefix = worker_name_prefix
|
|
116
|
+
@mutex = Thread::Mutex.new
|
|
117
|
+
@tasks = Thread::Queue.new
|
|
118
|
+
|
|
119
|
+
# Set Hash value to `true` when a worker is running
|
|
120
|
+
# and `false` otherwise.
|
|
121
|
+
@pool = {}
|
|
122
|
+
@worker_count = 0
|
|
123
|
+
|
|
124
|
+
at_exit { shutdown(wait: false) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Asynchronously submit a task for execution.
|
|
128
|
+
#
|
|
129
|
+
# May run task immediately
|
|
130
|
+
# and return a completed `Future`
|
|
131
|
+
# under certain circumstances.
|
|
132
|
+
#
|
|
133
|
+
# See `AsyncFutures::Executor.submit` method for full documentation.
|
|
134
|
+
def submit(*args, **kwargs, &block)
|
|
135
|
+
raise ArgumentError.new('No block given') unless block
|
|
136
|
+
|
|
137
|
+
Future.new.tap do |f|
|
|
138
|
+
f.complete(*args, **kwargs, &block) unless queue_task(f, *args, **kwargs, &block)
|
|
139
|
+
rescue ClosedQueueError
|
|
140
|
+
raise 'ThreadExecutor instance is shutdown'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Submit a task for concurrent execution.
|
|
145
|
+
#
|
|
146
|
+
# Will raise `NoConcurrencyError`
|
|
147
|
+
# if it is not possible
|
|
148
|
+
# to run the task concurrently
|
|
149
|
+
# with other already scheduled tasks.
|
|
150
|
+
#
|
|
151
|
+
# See `AsyncFutures::Executor.submit_concurrent` method for full documentation.
|
|
152
|
+
def submit_concurrent(*args, **kwargs, &block)
|
|
153
|
+
raise ArgumentError.new('No block given') unless block
|
|
154
|
+
|
|
155
|
+
Future.new.tap do |f|
|
|
156
|
+
raise NoConcurrencyError.new('Tasks exceed potential workers') unless queue_task(f, *args, **kwargs, &block)
|
|
157
|
+
rescue ClosedQueueError
|
|
158
|
+
raise 'ThreadExecutor instance is shutdown'
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Return the current size of the worker pool
|
|
163
|
+
def pool_size
|
|
164
|
+
synchronize { @pool.size }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# :nocov:
|
|
168
|
+
|
|
169
|
+
# Always returns `true`
|
|
170
|
+
# for `ThreadExecutor`.
|
|
171
|
+
def support_concurrency?
|
|
172
|
+
true
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# :nocov:
|
|
176
|
+
|
|
177
|
+
# Shutdown `ThreadExecutor` instance.
|
|
178
|
+
#
|
|
179
|
+
# See `AsyncFutures::Executor.shutdown` for full documentation.
|
|
180
|
+
def shutdown(wait: true, cancel_futures: false, &block)
|
|
181
|
+
block&.call(self)
|
|
182
|
+
ensure
|
|
183
|
+
unless check_and_set_shutdown!
|
|
184
|
+
if cancel_futures
|
|
185
|
+
while (task = @tasks.pop)
|
|
186
|
+
future = task[0]
|
|
187
|
+
future.cancel
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if wait
|
|
192
|
+
synchronize { @pool.dup }.each do |thread|
|
|
193
|
+
thread.join
|
|
194
|
+
synchronize { @pool.delete(thread) }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def synchronize(&)
|
|
203
|
+
@mutex.synchronize(&)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Returns the current shutdown state,
|
|
207
|
+
# then sets internal shutdown state to `true`.
|
|
208
|
+
# This is all done atomically to avoid race conditions.
|
|
209
|
+
def check_and_set_shutdown!
|
|
210
|
+
synchronize do
|
|
211
|
+
return true if @tasks.closed?
|
|
212
|
+
|
|
213
|
+
@tasks.close
|
|
214
|
+
return false
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Attempt to queue task.
|
|
219
|
+
# Return `true` if successful, `false` otherwise.
|
|
220
|
+
#
|
|
221
|
+
# Raises `ClosedQueueError` if the task queue is closed.
|
|
222
|
+
#
|
|
223
|
+
# If `@strict_concurrency` is `false`,
|
|
224
|
+
# this method always queues the task.
|
|
225
|
+
#
|
|
226
|
+
# If `@strict_concurrency` is `true`,
|
|
227
|
+
# task may or may not be queued
|
|
228
|
+
# based on whether there are any potentially available workers.
|
|
229
|
+
#
|
|
230
|
+
# May spawn a new worker, if the task was queued.
|
|
231
|
+
def queue_task(future, *args, **kwargs, &block)
|
|
232
|
+
queued = if @strict_concurrency
|
|
233
|
+
synchronize do
|
|
234
|
+
potential_workers = (@max_workers - @pool.size) + @pool.values.count(&:!)
|
|
235
|
+
if (@tasks.size + 1) <= potential_workers
|
|
236
|
+
@tasks.push([future, block, args, kwargs])
|
|
237
|
+
true
|
|
238
|
+
else
|
|
239
|
+
raise ClosedQueueError if @tasks.closed?
|
|
240
|
+
|
|
241
|
+
false
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
else
|
|
245
|
+
@tasks.push([future, block, args, kwargs])
|
|
246
|
+
true
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
queued.tap { maybe_spawn_worker if queued }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Only spawn a worker if one is needed.
|
|
253
|
+
def maybe_spawn_worker
|
|
254
|
+
# synchronize when interacting directly with @pool
|
|
255
|
+
spawn_worker if !@tasks.empty? && synchronize { @pool.size } < @max_workers
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def new_worker_name
|
|
259
|
+
synchronize do
|
|
260
|
+
if @worker_name_prefix
|
|
261
|
+
"#{@worker_name_prefix}_#{@worker_count += 1}"
|
|
262
|
+
else
|
|
263
|
+
"#{self.class.name}_#{object_id}_worker_#{@worker_count += 1}"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Always spawn a worker
|
|
269
|
+
def spawn_worker # rubocop:disable Metrics/AbcSize
|
|
270
|
+
thread = Thread.new do
|
|
271
|
+
Thread.current.name = new_worker_name
|
|
272
|
+
while (task = @tasks.pop(timeout: @reap_after))
|
|
273
|
+
synchronize { @pool[Thread.current] = true }
|
|
274
|
+
|
|
275
|
+
tfuture, tblock, targs, tkwargs = task
|
|
276
|
+
tfuture.complete(*targs, **tkwargs, &tblock)
|
|
277
|
+
|
|
278
|
+
synchronize { @pool[Thread.current] = false }
|
|
279
|
+
end
|
|
280
|
+
ensure
|
|
281
|
+
synchronize { @pool.delete Thread.current }
|
|
282
|
+
end
|
|
283
|
+
synchronize { @pool[thread] ||= false }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Library to create Future instances.
|
|
4
|
+
#
|
|
5
|
+
# Has Executor implementations for Ractor, Thread, and Fiber primitives.
|
|
6
|
+
module AsyncFutures
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# In order of dependency (roughly)
|
|
10
|
+
require_relative 'async_futures/logger'
|
|
11
|
+
require_relative 'async_futures/version'
|
|
12
|
+
require_relative 'async_futures/error'
|
|
13
|
+
require_relative 'async_futures/future'
|
|
14
|
+
require_relative 'async_futures/executor'
|
|
15
|
+
require_relative 'async_futures/fiber_executor'
|
|
16
|
+
require_relative 'async_futures/thread_executor'
|
|
17
|
+
|
|
18
|
+
# ractor executor is only support in version 4.x or greater,
|
|
19
|
+
# so it must be required explicitly.
|
|
20
|
+
# require_relative 'async_futures/ractor_executor'
|