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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1d673d78fe3b8bae70c67b12abf800e58cd79b95a7690c939b3d6d125d4224a
4
- data.tar.gz: 4806d60aa3da8d213aa286874dbc8c7a90902832acb22e35ed00060595a8419f
3
+ metadata.gz: 7b11b0e5466cc8ba37b992612e1ed73048968cc67e0ca7012317c6fcbc0f1045
4
+ data.tar.gz: bf5081106fd5cf522744e95b1afe45dbd552018b76ab646757c8acf411d009f4
5
5
  SHA512:
6
- metadata.gz: 9a3ee85e2d1d7a99feaae35124610c1d6265a43b27da902a3e32f683eb32d159a0b4cb7f6441e71de9e04da8827a30504058f9908976273abf11bec038c97fa8
7
- data.tar.gz: 11cb824b92cd4dee667e477a0f67b0b8fce864ce5eef5dd3781fc5f345d27cfd0cdedb349c95695ab957506a46027f67bb6b278825f147f5f6a84045ff59e375
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
+ [![Gem Version](https://badge.fury.io/rb/async_futures.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/async_futures)
5
6
  [![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
 
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
- # `Executor` mixin module.
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 using this mixin should override the `submit` method.
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
- module Executor
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, &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.
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
- module_function :submit, :submit_concurrent, :support_concurrency?, :map, :shutdown
238
+ private
239
+
240
+ def synchronize
241
+ @mutex.synchronize do
242
+ yield
243
+ ensure
244
+ @condition.broadcast
245
+ end
246
+ end
221
247
 
222
- public :submit, :submit_concurrent, :support_concurrency?, :map, :shutdown
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
- def initialize(treat_as_concurrent: false)
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
- @is_shutdown = false
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
- @mutex.synchronize do
88
- raise 'FiberExecutor instance is shutdown' if @is_shutdown
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.add future
93
- future.add_done_callback { |f| @mutex.synchronize { @futures.delete f } }
92
+ future.add_done_callback { |f| @futures.delete(f) }
93
+ @futures.add(future)
94
94
  end
95
95
 
96
- Fiber.schedule { future.complete(*args, **kwargs, &block) }
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, &block) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
114
- block&.call(self)
116
+ def shutdown(wait: true, cancel_futures: false) # rubocop:disable Metrics/CyclomaticComplexity
117
+ yield(self) if block_given?
115
118
  ensure
116
- unless check_and_set_shutdown!
117
- futures_dup = @mutex.synchronize { @futures.dup } if wait || cancel_futures
118
- futures_dup.reject!(&:cancel) if cancel_futures
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
- # This will deadlock outside a FiberScheduler,
121
- futures_dup.reject!(&:join) if wait
122
- @mutex.synchronize { @futures.replace(@futures & futures_dup) } if wait || cancel_futures
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
@@ -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, &block) # rubocop:disable Style/ArgumentsForwarding,Naming/PredicateMethod
220
- raise ArgumentError.new('No block given') unless block
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 = block.call(*args, **kwargs) # rubocop:disable Style/ArgumentsForwarding
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, &block)
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
- block.call
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 concurrently.
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
- # `ProcessExecutor` specific submission considerations:
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
- # If RactorExecutor is available on your Ruby version
29
- # it is almost certainly a better choice than this.
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 worker threads and tasks there are
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 concurrently.
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 `Thread` names.
80
+ # to optionally add a prefix to generated worker names.
50
81
  #
51
- # If the `reap_after` keyword argument is given,
52
- # worker threads will be shut down
53
- # if they haven't received any work after this amount of seconds.
54
- # If it is `nil` or not given,
55
- # they will not be reaped until the `ProcessExecutor` instance is `shutdown`.
56
- def initialize(max_workers: nil, worker_name_prefix: '', reap_after: nil)
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
- @worker_name_prefix = worker_name_prefix.to_s
59
- @reap_after = reap_after
60
- @mutex = Thread::Mutex.new
61
- @tasks = Thread::Queue.new
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
- at_exit { shutdown(wait: false) }
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
- @tasks.push([future, block, args, kwargs])
76
- maybe_spawn_worker
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
- alias submit_concurrent submit
139
+ # :nocov:
81
140
 
82
- public :map
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, &block)
88
- block&.call(self)
156
+ def shutdown(wait: true, cancel_futures: false)
157
+ yield(self) if block_given?
89
158
  ensure
90
- unless check_and_set_shutdown!
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 synchronize(&)
110
- @mutex.synchronize(&)
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
- # Returns the current shutdown state,
114
- # then sets internal shutdown state to `true`.
115
- # This is all done atomically to avoid race conditions.
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
- @tasks.close
121
- return false
122
- end
123
- end
216
+ private_constant :SMALLEST_TIMEOUT
124
217
 
125
- # Only spawn a worker if one is needed.
126
- def maybe_spawn_worker
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
- # Always spawn a worker
132
- def spawn_worker # rubocop:disable Metrics/AbcSize
133
- thread = Thread.new do
134
- Thread.current.name = "#{@worker_name_prefix}_#{Thread.current.object_id}" unless @worker_name_prefix.empty?
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
- while (task = @tasks.pop(timeout: @reap_after))
137
- tfuture, tblock, targs, tkwargs = task
229
+ unless future.set_running_or_notify_cancel
230
+ synchronize { @futures.delete(future.object_id) }
231
+ next
232
+ end
138
233
 
139
- next unless tfuture.set_running_or_notify_cancel
234
+ future_object_id = future.object_id
140
235
 
141
- begin
142
- result = tblock.call(*targs, **tkwargs)
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
- tfuture.set_exception(e)
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
- tfuture.set_result(result)
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 { @pool.delete Thread.current }
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 `Thread` names.
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
- raise 'RactorExecutor instance is shutdown'
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, &block)
158
- block&.call(self)
152
+ def shutdown(wait: true, cancel_futures: false)
153
+ yield(self) if block_given?
159
154
  ensure
160
- unless check_and_set_shutdown!
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? || (@pool.empty? && @tasks.closed? && @tasks.empty?) }
221
+ wait_until { !@results_ports.empty? || (@tasks.closed? && @tasks.empty? && @pool.empty?) }
259
222
 
260
- [@results_ports.empty? && @pool.empty? && @tasks.closed? && @tasks.empty?, @results_ports.keys]
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
- name: new_worker_name
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 # rubocop:disable Metrics/ClassLength
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 `Thread` worker names.
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
- synchronize { @pool.size }
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, &block)
181
- block&.call(self)
173
+ def shutdown(wait: true, cancel_futures: false)
174
+ yield(self) if block_given?
182
175
  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
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
- 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
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
- # 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
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 # rubocop:disable Metrics/AbcSize
270
- thread = Thread.new do
271
- Thread.current.name = new_worker_name
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
- synchronize { @pool[Thread.current] = true }
229
+ @pool[Thread.current] = true
274
230
 
275
231
  tfuture, tblock, targs, tkwargs = task
276
232
  tfuture.complete(*targs, **tkwargs, &tblock)
277
233
 
278
- synchronize { @pool[Thread.current] = false }
234
+ @pool[Thread.current] = false
279
235
  end
280
236
  ensure
281
- synchronize { @pool.delete Thread.current }
237
+ @pool.delete(Thread.current)
282
238
  end
283
- synchronize { @pool[thread] ||= false }
239
+ @pool[thread] ||= false
284
240
  end
285
241
  end
286
242
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module AsyncFutures
4
4
  # Version of the library as a String instance.
5
- VERSION = '0.1.2'
5
+ VERSION = '0.2.0'
6
6
  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.1.2
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