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.
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'future'
4
+
5
+ require 'timeout'
6
+ require 'openssl'
7
+ require 'stringio'
8
+ require 'set' # rubocop:disable Lint/RedundantRequireStatement
9
+
10
+ module AsyncFutures
11
+ # This is a simple example mixin module
12
+ # to add async IO to the `IO` and `OpenSSL::SSLSocket` classes.
13
+ # You can also `include` this in other classes
14
+ # so long as they have the methods `write_nonblock` and `read_nonblock`
15
+ # and they behave the same way as `IO` and/or `OpenSSL::SSLSocket`.
16
+ #
17
+ # All reads and writes are done on (at least) one background worker thread.
18
+ #
19
+ # This is not the most efficient implementation.
20
+ # It is just meant to be an example
21
+ # of how one can use the `Future` class
22
+ # outside of an `Executor` implementation.
23
+ #
24
+ # It's only real efficiencies, so to speak,
25
+ # are that it doesn't use a thread per future
26
+ # (which would potentially use up a lot of memory),
27
+ # nor do futures need to wait until a thread opens up on a worker pool
28
+ # (which would block newer IO work until older IO work completely finished).
29
+ # Instead work is picked up immediately
30
+ # on at least one background worker thread,
31
+ # and attempts to read/write are started immediately
32
+ # via `read_nonblock` and `write_nonblock`.
33
+ # If `read_nonblock`/`write_nonblock` cannot proceed
34
+ # (because they would block)
35
+ # for any particular `IO` object
36
+ # then the next operation for the next `IO` object is attempted,
37
+ # and so forth,
38
+ # until all the work is completed (or a timeout happens).
39
+ #
40
+ # However, all of this is accomplished via a simplistic, unoptimized busy loop.
41
+ # This is less than ideal.
42
+ # There are some simple sleeps and timeouts added
43
+ # to avoid completely eating up the CPU,
44
+ # but this is still a very naive approach.
45
+ #
46
+ # A better implementation would utilize higher performance OS specific features
47
+ # like FreeBSD's kqueue/aio or Linux's epoll/io_uring.
48
+ # However, the logic for integrating these
49
+ # is beyond the scope of this example code.
50
+ #
51
+ # This could probably be done using the FFI library fiddle,
52
+ # which is bundled with ruby,
53
+ # so it wouldn't need to reach outside the standard library.
54
+ # However I have a very good reason for not doing that right now:
55
+ # I don't want to.
56
+ module IOAsync
57
+ # Return an incomplete future
58
+ # that will eventually contain an integer with the number of bytes written
59
+ # or an exception if the string could not be written for some reason.
60
+ #
61
+ # The `string` argument is written in a nonblocking fashion
62
+ # on a background worker thread.
63
+ #
64
+ # The optional `timeout` argument
65
+ # causes the work to finish the future exceptionally with `Timeout::Error`
66
+ # if it takes longer than `timeout` seconds to complete.
67
+ # This is used to avoid having background work that spins forever
68
+ # on IO that may never complete.
69
+ # If `nil` or no value is given, this means no timeout
70
+ # (i.e. potentially spin indefinitely).
71
+ #
72
+ # This should *not* be confused with the `Timeout::Error` raised via the
73
+ # `timeout` argument on `Future.result` and `Future.exception`.
74
+ # If it matters for your purposes to differentiate between the two,
75
+ # you can do something like the following:
76
+ #
77
+ # ```ruby
78
+ # # `join` returns `nil` on timeout
79
+ # result = if future.join(1.0)
80
+ # # If `Timeout::Error` is raised here,
81
+ # # it is from the `timeout` parameter to the `*_async` method.
82
+ # future.result
83
+ # else
84
+ # raise Timeout::Error.new('Timed out on `join`')
85
+ # end
86
+ # ```
87
+ #
88
+ # The optional `sleep_timeout` keyword argument
89
+ # is used to determine how quickly the worker thread
90
+ # stops polling the input work queue
91
+ # and how much sleep time happens between failed nonblocking IO attempts.
92
+ # It defaults to 1ms.
93
+ # If existing worker(s) have already been spawned,
94
+ # then this argument isn't used.
95
+ #
96
+ # If the process shuts down before the future can be fully completed,
97
+ # the work may be abandoned even if it partially completed.
98
+ def write_async(string, timeout = nil, sleep_timeout: 0.001) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
99
+ Future.new.tap do |ftr|
100
+ clock_timeout = timeout && (Time.now.to_f + timeout)
101
+
102
+ work_proc = proc do
103
+ # :nocov:
104
+ break ftr unless ftr.set_running_or_notify_cancel(set_context: true)
105
+ # :nocov:
106
+
107
+ to_err = Timeout::Error.new('execution expired')
108
+ all_written = 0
109
+
110
+ loop do
111
+ cur_to = timeout && (Time.now.to_f - clock_timeout)
112
+ break ftr.tap { ftr.set_exception(to_err) } unless timeout.nil? || cur_to.positive?
113
+
114
+ bytes_written = write_nonblock(string)
115
+ string = string[bytes_written..nil]
116
+ all_written += bytes_written
117
+ break ftr.tap { ftr.set_result(all_written) } if string.empty?
118
+ rescue IO::WaitReadable, IO::WaitWritable, Errno::EINTR
119
+ Fiber.yield nil
120
+ retry
121
+ rescue Exception => e # rubocop:disable Lint/RescueException
122
+ break ftr.tap { ftr.set_exception(e) }
123
+ end
124
+ end
125
+
126
+ io_async_queue.push(work_proc)
127
+ maybe_spawn_worker(sleep_timeout)
128
+ end
129
+ end
130
+
131
+ # Return an incomplete future
132
+ # that will eventually contain the string value read from the IO object
133
+ # or an exception if the IO object could not be read from for some reason.
134
+ #
135
+ # A string up to `maxlen` in length is read in a nonblocking fashion
136
+ # on a background worker thread.
137
+ #
138
+ # The optional `timeout` argument
139
+ # causes the work to finish the future exceptionally with `Timeout::Error`
140
+ # if it takes longer than `timeout` seconds to complete.
141
+ # This is used to avoid having background work that spins forever
142
+ # on IO that may never complete.
143
+ # If `nil` or no value is given, this means no timeout
144
+ # (i.e. potentially spin indefinitely).
145
+ #
146
+ # This should *not* be confused with the `Timeout::Error` raised via the
147
+ # `timeout` argument on `Future.result` and `Future.exception`.
148
+ # If it matters for your purposes to differentiate between the two,
149
+ # you can do something like the following:
150
+ #
151
+ # ```ruby
152
+ # # `join` returns `nil` on timeout
153
+ # result = if future.join(1.0)
154
+ # # If `Timeout::Error` is raised here,
155
+ # # it is from the `timeout` parameter to the `*_async` method.
156
+ # future.result
157
+ # else
158
+ # raise Timeout::Error.new('Timed out on `join`')
159
+ # end
160
+ # ```
161
+ #
162
+ # The optional `sleep_timeout` keyword argument
163
+ # is used to determine how quickly the worker thread
164
+ # stops polling the input work queue
165
+ # and how much sleep time happens between failed nonblocking IO attempts.
166
+ # It defaults to 1ms.
167
+ # If existing worker(s) have already been spawned,
168
+ # then this argument isn't used.
169
+ #
170
+ # If the process shuts down before the future can be fully completed,
171
+ # the work may be abandoned even if it partially completed.
172
+ def read_async(maxlen, timeout = nil, sleep_timeout: 0.001) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
173
+ Future.new.tap do |ftr|
174
+ clock_timeout = timeout && (Time.now.to_f + timeout)
175
+
176
+ work_proc = proc do
177
+ # :nocov:
178
+ break ftr unless ftr.set_running_or_notify_cancel(set_context: true)
179
+ # :nocov:
180
+
181
+ to_err = Timeout::Error.new('execution expired')
182
+ to_read_length = maxlen
183
+ retrieved_str = String.new
184
+
185
+ loop do
186
+ cur_to = timeout && (Time.now.to_f - clock_timeout)
187
+ break ftr.tap { ftr.set_exception(to_err) } unless timeout.nil? || cur_to.positive?
188
+
189
+ retrieved_str << read_nonblock(to_read_length)
190
+ to_read_length = maxlen - retrieved_str.size
191
+ break ftr.tap { ftr.set_result(retrieved_str) } if to_read_length.zero?
192
+ rescue IO::WaitReadable, IO::WaitWritable, Errno::EINTR
193
+ Fiber.yield nil
194
+ retry
195
+ rescue EOFError
196
+ break ftr.tap { ftr.set_result(retrieved_str) }
197
+ rescue Exception => e # rubocop:disable Lint/RescueException
198
+ break ftr.tap { ftr.set_exception(e) }
199
+ end
200
+ end
201
+
202
+ io_async_queue.push(work_proc)
203
+ maybe_spawn_worker(sleep_timeout)
204
+ end
205
+ end
206
+
207
+ private
208
+
209
+ # This method will spawn *at least* one worker thread.
210
+ # It *may* spawn more than one worker thread
211
+ # based on submission thread timing,
212
+ # but that is ok,
213
+ # because they will all eventually reap once they run out of work
214
+ # and the individual work is grabbed exclusively per thread
215
+ # via the thread safe queue.
216
+ #
217
+ # If work starts up again after reaping all threads,
218
+ # then new worker thread(s) will be spawned again.
219
+ def maybe_spawn_worker(sleep_timeout)
220
+ Ractor[:io_async_worker] ||= Thread.new do
221
+ worker_fibers = Set.new
222
+
223
+ while (fproc = io_async_queue.pop(timeout: sleep_timeout))
224
+ worker_fibers.add(Fiber.new(&fproc))
225
+ worker_fibers.reject!(&:resume)
226
+ end
227
+
228
+ # We need to unset the worker thread
229
+ # since we're no longer polling the input queue.
230
+ #
231
+ # If more work comes in,
232
+ # new worker thread(s) need to be spawned.
233
+ Ractor[:io_async_worker] = nil
234
+
235
+ # FIXME: if we have IO that never finishes,
236
+ # this will create zombie threads that busy loop and never complete.
237
+ until worker_fibers.empty?
238
+ # Sleep when no fiber in the set completes
239
+ sleep(sleep_timeout) unless worker_fibers.reject!(&:resume)
240
+ end
241
+ end
242
+ end
243
+
244
+ GLOBAL_MUTEX = Thread::Mutex.new
245
+
246
+ private_constant :GLOBAL_MUTEX
247
+
248
+ def io_async_queue
249
+ GLOBAL_MUTEX.synchronize do
250
+ Ractor[:io_async_queue] ||= Thread::Queue.new
251
+ end
252
+ end
253
+ end
254
+
255
+ # Simple mixin for sync IO with an async interface.
256
+ module IOSync
257
+ # Return a completed future
258
+ # containing an integer with the number of bytes written.
259
+ #
260
+ # This exists for classes such as `StringIO` to maintain compatibility
261
+ # with classes with true nonblocking methods (such as `IO`).
262
+ #
263
+ # There is no performance benefit
264
+ # to calling this instead of directly calling `write`.
265
+ # In fact,
266
+ # there may be a slight performance degradation
267
+ # because of the added overhead of instantiating
268
+ # and completing a `Future` object.
269
+ #
270
+ # You should only use this method if you are dealing with a mix
271
+ # of `IO`, `OpenSSL::SSLSocket`, and/or `StringIO` objects
272
+ # and want to interact with them identically in a nonblocking manner.
273
+ # Or you may want to use this method with `StringIO`
274
+ # as a type of mock object for testing
275
+ # in place of real `IO` or `OpenSSL::SSLSocket` objects.
276
+ def write_async(string, *args, **kwargs) # rubocop:disable Lint/UnusedMethodArgument
277
+ Future.new.tap do |ftr|
278
+ ftr.complete(string, &method(:write))
279
+ end
280
+ end
281
+
282
+ # Return a completed future
283
+ # containing a string up to `maxlen` bytes long.
284
+ #
285
+ # This exists for classes such as `StringIO` to maintain compatibility
286
+ # with classes with true nonblocking methods (such as `IO`).
287
+ #
288
+ # There is no performance benefit
289
+ # to calling this instead of directly calling `read`.
290
+ # In fact,
291
+ # there may be a slight performance degradation
292
+ # because of the added overhead of instantiating
293
+ # and completing a `Future` object.
294
+ #
295
+ # You should only use this method if you are dealing with a mix
296
+ # of `IO`, `OpenSSL::SSLSocket`, and/or `StringIO` objects
297
+ # and want to interact with them identically in a nonblocking manner.
298
+ # Or you may want to use this method with `StringIO`
299
+ # as a type of mock object for testing
300
+ # in place of real `IO` or `OpenSSL::SSLSocket` objects.
301
+ def read_async(maxlen, *args, **kwargs) # rubocop:disable Lint/UnusedMethodArgument
302
+ Future.new.tap do |ftr|
303
+ ftr.complete(maxlen, &method(:read))
304
+ end
305
+ end
306
+ end
307
+ end
308
+
309
+ IO.include AsyncFutures::IOAsync
310
+
311
+ OpenSSL::SSL::SSLSocket.include AsyncFutures::IOAsync
312
+
313
+ StringIO.include AsyncFutures::IOSync
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module AsyncFutures # rubocop:disable Style/Documentation
6
+ class << self
7
+ # Configurable logger for the library. All calls assume the standard logger interface.
8
+ #
9
+ # Defaults to being unset (i.e. `nil`).
10
+ attr_accessor :logger
11
+ end
12
+ end
@@ -0,0 +1,155 @@
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 Process forking
10
+ # that uses up to `max_workers` to execute calls concurrently.
11
+ #
12
+ # `ProcessExecutor` specific submission considerations:
13
+ #
14
+ # For `ProcessExecutor` the tasks are never run immediately upon submission.
15
+ # They are placed into a work queue
16
+ # to be picked up later.
17
+ #
18
+ # Process workers are not reused for work.
19
+ # Each task gets a freshly forked process.
20
+ # This is because marshalling anonymous blocks is not trivial;
21
+ # it is simpler to just fork after the block closure has been defined.
22
+ # Use `ThreadExecutor` or `RactorExecutor`
23
+ # for `Executor` implementations that support worker reuse.
24
+ #
25
+ # Consequently, this executor is only really useful for expensive calculations
26
+ # where the startup time for a process
27
+ # 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.
30
+ #
31
+ # This does _not_ guarantee
32
+ # that any particular task will be run concurrently
33
+ # with any other particular task;
34
+ # that is dependent on how many worker threads and tasks there are
35
+ # at any given point in time.
36
+ class ProcessExecutor
37
+ include Executor
38
+
39
+ # Create a new `ProcessExecutor`.
40
+ #
41
+ # Uses a pool of up to `max_workers`
42
+ # to execute tasks concurrently.
43
+ # If no value is given for `max_workers`
44
+ # it will default to `[32, Etc.nprocessors + 4].min`.
45
+ # Workers are spawned lazily as needed
46
+ # when tasks are added to the work queue.
47
+ #
48
+ # The parameter `worker_name_prefix` can be used
49
+ # to optionally add a prefix to generated `Thread` names.
50
+ #
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)
57
+ @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
62
+ @pool = Set.new
63
+
64
+ at_exit { shutdown(wait: false) }
65
+ end
66
+
67
+ # Asynchronously submit a task for execution.
68
+ #
69
+ # See `AsyncFutures::Executor.submit` method for full documentation.
70
+ def submit(*args, **kwargs, &block)
71
+ raise ArgumentError.new('No block given') unless block
72
+ raise 'ProcessExecutor instance is shutdown' if @tasks.closed?
73
+
74
+ Future.new.tap do |future|
75
+ @tasks.push([future, block, args, kwargs])
76
+ maybe_spawn_worker
77
+ end
78
+ end
79
+
80
+ alias submit_concurrent submit
81
+
82
+ public :map
83
+
84
+ # Shutdown `ProcessExecutor` instance.
85
+ #
86
+ # See `AsyncFutures::Executor.shutdown` for full documentation.
87
+ def shutdown(wait: true, cancel_futures: false, &block)
88
+ block&.call(self)
89
+ ensure
90
+ unless check_and_set_shutdown!
91
+ if cancel_futures
92
+ while (task = @tasks.pop)
93
+ future = task[0]
94
+ future.cancel
95
+ end
96
+ end
97
+
98
+ if wait
99
+ synchronize { @pool.dup }.each do |thread|
100
+ thread.join
101
+ synchronize { @pool.delete(thread) }
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def synchronize(&)
110
+ @mutex.synchronize(&)
111
+ end
112
+
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?
119
+
120
+ @tasks.close
121
+ return false
122
+ end
123
+ end
124
+
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
129
+ end
130
+
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?
135
+
136
+ while (task = @tasks.pop(timeout: @reap_after))
137
+ tfuture, tblock, targs, tkwargs = task
138
+
139
+ next unless tfuture.set_running_or_notify_cancel
140
+
141
+ begin
142
+ result = tblock.call(*targs, **tkwargs)
143
+ rescue Exception => e # rubocop:disable Lint/RescueException
144
+ tfuture.set_exception(e)
145
+ else
146
+ tfuture.set_result(result)
147
+ end
148
+ end
149
+ ensure
150
+ synchronize { @pool.delete Thread.current }
151
+ end
152
+ synchronize { @pool.add thread }
153
+ end
154
+ end
155
+ end