async_futures 0.1.2 → 0.2.0
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 +4 -4
- data/README.md +1 -0
- data/lib/async_futures/counter.rb +17 -0
- data/lib/async_futures/executor.rb +89 -21
- data/lib/async_futures/fiber_executor.rb +26 -36
- data/lib/async_futures/future.rb +5 -5
- data/lib/async_futures/process_executor.rb +239 -66
- data/lib/async_futures/ractor_executor.rb +14 -50
- data/lib/async_futures/synchronized_delegator.rb +85 -0
- data/lib/async_futures/thread_executor.rb +33 -77
- data/lib/async_futures/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b11b0e5466cc8ba37b992612e1ed73048968cc67e0ca7012317c6fcbc0f1045
|
|
4
|
+
data.tar.gz: bf5081106fd5cf522744e95b1afe45dbd552018b76ab646757c8acf411d009f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f0cf51a97b98203674b3ea71db157908537b105a07a370062ee6e01a3970d7bc4f0ce5f1e7c2080fb4d131802f66f1a96b8980d0ab5400d7724d706f9bd643a
|
|
7
|
+
data.tar.gz: 832da9a52dcb471f1b8adffa21cb4fac0b52de12f6fc33e7a154d9a42d2c7b830d88014cbbdeaac7a71267575dcd74307ac60c76d4e8f01e140f06b1d4e5d6a8
|
data/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Launch asynchronous tasks
|
|
4
4
|
|
|
5
|
+
[](https://badge.fury.io/rb/async_futures)
|
|
5
6
|
[](https://github.com/eestrada/async_futures/actions/workflows/main.yml)
|
|
6
7
|
|
|
7
8
|
This library is heavily inspired by Python's `concurrent.futures` module.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsyncFutures
|
|
4
|
+
# Simple counter.
|
|
5
|
+
# Useful to wrap with a SynchronizedDelegator.
|
|
6
|
+
class Counter
|
|
7
|
+
attr_reader :value
|
|
8
|
+
|
|
9
|
+
def initialize(value = 0)
|
|
10
|
+
@value = value
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def increment(amount = 1)
|
|
14
|
+
@value += amount
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'counter'
|
|
3
4
|
require_relative 'error'
|
|
4
5
|
require_relative 'future'
|
|
6
|
+
require_relative 'synchronized_delegator'
|
|
5
7
|
|
|
6
8
|
require 'timeout'
|
|
7
9
|
|
|
8
|
-
module AsyncFutures
|
|
9
|
-
|
|
10
|
+
module AsyncFutures # rubocop:disable Style/Documentation
|
|
11
|
+
class << self
|
|
12
|
+
# Set the name for the current worker.
|
|
13
|
+
def worker_name=(name)
|
|
14
|
+
Fiber[:async_futures_worker_name] = name.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Get the name for the current worker.
|
|
18
|
+
def worker_name
|
|
19
|
+
Fiber[:async_futures_worker_name]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# `Executor` base class.
|
|
10
24
|
# Has a simple implementation
|
|
11
25
|
# that just runs submitted blocks immediately
|
|
12
26
|
# and returns a completed `Future`.
|
|
13
|
-
# Can be used standalone as a stateless `Executor`
|
|
14
|
-
# that runs submitted blocks immediately.
|
|
15
27
|
#
|
|
16
|
-
# Classes
|
|
28
|
+
# Classes inheriting this class
|
|
29
|
+
# should at least override the `submit` method.
|
|
17
30
|
#
|
|
18
31
|
# `shutdown` should be overridden if there is cleanup to be performed.
|
|
19
32
|
#
|
|
@@ -25,16 +38,26 @@ module AsyncFutures
|
|
|
25
38
|
# The `map` method should *never* be overridden.
|
|
26
39
|
# This is already logically correct
|
|
27
40
|
# and should work with any `Executor` implementation.
|
|
28
|
-
|
|
41
|
+
class Executor
|
|
42
|
+
# initialize private variables that all derived classes need.
|
|
43
|
+
def initialize(worker_name_prefix: nil)
|
|
44
|
+
@worker_name_prefix = worker_name_prefix
|
|
45
|
+
@mutex = Thread::Mutex.new
|
|
46
|
+
@condition = Thread::ConditionVariable.new
|
|
47
|
+
@tasks = Thread::Queue.new
|
|
48
|
+
@worker_count = SynchronizedDelegator.new(Counter.new(0))
|
|
49
|
+
end
|
|
50
|
+
|
|
29
51
|
# Schedules the block
|
|
30
52
|
# to be executed as `block.call(*args, **kwargs)`
|
|
31
53
|
# and returns a `Future` object representing the execution of the block.
|
|
32
54
|
#
|
|
33
|
-
# Some Executor implementations may,
|
|
55
|
+
# Some `Executor` implementations may,
|
|
34
56
|
# under some or all circumstances,
|
|
35
57
|
# run the given block immediately and synchronously
|
|
36
58
|
# and return an already completed `Future` object.
|
|
37
59
|
def submit(...)
|
|
60
|
+
refute_shutdown
|
|
38
61
|
Future.new.tap { |future| future.complete(...) }
|
|
39
62
|
end
|
|
40
63
|
|
|
@@ -203,22 +226,67 @@ module AsyncFutures
|
|
|
203
226
|
#
|
|
204
227
|
# This method returns the return value of the block,
|
|
205
228
|
# or `nil` if no block is given.
|
|
206
|
-
def shutdown(wait: true, cancel_futures: false
|
|
207
|
-
|
|
208
|
-
ensure
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
229
|
+
def shutdown(wait: true, cancel_futures: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
230
|
+
yield(self) if block_given?
|
|
231
|
+
ensure
|
|
232
|
+
at_first_shutdown do
|
|
233
|
+
# do nothing for base Executor
|
|
234
|
+
# even on first shutdown.
|
|
235
|
+
end
|
|
218
236
|
end
|
|
219
237
|
|
|
220
|
-
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def synchronize
|
|
241
|
+
@mutex.synchronize do
|
|
242
|
+
yield
|
|
243
|
+
ensure
|
|
244
|
+
@condition.broadcast
|
|
245
|
+
end
|
|
246
|
+
end
|
|
221
247
|
|
|
222
|
-
|
|
248
|
+
def wait_until
|
|
249
|
+
@condition.wait(@mutex) until yield
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# A simple Enumerator that iterates until the @tasks queue is closed and empty.
|
|
253
|
+
# Because it is a queue, it can block
|
|
254
|
+
# so it should only be used under circumstances
|
|
255
|
+
# where the caller is certain the queue will eventually close and drain.
|
|
256
|
+
#
|
|
257
|
+
# Also, the Enumerator can't be shared between threads like the queue itself
|
|
258
|
+
# can be.
|
|
259
|
+
def tasks_enum(timeout: nil)
|
|
260
|
+
Enumerator.new do |yielder|
|
|
261
|
+
while (task = @tasks.pop(timeout: timeout))
|
|
262
|
+
yielder << task
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Shutdown the Executor if it hasn't already been shutdown.
|
|
268
|
+
# Also run the given block,
|
|
269
|
+
# but only at the first shutdown attempt.
|
|
270
|
+
def at_first_shutdown
|
|
271
|
+
synchronize do
|
|
272
|
+
return if @tasks.closed?
|
|
273
|
+
|
|
274
|
+
@tasks.close
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
yield
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def refute_shutdown
|
|
281
|
+
raise "#{self.class.name} instance is shutdown" if @tasks.closed?
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def new_worker_name
|
|
285
|
+
if @worker_name_prefix
|
|
286
|
+
"#{@worker_name_prefix}_#{@worker_count.increment}"
|
|
287
|
+
else
|
|
288
|
+
"#{self.class.name}_#{object_id}_worker_#{@worker_count.increment}"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
223
291
|
end
|
|
224
292
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'error'
|
|
4
4
|
require_relative 'executor'
|
|
5
|
+
require_relative 'synchronized_delegator'
|
|
5
6
|
|
|
6
7
|
require 'set' # rubocop:disable Lint/RedundantRequireStatement
|
|
7
8
|
|
|
@@ -44,9 +45,7 @@ module AsyncFutures
|
|
|
44
45
|
# back to the `Fiber::Scheduler`
|
|
45
46
|
# and whether the `Fiber::Scheduler` properly implements
|
|
46
47
|
# `Fiber` switching for those operations.
|
|
47
|
-
class FiberExecutor
|
|
48
|
-
include Executor
|
|
49
|
-
|
|
48
|
+
class FiberExecutor < Executor
|
|
50
49
|
# Create a new `FiberExecutor`.
|
|
51
50
|
#
|
|
52
51
|
# Spawns fibers via `Fiber.schedule`.
|
|
@@ -65,16 +64,17 @@ module AsyncFutures
|
|
|
65
64
|
# so it is safe to use a single `FiberExecutor` instance across multiple threads.
|
|
66
65
|
# However each thread must have its own `Fiber::Scheduler` set
|
|
67
66
|
# in order to successfully call `submit`.
|
|
68
|
-
|
|
67
|
+
#
|
|
68
|
+
# The parameter `worker_name_prefix` can be used
|
|
69
|
+
# to optionally add a prefix to generated worker names.
|
|
70
|
+
def initialize(treat_as_concurrent: false, worker_name_prefix: nil)
|
|
69
71
|
raise Error.new('No Fiber.scheduler set') unless Fiber.scheduler
|
|
70
72
|
|
|
71
|
-
super()
|
|
73
|
+
super(worker_name_prefix: worker_name_prefix)
|
|
72
74
|
@treat_as_concurrent = treat_as_concurrent
|
|
73
|
-
@
|
|
74
|
-
@futures = Set.new
|
|
75
|
-
@mutex = Thread::Mutex.new
|
|
75
|
+
@futures = SynchronizedDelegator.new(Set.new)
|
|
76
76
|
|
|
77
|
-
at_exit { shutdown(wait: false) }
|
|
77
|
+
at_exit { shutdown(wait: false, cancel_futures: true) }
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# Asynchronously submit a task for execution.
|
|
@@ -84,16 +84,19 @@ module AsyncFutures
|
|
|
84
84
|
raise ArgumentError.new('No block given') unless block
|
|
85
85
|
|
|
86
86
|
Future.new.tap do |future|
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
synchronize do
|
|
88
|
+
refute_shutdown
|
|
89
89
|
|
|
90
90
|
# Need to set this immediately to ensure DeadlockError is raised appropriately.
|
|
91
91
|
future.thread = Thread.current
|
|
92
|
-
@futures.
|
|
93
|
-
|
|
92
|
+
future.add_done_callback { |f| @futures.delete(f) }
|
|
93
|
+
@futures.add(future)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
Fiber.schedule
|
|
96
|
+
Fiber.schedule do
|
|
97
|
+
AsyncFutures.worker_name = new_worker_name
|
|
98
|
+
future.complete(*args, **kwargs, &block)
|
|
99
|
+
end
|
|
97
100
|
end
|
|
98
101
|
end
|
|
99
102
|
|
|
@@ -110,30 +113,17 @@ module AsyncFutures
|
|
|
110
113
|
# Shutdown `FiberExecutor` instance.
|
|
111
114
|
#
|
|
112
115
|
# See `AsyncFutures::Executor.shutdown` for full documentation.
|
|
113
|
-
def shutdown(wait: true, cancel_futures: false
|
|
114
|
-
|
|
116
|
+
def shutdown(wait: true, cancel_futures: false) # rubocop:disable Metrics/CyclomaticComplexity
|
|
117
|
+
yield(self) if block_given?
|
|
115
118
|
ensure
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
at_first_shutdown do
|
|
120
|
+
if wait || cancel_futures
|
|
121
|
+
futures_dup = @futures.dup.to_set
|
|
122
|
+
futures_dup.reject!(&:cancel) if cancel_futures
|
|
119
123
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
private
|
|
127
|
-
|
|
128
|
-
# Returns the current shutdown state,
|
|
129
|
-
# then sets internal shutdown state to `true`.
|
|
130
|
-
# This is all done atomically to avoid race conditions.
|
|
131
|
-
def check_and_set_shutdown!
|
|
132
|
-
@mutex.synchronize do
|
|
133
|
-
return true if @is_shutdown
|
|
134
|
-
|
|
135
|
-
@is_shutdown = true
|
|
136
|
-
return false
|
|
124
|
+
# This will deadlock outside a FiberScheduler,
|
|
125
|
+
futures_dup.reject!(&:join) if wait
|
|
126
|
+
end
|
|
137
127
|
end
|
|
138
128
|
end
|
|
139
129
|
end
|
data/lib/async_futures/future.rb
CHANGED
|
@@ -216,8 +216,8 @@ module AsyncFutures
|
|
|
216
216
|
#
|
|
217
217
|
# It will return `true` if the block was run by this call
|
|
218
218
|
# and `false` if it was *not* run by this call.
|
|
219
|
-
def complete(*args, **kwargs
|
|
220
|
-
raise ArgumentError.new('No block given') unless
|
|
219
|
+
def complete(*args, **kwargs) # rubocop:disable Style/ArgumentsForwarding,Naming/PredicateMethod
|
|
220
|
+
raise ArgumentError.new('No block given') unless block_given?
|
|
221
221
|
|
|
222
222
|
begin
|
|
223
223
|
return false unless set_running_or_notify_cancel(set_context: true)
|
|
@@ -227,7 +227,7 @@ module AsyncFutures
|
|
|
227
227
|
end
|
|
228
228
|
|
|
229
229
|
begin
|
|
230
|
-
result =
|
|
230
|
+
result = yield(*args, **kwargs) # rubocop:disable Style/ArgumentsForwarding
|
|
231
231
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
232
232
|
set_exception(e)
|
|
233
233
|
else
|
|
@@ -502,7 +502,7 @@ module AsyncFutures
|
|
|
502
502
|
# Make all internal states private visibility
|
|
503
503
|
private_constant :PENDING, :RUNNING, :CANCELLED, :CANCELLED_AND_NOTIFIED, :FINISHED
|
|
504
504
|
|
|
505
|
-
def private_join(timeout
|
|
505
|
+
def private_join(timeout)
|
|
506
506
|
Timeout.timeout(timeout) do
|
|
507
507
|
@mutex.synchronize do
|
|
508
508
|
unless lockless_done?
|
|
@@ -511,7 +511,7 @@ module AsyncFutures
|
|
|
511
511
|
end
|
|
512
512
|
|
|
513
513
|
@condition.wait(@mutex) until lockless_done?
|
|
514
|
-
|
|
514
|
+
yield
|
|
515
515
|
end
|
|
516
516
|
end
|
|
517
517
|
end
|
|
@@ -4,20 +4,50 @@ require_relative 'executor'
|
|
|
4
4
|
|
|
5
5
|
require 'etc'
|
|
6
6
|
require 'set' # rubocop:disable Lint/RedundantRequireStatement
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'base64'
|
|
9
|
+
|
|
10
|
+
# :nocov:
|
|
11
|
+
raise LoadError.new('ProcessExecutor requires `Process.fork`') unless Process.respond_to?(:fork)
|
|
12
|
+
# :nocov:
|
|
7
13
|
|
|
8
14
|
module AsyncFutures
|
|
9
15
|
# `Executor` implementation based on Process forking
|
|
10
|
-
# that uses up to `max_workers` to execute calls
|
|
16
|
+
# that uses up to `max_workers` to execute calls in parallel.
|
|
17
|
+
#
|
|
18
|
+
# `ProcessExecutor` specific considerations:
|
|
19
|
+
#
|
|
20
|
+
# The `ProcessExecutor` class is not required by default
|
|
21
|
+
# when loading the overall `AsyncFutures` gem.
|
|
22
|
+
#
|
|
23
|
+
# ```ruby
|
|
24
|
+
# # ProcessExecutor *NOT* loaded
|
|
25
|
+
# require 'async_futures'
|
|
11
26
|
#
|
|
12
|
-
#
|
|
27
|
+
# # ProcessExecutor loaded
|
|
28
|
+
# require 'async_futures/process_executor'
|
|
29
|
+
# ```
|
|
30
|
+
#
|
|
31
|
+
# This is because it depends on the `'base64'` gem.
|
|
32
|
+
# This gem was bundled in Ruby 3.3 and prior,
|
|
33
|
+
# but was unbundled in 3.4 and later
|
|
34
|
+
# (even though it is still the Ruby core team that maintains this gem).
|
|
35
|
+
# One goal of `AsyncFutures` is to have no hard dependencies
|
|
36
|
+
# on code outside the standard library.
|
|
37
|
+
# Because this Executor does have a hard gem dependency,
|
|
38
|
+
# it is not loaded by default.
|
|
39
|
+
#
|
|
40
|
+
# If you want to use this Executor in Ruby 3.4 or later,
|
|
41
|
+
# you will need to install the `'base64'` gem as well.
|
|
13
42
|
#
|
|
14
43
|
# For `ProcessExecutor` the tasks are never run immediately upon submission.
|
|
15
44
|
# They are placed into a work queue
|
|
16
45
|
# to be picked up later.
|
|
17
46
|
#
|
|
18
|
-
# Process workers are not reused for work
|
|
47
|
+
# Process workers are not reused for work
|
|
48
|
+
# like Threads and Ractors are.
|
|
19
49
|
# Each task gets a freshly forked process.
|
|
20
|
-
# This is because marshalling anonymous blocks is not trivial;
|
|
50
|
+
# This is because marshalling anonymous blocks is not trivial in Ruby;
|
|
21
51
|
# it is simpler to just fork after the block closure has been defined.
|
|
22
52
|
# Use `ThreadExecutor` or `RactorExecutor`
|
|
23
53
|
# for `Executor` implementations that support worker reuse.
|
|
@@ -25,43 +55,66 @@ module AsyncFutures
|
|
|
25
55
|
# Consequently, this executor is only really useful for expensive calculations
|
|
26
56
|
# where the startup time for a process
|
|
27
57
|
# is dwarfed by the time needed for the actual work.
|
|
28
|
-
#
|
|
29
|
-
#
|
|
58
|
+
# Although modern machines can fork a process thousands of times per second,
|
|
59
|
+
# this is very, very slow when machines can do billions of operations per second.
|
|
60
|
+
#
|
|
61
|
+
# If `RactorExecutor` is available on your Ruby engine/version
|
|
62
|
+
# it is probably a better choice for parallel work.
|
|
30
63
|
#
|
|
31
64
|
# This does _not_ guarantee
|
|
32
65
|
# that any particular task will be run concurrently
|
|
33
66
|
# with any other particular task;
|
|
34
|
-
# that is dependent on how many
|
|
67
|
+
# that is dependent on how many workers and tasks there are
|
|
35
68
|
# at any given point in time.
|
|
36
|
-
class ProcessExecutor
|
|
37
|
-
include Executor
|
|
38
|
-
|
|
69
|
+
class ProcessExecutor < Executor # rubocop:disable Metrics/ClassLength
|
|
39
70
|
# Create a new `ProcessExecutor`.
|
|
40
71
|
#
|
|
41
72
|
# Uses a pool of up to `max_workers`
|
|
42
|
-
# to execute tasks
|
|
73
|
+
# to execute tasks in parallel.
|
|
43
74
|
# If no value is given for `max_workers`
|
|
44
75
|
# it will default to `[32, Etc.nprocessors + 4].min`.
|
|
45
76
|
# Workers are spawned lazily as needed
|
|
46
77
|
# when tasks are added to the work queue.
|
|
47
78
|
#
|
|
48
79
|
# The parameter `worker_name_prefix` can be used
|
|
49
|
-
# to optionally add a prefix to generated
|
|
80
|
+
# to optionally add a prefix to generated worker names.
|
|
50
81
|
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
|
|
82
|
+
# The parameter `daemonize_workers`,
|
|
83
|
+
# if set to `true`,
|
|
84
|
+
# causes workers to reparent under the init process
|
|
85
|
+
# and allow it to reap them.
|
|
86
|
+
# If set to `false`,
|
|
87
|
+
# this will cause the Executor instance to use `Process.detach`
|
|
88
|
+
# on the PID of each spawned worker,
|
|
89
|
+
# which will create an extra Ruby thread to reap the PID of each worker.
|
|
90
|
+
# It defaults to `false`.
|
|
91
|
+
def initialize(
|
|
92
|
+
max_workers: nil,
|
|
93
|
+
worker_name_prefix: nil,
|
|
94
|
+
daemonize_workers: false
|
|
95
|
+
)
|
|
96
|
+
super(worker_name_prefix: worker_name_prefix)
|
|
97
|
+
|
|
57
98
|
@max_workers = (max_workers || [32, Etc.nprocessors + 4].min).to_i
|
|
58
|
-
@
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
99
|
+
@daemonize_workers = daemonize_workers
|
|
100
|
+
|
|
101
|
+
# All private variables after this point
|
|
102
|
+
# require synchronization to safely interact with.
|
|
103
|
+
@futures = {}
|
|
104
|
+
|
|
62
105
|
@pool = Set.new
|
|
106
|
+
@pids = Set.new
|
|
63
107
|
|
|
64
|
-
|
|
108
|
+
@task_feeder = nil
|
|
109
|
+
@result_feeder = nil
|
|
110
|
+
|
|
111
|
+
# The inter-thread communication between these is necessary for shutdown,
|
|
112
|
+
# so even if nothing is submitted, we still need these to exist for now.
|
|
113
|
+
maybe_spawn_task_feeder
|
|
114
|
+
maybe_spawn_result_feeder
|
|
115
|
+
|
|
116
|
+
# at_exit { terminate_workers }
|
|
117
|
+
at_exit { shutdown(wait: false, cancel_futures: true) }
|
|
65
118
|
end
|
|
66
119
|
|
|
67
120
|
# Asynchronously submit a task for execution.
|
|
@@ -69,25 +122,41 @@ module AsyncFutures
|
|
|
69
122
|
# See `AsyncFutures::Executor.submit` method for full documentation.
|
|
70
123
|
def submit(*args, **kwargs, &block)
|
|
71
124
|
raise ArgumentError.new('No block given') unless block
|
|
72
|
-
raise 'ProcessExecutor instance is shutdown' if @tasks.closed?
|
|
73
125
|
|
|
74
126
|
Future.new.tap do |future|
|
|
75
|
-
|
|
76
|
-
|
|
127
|
+
task_ary = [future, block, args, kwargs]
|
|
128
|
+
|
|
129
|
+
synchronize { @futures[future.object_id] = future } # rubocop:disable Lint/HashCompareByIdentity
|
|
130
|
+
@tasks.push(task_ary)
|
|
131
|
+
maybe_spawn_task_feeder
|
|
132
|
+
maybe_spawn_result_feeder
|
|
133
|
+
rescue ClosedQueueError
|
|
134
|
+
synchronize { @futures.delete(future.object_id) }
|
|
135
|
+
refute_shutdown
|
|
77
136
|
end
|
|
78
137
|
end
|
|
79
138
|
|
|
80
|
-
|
|
139
|
+
# :nocov:
|
|
81
140
|
|
|
82
|
-
|
|
141
|
+
# Always returns `true`
|
|
142
|
+
# for `ProcessExecutor`.
|
|
143
|
+
def support_concurrency?
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def pool_size
|
|
148
|
+
synchronize { @pool.size }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# :nocov:
|
|
83
152
|
|
|
84
153
|
# Shutdown `ProcessExecutor` instance.
|
|
85
154
|
#
|
|
86
155
|
# See `AsyncFutures::Executor.shutdown` for full documentation.
|
|
87
|
-
def shutdown(wait: true, cancel_futures: false
|
|
88
|
-
|
|
156
|
+
def shutdown(wait: true, cancel_futures: false)
|
|
157
|
+
yield(self) if block_given?
|
|
89
158
|
ensure
|
|
90
|
-
|
|
159
|
+
at_first_shutdown do
|
|
91
160
|
if cancel_futures
|
|
92
161
|
while (task = @tasks.pop)
|
|
93
162
|
future = task[0]
|
|
@@ -95,61 +164,165 @@ module AsyncFutures
|
|
|
95
164
|
end
|
|
96
165
|
end
|
|
97
166
|
|
|
98
|
-
if wait
|
|
99
|
-
synchronize { @pool.dup }.each do |thread|
|
|
100
|
-
thread.join
|
|
101
|
-
synchronize { @pool.delete(thread) }
|
|
102
|
-
end
|
|
103
|
-
end
|
|
167
|
+
synchronize { wait_until { all_work_complete? } } if wait
|
|
104
168
|
end
|
|
105
169
|
end
|
|
106
170
|
|
|
171
|
+
# # Send `SIGTERM` signal
|
|
172
|
+
# # to all running workers.
|
|
173
|
+
# #
|
|
174
|
+
# # First shuts down the executor.
|
|
175
|
+
# #
|
|
176
|
+
# # No processes are signalled if `daemonize_workers` is `true`.
|
|
177
|
+
# def terminate_workers
|
|
178
|
+
# shutdown(wait: false, cancel_futures: true)
|
|
179
|
+
# signal_workers('SIGTERM')
|
|
180
|
+
# end
|
|
181
|
+
|
|
182
|
+
# # Send `SIGKILL` signal
|
|
183
|
+
# # to all running workers.
|
|
184
|
+
# #
|
|
185
|
+
# # First shuts down the executor.
|
|
186
|
+
# #
|
|
187
|
+
# # No processes are signalled if `daemonize_workers` is `true`.
|
|
188
|
+
# def kill_workers
|
|
189
|
+
# shutdown(wait: false, cancel_futures: true)
|
|
190
|
+
# signal_workers('SIGKILL')
|
|
191
|
+
# end
|
|
192
|
+
|
|
107
193
|
private
|
|
108
194
|
|
|
109
|
-
def
|
|
110
|
-
|
|
195
|
+
# def signal_workers(signal)
|
|
196
|
+
# synchronize { @pids.dup }.each do |pid|
|
|
197
|
+
# Process.kill(signal, pid)
|
|
198
|
+
# rescue Errno::ECHILD, Errno::ESRCH
|
|
199
|
+
# # Do nothing
|
|
200
|
+
# ensure
|
|
201
|
+
# synchronize { @pids.delete(pid) }
|
|
202
|
+
# end
|
|
203
|
+
# end
|
|
204
|
+
|
|
205
|
+
# If the Executor is shutdown *AND* all remaining work as been completed.
|
|
206
|
+
#
|
|
207
|
+
# Must be called within a `synchronize` block.
|
|
208
|
+
def all_work_complete?
|
|
209
|
+
@pool.empty? && @tasks.closed? && @tasks.empty? && @futures.empty?
|
|
111
210
|
end
|
|
112
211
|
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
def check_and_set_shutdown!
|
|
117
|
-
synchronize do
|
|
118
|
-
return true if @tasks.closed?
|
|
212
|
+
# The smallest positive float value,
|
|
213
|
+
# and thus the smallest possible timeout value.
|
|
214
|
+
SMALLEST_TIMEOUT = 0.0.next_float
|
|
119
215
|
|
|
120
|
-
|
|
121
|
-
return false
|
|
122
|
-
end
|
|
123
|
-
end
|
|
216
|
+
private_constant :SMALLEST_TIMEOUT
|
|
124
217
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
# synchronize when interacting directly with @pool
|
|
128
|
-
spawn_worker if !@tasks.empty? && synchronize { @pool.size } < @max_workers
|
|
218
|
+
def maybe_spawn_task_feeder
|
|
219
|
+
synchronize { spawn_task_feeder unless @task_feeder }
|
|
129
220
|
end
|
|
130
221
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
222
|
+
def spawn_task_feeder # rubocop:disable Metrics/AbcSize
|
|
223
|
+
@task_feeder = Thread.new("task_feeder_#{object_id}") do |feeder_name|
|
|
224
|
+
Thread.current.name = feeder_name
|
|
225
|
+
|
|
226
|
+
while (task = @tasks.pop)
|
|
227
|
+
future, block, args, kwargs = task
|
|
135
228
|
|
|
136
|
-
|
|
137
|
-
|
|
229
|
+
unless future.set_running_or_notify_cancel
|
|
230
|
+
synchronize { @futures.delete(future.object_id) }
|
|
231
|
+
next
|
|
232
|
+
end
|
|
138
233
|
|
|
139
|
-
|
|
234
|
+
future_object_id = future.object_id
|
|
140
235
|
|
|
141
|
-
|
|
142
|
-
|
|
236
|
+
read_pipe, write_pipe, worker_name = synchronize do
|
|
237
|
+
wait_until { @pool.size < @max_workers }
|
|
238
|
+
|
|
239
|
+
read_pipe, write_pipe = IO.pipe
|
|
240
|
+
@pool.add(read_pipe)
|
|
241
|
+
[read_pipe, write_pipe, new_worker_name]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
pid = Process.fork do
|
|
245
|
+
# :nocov:
|
|
246
|
+
Process.daemon(true, true) if @daemonize_workers
|
|
247
|
+
# :nocov:
|
|
248
|
+
|
|
249
|
+
read_pipe.close
|
|
250
|
+
AsyncFutures.worker_name = worker_name
|
|
251
|
+
result = block.call(*args, **kwargs)
|
|
252
|
+
marshalled_result = Marshal.dump(result)
|
|
253
|
+
b64_enc = Base64.strict_encode64(marshalled_result)
|
|
254
|
+
json_result = JSON.dump([future_object_id, :result, b64_enc])
|
|
143
255
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
144
|
-
|
|
256
|
+
marshalled_exc = Marshal.dump(e)
|
|
257
|
+
b64_enc = Base64.strict_encode64(marshalled_exc)
|
|
258
|
+
json_exc = JSON.dump([future_object_id, :exception, b64_enc])
|
|
259
|
+
write_pipe.write(json_exc)
|
|
145
260
|
else
|
|
146
|
-
|
|
261
|
+
write_pipe.write(json_result)
|
|
262
|
+
ensure
|
|
263
|
+
write_pipe.close
|
|
147
264
|
end
|
|
265
|
+
write_pipe.close
|
|
266
|
+
|
|
267
|
+
next if @daemonize_workers
|
|
268
|
+
|
|
269
|
+
synchronize { @pids.add(pid) }
|
|
270
|
+
Process.detach(pid)
|
|
148
271
|
end
|
|
149
272
|
ensure
|
|
150
|
-
synchronize
|
|
273
|
+
synchronize do
|
|
274
|
+
@task_feeder = nil
|
|
275
|
+
end
|
|
151
276
|
end
|
|
152
|
-
synchronize { @pool.add thread }
|
|
153
277
|
end
|
|
278
|
+
|
|
279
|
+
def maybe_spawn_result_feeder
|
|
280
|
+
synchronize { spawn_result_feeder unless @result_feeder }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def spawn_result_feeder # rubocop:disable Metrics/AbcSize
|
|
284
|
+
@result_feeder = Thread.new("result_feeder_#{object_id}") do |feeder_name|
|
|
285
|
+
Thread.current.name = feeder_name
|
|
286
|
+
|
|
287
|
+
loop do
|
|
288
|
+
break_loop, results_pipes = synchronize do
|
|
289
|
+
wait_until { all_work_complete? || !@pool.empty? }
|
|
290
|
+
|
|
291
|
+
[all_work_complete?, @pool.dup]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
break if break_loop
|
|
295
|
+
|
|
296
|
+
next_pipe = results_pipes.lazy.filter { |p| p.wait_readable(SMALLEST_TIMEOUT) }.first
|
|
297
|
+
|
|
298
|
+
next if next_pipe.nil?
|
|
299
|
+
|
|
300
|
+
synchronize { @pool.delete(next_pipe) }
|
|
301
|
+
|
|
302
|
+
msg_raw = begin
|
|
303
|
+
next_pipe.read
|
|
304
|
+
ensure
|
|
305
|
+
next_pipe.close
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
msg = JSON.parse(msg_raw)
|
|
309
|
+
future_id, type, value = msg
|
|
310
|
+
b64_dec = Base64.strict_decode64(value)
|
|
311
|
+
unmarshalled_value = Marshal.load(b64_dec) # rubocop:disable Security/MarshalLoad
|
|
312
|
+
future = synchronize { @futures.delete(future_id) { raise "future_id not found #{future_id}" } }
|
|
313
|
+
|
|
314
|
+
future.set_exception(unmarshalled_value) if type.to_sym.equal? :exception
|
|
315
|
+
future.set_result(unmarshalled_value) if type.to_sym.equal? :result
|
|
316
|
+
end
|
|
317
|
+
ensure
|
|
318
|
+
synchronize do
|
|
319
|
+
@result_feeder = nil
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# def log_debug(&)
|
|
325
|
+
# AsyncFutures.logger&.debug(&)
|
|
326
|
+
# end
|
|
154
327
|
end
|
|
155
328
|
end
|
|
@@ -28,9 +28,7 @@ module AsyncFutures
|
|
|
28
28
|
# with any other particular task;
|
|
29
29
|
# that is dependent on how many worker threads and tasks there are
|
|
30
30
|
# at any given point in time.
|
|
31
|
-
class RactorExecutor # rubocop:disable Metrics/ClassLength
|
|
32
|
-
include Executor
|
|
33
|
-
|
|
31
|
+
class RactorExecutor < Executor # rubocop:disable Metrics/ClassLength
|
|
34
32
|
# Create a new `RactorExecutor`.
|
|
35
33
|
#
|
|
36
34
|
# Uses a pool of up to `max_workers`
|
|
@@ -41,7 +39,7 @@ module AsyncFutures
|
|
|
41
39
|
# when tasks are added to the work queue.
|
|
42
40
|
#
|
|
43
41
|
# The parameter `worker_name_prefix` can be used
|
|
44
|
-
# to optionally add a prefix to generated
|
|
42
|
+
# to optionally add a prefix to generated worker names.
|
|
45
43
|
#
|
|
46
44
|
# If the `move_result` keyword argument is `true`,
|
|
47
45
|
# results from worker ractors will be moved instead of copied.
|
|
@@ -71,12 +69,13 @@ module AsyncFutures
|
|
|
71
69
|
make_args_shareable: false,
|
|
72
70
|
copy_args: false
|
|
73
71
|
)
|
|
72
|
+
super(worker_name_prefix: worker_name_prefix)
|
|
73
|
+
|
|
74
74
|
if copy_args && !make_args_shareable
|
|
75
75
|
raise ArgumentError.new('`copy_args` cannot be true unless `make_args_shareable` is also true')
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
@max_workers = (max_workers || [32, Etc.nprocessors + 4].min).to_i
|
|
79
|
-
@worker_name_prefix = worker_name_prefix
|
|
80
79
|
|
|
81
80
|
# This value is passed into worker Ractors.
|
|
82
81
|
# If the caller passed something not shareable,
|
|
@@ -89,9 +88,6 @@ module AsyncFutures
|
|
|
89
88
|
@move_args = move_args
|
|
90
89
|
@make_args_shareable = make_args_shareable
|
|
91
90
|
@copy_args = copy_args
|
|
92
|
-
@mutex = Thread::Mutex.new
|
|
93
|
-
@condition = Thread::ConditionVariable.new
|
|
94
|
-
@tasks = Thread::Queue.new
|
|
95
91
|
@worker_tasks_ports = Thread::Queue.new
|
|
96
92
|
|
|
97
93
|
# All private variables after this point
|
|
@@ -101,7 +97,6 @@ module AsyncFutures
|
|
|
101
97
|
@futures = {}
|
|
102
98
|
|
|
103
99
|
@pool = Set.new
|
|
104
|
-
@worker_count = 0
|
|
105
100
|
|
|
106
101
|
@task_feeder = nil
|
|
107
102
|
@result_feeder = nil
|
|
@@ -111,7 +106,7 @@ module AsyncFutures
|
|
|
111
106
|
maybe_spawn_task_feeder
|
|
112
107
|
maybe_spawn_result_feeder
|
|
113
108
|
|
|
114
|
-
at_exit { shutdown(wait: false) }
|
|
109
|
+
at_exit { shutdown(wait: false, cancel_futures: true) }
|
|
115
110
|
end
|
|
116
111
|
|
|
117
112
|
# Asynchronously submit a task for execution.
|
|
@@ -137,7 +132,7 @@ module AsyncFutures
|
|
|
137
132
|
maybe_spawn_task_feeder
|
|
138
133
|
maybe_spawn_result_feeder
|
|
139
134
|
rescue ClosedQueueError
|
|
140
|
-
|
|
135
|
+
refute_shutdown
|
|
141
136
|
end
|
|
142
137
|
end
|
|
143
138
|
|
|
@@ -154,10 +149,10 @@ module AsyncFutures
|
|
|
154
149
|
# Shutdown `RactorExecutor` instance.
|
|
155
150
|
#
|
|
156
151
|
# See `AsyncFutures::Executor.shutdown` for full documentation.
|
|
157
|
-
def shutdown(wait: true, cancel_futures: false
|
|
158
|
-
|
|
152
|
+
def shutdown(wait: true, cancel_futures: false)
|
|
153
|
+
yield(self) if block_given?
|
|
159
154
|
ensure
|
|
160
|
-
|
|
155
|
+
at_first_shutdown do
|
|
161
156
|
if cancel_futures
|
|
162
157
|
while (task = @tasks.pop)
|
|
163
158
|
future = task[0]
|
|
@@ -171,38 +166,6 @@ module AsyncFutures
|
|
|
171
166
|
|
|
172
167
|
private
|
|
173
168
|
|
|
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
169
|
def maybe_spawn_task_feeder
|
|
207
170
|
synchronize { spawn_task_feeder unless @task_feeder }
|
|
208
171
|
end
|
|
@@ -255,9 +218,9 @@ module AsyncFutures
|
|
|
255
218
|
|
|
256
219
|
loop do
|
|
257
220
|
break_loop, results_ports_keys = synchronize do
|
|
258
|
-
wait_until { !@results_ports.empty? || (@
|
|
221
|
+
wait_until { !@results_ports.empty? || (@tasks.closed? && @tasks.empty? && @pool.empty?) }
|
|
259
222
|
|
|
260
|
-
[@
|
|
223
|
+
[@tasks.closed? && @tasks.empty? && @pool.empty? && @results_ports.empty?, @results_ports.keys]
|
|
261
224
|
end
|
|
262
225
|
|
|
263
226
|
break if break_loop
|
|
@@ -309,8 +272,9 @@ module AsyncFutures
|
|
|
309
272
|
worker = Ractor.new(
|
|
310
273
|
new_results_port,
|
|
311
274
|
@move_result,
|
|
312
|
-
|
|
313
|
-
) do |results_port, move_result|
|
|
275
|
+
new_worker_name
|
|
276
|
+
) do |results_port, move_result, worker_name|
|
|
277
|
+
AsyncFutures.worker_name = worker_name
|
|
314
278
|
tasks_port = Ractor::Port.new
|
|
315
279
|
|
|
316
280
|
results_port.send(tasks_port)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'delegate'
|
|
4
|
+
|
|
5
|
+
module AsyncFutures
|
|
6
|
+
# A Delegator that synchronizes all calls for a delegated object.
|
|
7
|
+
#
|
|
8
|
+
# This does not guarantee concurrency safety under all circumstances,
|
|
9
|
+
# but it does make it easier to be safe.
|
|
10
|
+
# For example,
|
|
11
|
+
# any method that returns `self` is potentially unsafe if chained afterward
|
|
12
|
+
# (because the chained operations can happen outside the synchronized mutex,
|
|
13
|
+
# and thus are not safe for concurrency).
|
|
14
|
+
#
|
|
15
|
+
# The only argument to `new` is the object to be wrapped.
|
|
16
|
+
# For concurrency safety, it should no longer be directly accessed
|
|
17
|
+
# outside the delegator
|
|
18
|
+
# unless you are know you are in a situation
|
|
19
|
+
# where the object will not be accessed concurrently.
|
|
20
|
+
class SynchronizedDelegator < SimpleDelegator
|
|
21
|
+
def initialize(obj)
|
|
22
|
+
@mutex = Thread::Mutex.new
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Like regular `method_missing`, but all calls are synchronized.
|
|
27
|
+
def method_missing(name, *args, **kwargs, &)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
# SimpleDelegator#method_missing forwards to __getobj__
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def respond_to_missing?(name, include_private = false)
|
|
35
|
+
# keep respond_to? consistent
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A synchronized implementation of `clone`.
|
|
40
|
+
#
|
|
41
|
+
# Uses a custom implementation of `initialize_clone`
|
|
42
|
+
# that creates and assigns a new mutex
|
|
43
|
+
# to the clone.
|
|
44
|
+
# The internal mutex will also be properly frozen
|
|
45
|
+
# if the original delegator is frozen.
|
|
46
|
+
def clone(freeze: nil)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A synchronized implementation of `dup`.
|
|
53
|
+
#
|
|
54
|
+
# Uses a custom implementation of `initialize_dup`
|
|
55
|
+
# that creates and assigns a new mutex
|
|
56
|
+
# to the duplicate.
|
|
57
|
+
def dup
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A synchronized implementation of `freeze`.
|
|
64
|
+
#
|
|
65
|
+
# Also freezes private mutex.
|
|
66
|
+
def freeze
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@mutex.freeze
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def initialize_dup(other) # :nodoc:
|
|
76
|
+
@mutex = other.instance_variable_get(:@mutex).dup
|
|
77
|
+
super
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize_clone(other, freeze: nil) # :nodoc:
|
|
81
|
+
@mutex = other.instance_variable_get(:@mutex).clone(freeze: freeze)
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'executor'
|
|
4
|
+
require_relative 'synchronized_delegator'
|
|
4
5
|
|
|
5
6
|
require 'etc'
|
|
6
7
|
require 'set' # rubocop:disable Lint/RedundantRequireStatement
|
|
@@ -22,9 +23,7 @@ module AsyncFutures
|
|
|
22
23
|
# that is dependent on how many worker threads and tasks there are
|
|
23
24
|
# at any given point in time
|
|
24
25
|
# and whether the `strict_concurrency` argument is passed.
|
|
25
|
-
class ThreadExecutor
|
|
26
|
-
include Executor
|
|
27
|
-
|
|
26
|
+
class ThreadExecutor < Executor
|
|
28
27
|
# Create a new `ThreadExecutor`.
|
|
29
28
|
#
|
|
30
29
|
# Uses a pool of up to `max_workers`
|
|
@@ -102,26 +101,24 @@ module AsyncFutures
|
|
|
102
101
|
# they will not be reaped until the `ThreadExecutor` instance is `shutdown`.
|
|
103
102
|
#
|
|
104
103
|
# The parameter `worker_name_prefix` can be used
|
|
105
|
-
# to optionally add a prefix to generated
|
|
104
|
+
# to optionally add a prefix to generated worker names.
|
|
106
105
|
def initialize(
|
|
107
106
|
max_workers: nil,
|
|
108
107
|
strict_concurrency: false,
|
|
109
108
|
reap_after: nil,
|
|
110
109
|
worker_name_prefix: nil
|
|
111
110
|
)
|
|
111
|
+
super(worker_name_prefix: worker_name_prefix)
|
|
112
|
+
|
|
112
113
|
@max_workers = (max_workers || [32, Etc.nprocessors + 4].min).to_i
|
|
113
114
|
@strict_concurrency = strict_concurrency
|
|
114
115
|
@reap_after = reap_after
|
|
115
|
-
@worker_name_prefix = worker_name_prefix
|
|
116
|
-
@mutex = Thread::Mutex.new
|
|
117
|
-
@tasks = Thread::Queue.new
|
|
118
116
|
|
|
119
117
|
# Set Hash value to `true` when a worker is running
|
|
120
118
|
# and `false` otherwise.
|
|
121
|
-
@pool = {}
|
|
122
|
-
@worker_count = 0
|
|
119
|
+
@pool = SynchronizedDelegator.new({})
|
|
123
120
|
|
|
124
|
-
at_exit { shutdown(wait: false) }
|
|
121
|
+
at_exit { shutdown(wait: false, cancel_futures: true) }
|
|
125
122
|
end
|
|
126
123
|
|
|
127
124
|
# Asynchronously submit a task for execution.
|
|
@@ -136,8 +133,6 @@ module AsyncFutures
|
|
|
136
133
|
|
|
137
134
|
Future.new.tap do |f|
|
|
138
135
|
f.complete(*args, **kwargs, &block) unless queue_task(f, *args, **kwargs, &block)
|
|
139
|
-
rescue ClosedQueueError
|
|
140
|
-
raise 'ThreadExecutor instance is shutdown'
|
|
141
136
|
end
|
|
142
137
|
end
|
|
143
138
|
|
|
@@ -154,14 +149,12 @@ module AsyncFutures
|
|
|
154
149
|
|
|
155
150
|
Future.new.tap do |f|
|
|
156
151
|
raise NoConcurrencyError.new('Tasks exceed potential workers') unless queue_task(f, *args, **kwargs, &block)
|
|
157
|
-
rescue ClosedQueueError
|
|
158
|
-
raise 'ThreadExecutor instance is shutdown'
|
|
159
152
|
end
|
|
160
153
|
end
|
|
161
154
|
|
|
162
155
|
# Return the current size of the worker pool
|
|
163
156
|
def pool_size
|
|
164
|
-
|
|
157
|
+
@pool.size
|
|
165
158
|
end
|
|
166
159
|
|
|
167
160
|
# :nocov:
|
|
@@ -177,44 +170,18 @@ module AsyncFutures
|
|
|
177
170
|
# Shutdown `ThreadExecutor` instance.
|
|
178
171
|
#
|
|
179
172
|
# See `AsyncFutures::Executor.shutdown` for full documentation.
|
|
180
|
-
def shutdown(wait: true, cancel_futures: false
|
|
181
|
-
|
|
173
|
+
def shutdown(wait: true, cancel_futures: false)
|
|
174
|
+
yield(self) if block_given?
|
|
182
175
|
ensure
|
|
183
|
-
|
|
184
|
-
if cancel_futures
|
|
185
|
-
while (task = @tasks.pop)
|
|
186
|
-
future = task[0]
|
|
187
|
-
future.cancel
|
|
188
|
-
end
|
|
189
|
-
end
|
|
176
|
+
at_first_shutdown do
|
|
177
|
+
tasks_enum.lazy.map(&:first).each(&:cancel) if cancel_futures
|
|
190
178
|
|
|
191
|
-
if wait
|
|
192
|
-
synchronize { @pool.dup }.each do |thread|
|
|
193
|
-
thread.join
|
|
194
|
-
synchronize { @pool.delete(thread) }
|
|
195
|
-
end
|
|
196
|
-
end
|
|
179
|
+
@pool.dup.to_h.each_key(&:join) if wait
|
|
197
180
|
end
|
|
198
181
|
end
|
|
199
182
|
|
|
200
183
|
private
|
|
201
184
|
|
|
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
185
|
# Attempt to queue task.
|
|
219
186
|
# Return `true` if successful, `false` otherwise.
|
|
220
187
|
#
|
|
@@ -228,59 +195,48 @@ module AsyncFutures
|
|
|
228
195
|
# based on whether there are any potentially available workers.
|
|
229
196
|
#
|
|
230
197
|
# May spawn a new worker, if the task was queued.
|
|
231
|
-
def queue_task(future, *args, **kwargs, &block)
|
|
198
|
+
def queue_task(future, *args, **kwargs, &block) # rubocop:disable Metrics/PerceivedComplexity
|
|
232
199
|
queued = if @strict_concurrency
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
false
|
|
242
|
-
end
|
|
200
|
+
potential_workers = (@max_workers - @pool.size) + @pool.values.count(&:!)
|
|
201
|
+
if (@tasks.size + 1) <= potential_workers
|
|
202
|
+
@tasks.push([future, block, args, kwargs])
|
|
203
|
+
true
|
|
204
|
+
else
|
|
205
|
+
raise ClosedQueueError if @tasks.closed?
|
|
206
|
+
|
|
207
|
+
false
|
|
243
208
|
end
|
|
244
209
|
else
|
|
245
210
|
@tasks.push([future, block, args, kwargs])
|
|
246
211
|
true
|
|
247
212
|
end
|
|
248
|
-
|
|
213
|
+
rescue ClosedQueueError
|
|
214
|
+
refute_shutdown
|
|
215
|
+
else
|
|
249
216
|
queued.tap { maybe_spawn_worker if queued }
|
|
250
217
|
end
|
|
251
218
|
|
|
252
219
|
# Only spawn a worker if one is needed.
|
|
253
220
|
def maybe_spawn_worker
|
|
254
|
-
|
|
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
|
|
221
|
+
spawn_worker if !@tasks.empty? && @pool.size < @max_workers
|
|
266
222
|
end
|
|
267
223
|
|
|
268
224
|
# Always spawn a worker
|
|
269
|
-
def spawn_worker
|
|
270
|
-
thread = Thread.new do
|
|
271
|
-
|
|
225
|
+
def spawn_worker
|
|
226
|
+
thread = Thread.new(new_worker_name) do |worker_name|
|
|
227
|
+
AsyncFutures.worker_name = worker_name
|
|
272
228
|
while (task = @tasks.pop(timeout: @reap_after))
|
|
273
|
-
|
|
229
|
+
@pool[Thread.current] = true
|
|
274
230
|
|
|
275
231
|
tfuture, tblock, targs, tkwargs = task
|
|
276
232
|
tfuture.complete(*targs, **tkwargs, &tblock)
|
|
277
233
|
|
|
278
|
-
|
|
234
|
+
@pool[Thread.current] = false
|
|
279
235
|
end
|
|
280
236
|
ensure
|
|
281
|
-
|
|
237
|
+
@pool.delete(Thread.current)
|
|
282
238
|
end
|
|
283
|
-
|
|
239
|
+
@pool[thread] ||= false
|
|
284
240
|
end
|
|
285
241
|
end
|
|
286
242
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: async_futures
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Estrada
|
|
@@ -21,6 +21,7 @@ files:
|
|
|
21
21
|
- README.md
|
|
22
22
|
- Rakefile
|
|
23
23
|
- lib/async_futures.rb
|
|
24
|
+
- lib/async_futures/counter.rb
|
|
24
25
|
- lib/async_futures/error.rb
|
|
25
26
|
- lib/async_futures/executor.rb
|
|
26
27
|
- lib/async_futures/fiber_executor.rb
|
|
@@ -29,6 +30,7 @@ files:
|
|
|
29
30
|
- lib/async_futures/logger.rb
|
|
30
31
|
- lib/async_futures/process_executor.rb
|
|
31
32
|
- lib/async_futures/ractor_executor.rb
|
|
33
|
+
- lib/async_futures/synchronized_delegator.rb
|
|
32
34
|
- lib/async_futures/thread_executor.rb
|
|
33
35
|
- lib/async_futures/version.rb
|
|
34
36
|
- sig/async_futures.rbs
|