async 2.12.1 → 2.21.3

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.
data/lib/async/task.rb CHANGED
@@ -7,24 +7,32 @@
7
7
  # Copyright, 2020, by Patrik Wenger.
8
8
  # Copyright, 2023, by Math Ieu.
9
9
 
10
- require 'fiber'
11
- require 'console/event/failure'
10
+ require "fiber"
11
+ require "console"
12
12
 
13
- require_relative 'node'
14
- require_relative 'condition'
13
+ require_relative "node"
14
+ require_relative "condition"
15
+
16
+ Fiber.attr_accessor :async_task
15
17
 
16
18
  module Async
17
19
  # Raised when a task is explicitly stopped.
18
20
  class Stop < Exception
21
+ # Used to defer stopping the current task until later.
19
22
  class Later
23
+ # Create a new stop later operation.
24
+ #
25
+ # @parameter task [Task] The task to stop later.
20
26
  def initialize(task)
21
27
  @task = task
22
28
  end
23
29
 
30
+ # @returns [Boolean] Whether the task is alive.
24
31
  def alive?
25
32
  true
26
33
  end
27
34
 
35
+ # Transfer control to the operation - this will stop the task.
28
36
  def transfer
29
37
  @task.stop
30
38
  end
@@ -32,16 +40,23 @@ module Async
32
40
  end
33
41
 
34
42
  # Raised if a timeout occurs on a specific Fiber. Handled gracefully by `Task`.
35
- # @public Since `stable-v1`.
43
+ # @public Since *Async v1*.
36
44
  class TimeoutError < StandardError
45
+ # Create a new timeout error.
46
+ #
47
+ # @parameter message [String] The error message.
37
48
  def initialize(message = "execution expired")
38
49
  super
39
50
  end
40
51
  end
41
52
 
42
- # @public Since `stable-v1`.
53
+ # @public Since *Async v1*.
43
54
  class Task < Node
55
+ # Raised when a child task is created within a task that has finished execution.
44
56
  class FinishedError < RuntimeError
57
+ # Create a new finished error.
58
+ #
59
+ # @parameter message [String] The error message.
45
60
  def initialize(message = "Cannot create child task within a task that has finished execution!")
46
61
  super
47
62
  end
@@ -52,6 +67,13 @@ module Async
52
67
  Fiber.scheduler.transfer
53
68
  end
54
69
 
70
+ # Run the given block of code in a task, asynchronously, in the given scheduler.
71
+ def self.run(scheduler, *arguments, **options, &block)
72
+ self.new(scheduler, **options, &block).tap do |task|
73
+ task.run(*arguments)
74
+ end
75
+ end
76
+
55
77
  # Create a new task.
56
78
  # @parameter reactor [Reactor] the reactor this task will run within.
57
79
  # @parameter parent [Task] the parent task.
@@ -72,14 +94,21 @@ module Async
72
94
  @defer_stop = nil
73
95
  end
74
96
 
97
+ # @returns [Scheduler] The scheduler for this task.
75
98
  def reactor
76
99
  self.root
77
100
  end
78
101
 
102
+ # @returns [Array(Thread::Backtrace::Location) | Nil] The backtrace of the task, if available.
79
103
  def backtrace(*arguments)
80
104
  @fiber&.backtrace(*arguments)
81
105
  end
82
106
 
107
+ # Annotate the task with a description.
108
+ #
109
+ # This will internally try to annotate the fiber if it is running, otherwise it will annotate the task itself.
110
+ #
111
+ # @parameter annotation [String] The description to annotate the task with.
83
112
  def annotate(annotation, &block)
84
113
  if @fiber
85
114
  @fiber.annotate(annotation, &block)
@@ -88,6 +117,7 @@ module Async
88
117
  end
89
118
  end
90
119
 
120
+ # @returns [Object] The annotation of the task.
91
121
  def annotation
92
122
  if @fiber
93
123
  @fiber.annotation
@@ -96,6 +126,7 @@ module Async
96
126
  end
97
127
  end
98
128
 
129
+ # @returns [String] A description of the task and it's current status.
99
130
  def to_s
100
131
  "\#<#{self.description} (#{@status})>"
101
132
  end
@@ -115,10 +146,10 @@ module Async
115
146
  Fiber.scheduler.yield
116
147
  end
117
148
 
118
- # @attr fiber [Fiber] The fiber which is being used for the execution of this task.
149
+ # @attribute [Fiber] The fiber which is being used for the execution of this task.
119
150
  attr :fiber
120
151
 
121
- # Whether the internal fiber is alive, i.e. it
152
+ # @returns [Boolean] Whether the internal fiber is alive, i.e. it is actively executing.
122
153
  def alive?
123
154
  @fiber&.alive?
124
155
  end
@@ -130,38 +161,50 @@ module Async
130
161
  super && @block.nil? && @fiber.nil?
131
162
  end
132
163
 
133
- # Whether the task is running.
134
- # @returns [Boolean]
164
+ # @returns [Boolean] Whether the task is running.
135
165
  def running?
136
166
  @status == :running
137
167
  end
138
168
 
169
+ # @returns [Boolean] Whether the task failed with an exception.
139
170
  def failed?
140
171
  @status == :failed
141
172
  end
142
173
 
143
- # The task has been stopped
174
+ # @returns [Boolean] Whether the task has been stopped.
144
175
  def stopped?
145
176
  @status == :stopped
146
177
  end
147
178
 
148
- # The task has completed execution and generated a result.
179
+ # @returns [Boolean] Whether the task has completed execution and generated a result.
149
180
  def completed?
150
181
  @status == :completed
151
182
  end
152
183
 
153
- alias complete? completed?
184
+ # Alias for {#completed?}.
185
+ def complete?
186
+ self.completed?
187
+ end
154
188
 
155
- # @attr status [Symbol] The status of the execution of the fiber, one of `:initialized`, `:running`, `:complete`, `:stopped` or `:failed`.
189
+ # @attribute [Symbol] The status of the execution of the task, one of `:initialized`, `:running`, `:complete`, `:stopped` or `:failed`.
156
190
  attr :status
157
191
 
158
192
  # Begin the execution of the task.
193
+ #
194
+ # @raises [RuntimeError] If the task is already running.
159
195
  def run(*arguments)
160
196
  if @status == :initialized
161
197
  @status = :running
162
198
 
163
199
  schedule do
164
200
  @block.call(self, *arguments)
201
+ rescue => error
202
+ # I'm not completely happy with this overhead, but the alternative is to not log anything which makes debugging extremely difficult. Maybe we can introduce a debug wrapper which adds extra logging.
203
+ if @finished.nil?
204
+ warn(self, "Task may have ended with unhandled exception.", exception: error)
205
+ end
206
+
207
+ raise
165
208
  end
166
209
  else
167
210
  raise RuntimeError, "Task already running!"
@@ -169,11 +212,25 @@ module Async
169
212
  end
170
213
 
171
214
  # Run an asynchronous task as a child of the current task.
215
+ #
216
+ # @public Since *Async v1*.
217
+ # @asynchronous May context switch immediately to the new task.
218
+ #
219
+ # @yields {|task| ...} in the context of the new task.
220
+ # @raises [FinishedError] If the task has already finished.
221
+ # @returns [Task] The child task.
172
222
  def async(*arguments, **options, &block)
173
223
  raise FinishedError if self.finished?
174
224
 
175
225
  task = Task.new(self, **options, &block)
176
226
 
227
+ # When calling an async block, we deterministically execute it until the first blocking operation. We don't *have* to do this - we could schedule it for later execution, but it's useful to:
228
+ #
229
+ # - Fail at the point of the method call where possible.
230
+ # - Execute determinstically where possible.
231
+ # - Avoid scheduler overhead if no blocking operation is performed.
232
+ #
233
+ # There are different strategies (greedy vs non-greedy). We are currently using a greedy strategy.
177
234
  task.run(*arguments)
178
235
 
179
236
  return task
@@ -215,15 +272,18 @@ module Async
215
272
  return stopped!
216
273
  end
217
274
 
218
- # If we are deferring stop...
219
- if @defer_stop == false
220
- # Don't stop now... but update the state so we know we need to stop later.
221
- @defer_stop = true
222
- return false
223
- end
224
-
225
275
  # If the fiber is alive, we need to stop it:
226
276
  if @fiber&.alive?
277
+ # As the task is now exiting, we want to ensure the event loop continues to execute until the task finishes.
278
+ self.transient = false
279
+
280
+ # If we are deferring stop...
281
+ if @defer_stop == false
282
+ # Don't stop now... but update the state so we know we need to stop later.
283
+ @defer_stop = true
284
+ return false
285
+ end
286
+
227
287
  if self.current?
228
288
  # If the fiber is current, and later is `true`, we need to schedule the fiber to be stopped later, as it's currently invoking `stop`:
229
289
  if later
@@ -238,7 +298,7 @@ module Async
238
298
  begin
239
299
  # There is a chance that this will stop the fiber that originally called stop. If that happens, the exception handling in `#stopped` will rescue the exception and re-raise it later.
240
300
  Fiber.scheduler.raise(@fiber, Stop)
241
- rescue FiberError
301
+ rescue FiberError => error
242
302
  # In some cases, this can cause a FiberError (it might be resumed already), so we schedule it to be stopped later:
243
303
  Fiber.scheduler.push(Stop::Later.new(self))
244
304
  end
@@ -256,26 +316,30 @@ module Async
256
316
  # If stop is invoked a second time, it will be immediately executed.
257
317
  #
258
318
  # @yields {} The block of code to execute.
259
- # @public Since `stable-v1`.
319
+ # @public Since *Async v1*.
260
320
  def defer_stop
261
321
  # Tri-state variable for controlling stop:
262
322
  # - nil: defer_stop has not been called.
263
323
  # - false: defer_stop has been called and we are not stopping.
264
324
  # - true: defer_stop has been called and we will stop when exiting the block.
265
325
  if @defer_stop.nil?
266
- # If we are not deferring stop already, we can defer it now:
267
- @defer_stop = false
268
-
269
326
  begin
327
+ # If we are not deferring stop already, we can defer it now:
328
+ @defer_stop = false
329
+
270
330
  yield
271
331
  rescue Stop
272
332
  # If we are exiting due to a stop, we shouldn't try to invoke stop again:
273
333
  @defer_stop = nil
274
334
  raise
275
335
  ensure
336
+ defer_stop = @defer_stop
337
+
338
+ # We need to ensure the state is reset before we exit the block:
339
+ @defer_stop = nil
340
+
276
341
  # If we were asked to stop, we should do so now:
277
- if @defer_stop
278
- @defer_stop = nil
342
+ if defer_stop
279
343
  raise Stop, "Stopping current task (was deferred)!"
280
344
  end
281
345
  end
@@ -285,25 +349,35 @@ module Async
285
349
  end
286
350
  end
287
351
 
352
+ # @returns [Boolean] Whether stop has been deferred.
353
+ def stop_deferred?
354
+ @defer_stop
355
+ end
356
+
288
357
  # Lookup the {Task} for the current fiber. Raise `RuntimeError` if none is available.
289
358
  # @returns [Task]
290
359
  # @raises[RuntimeError] If task was not {set!} for the current fiber.
291
360
  def self.current
292
- Thread.current[:async_task] or raise RuntimeError, "No async task available!"
361
+ Fiber.current.async_task or raise RuntimeError, "No async task available!"
293
362
  end
294
363
 
295
364
  # Check if there is a task defined for the current fiber.
296
- # @returns [Task | Nil]
365
+ # @returns [Interface(:async) | Nil]
297
366
  def self.current?
298
- Thread.current[:async_task]
367
+ Fiber.current.async_task
299
368
  end
300
369
 
370
+ # @returns [Boolean] Whether this task is the currently executing task.
301
371
  def current?
302
372
  Fiber.current.equal?(@fiber)
303
373
  end
304
374
 
305
375
  private
306
376
 
377
+ def warn(...)
378
+ Console.warn(...)
379
+ end
380
+
307
381
  # Finish the current task, moving any children to the parent.
308
382
  def finish!
309
383
  # Don't hold references to the fiber or block after the task has finished:
@@ -326,21 +400,10 @@ module Async
326
400
  @status = :completed
327
401
  end
328
402
 
329
- # This is a very tricky aspect of tasks to get right. I've modelled it after `Thread` but it's slightly different in that the exception can propagate back up through the reactor. If the user writes code which raises an exception, that exception should always be visible, i.e. cause a failure. If it's not visible, such code fails silently and can be very difficult to debug.
330
- def failed!(exception = false, propagate = true)
403
+ # State transition into the failed state.
404
+ def failed!(exception = false)
331
405
  @result = exception
332
406
  @status = :failed
333
-
334
- if exception
335
- if propagate
336
- raise exception
337
- elsif @finished.nil?
338
- # If no one has called wait, we log this as a warning:
339
- Console::Event::Failure.for(exception).emit(self, "Task may have ended with unhandled exception.", severity: :warn)
340
- else
341
- Console::Event::Failure.for(exception).emit(self, severity: :debug)
342
- end
343
- end
344
407
  end
345
408
 
346
409
  def stopped!
@@ -371,30 +434,26 @@ module Async
371
434
 
372
435
  def schedule(&block)
373
436
  @fiber = Fiber.new(annotation: self.annotation) do
374
- set!
375
-
376
437
  begin
377
438
  completed!(yield)
378
- # Console.debug(self) {"Task was completed with #{@children.size} children!"}
379
439
  rescue Stop
380
440
  stopped!
381
441
  rescue StandardError => error
382
- failed!(error, false)
442
+ failed!(error)
383
443
  rescue Exception => exception
384
- failed!(exception, true)
444
+ failed!(exception)
445
+
446
+ # This is a critical failure, we should stop the reactor:
447
+ raise
385
448
  ensure
386
449
  # Console.info(self) {"Task ensure $! = #{$!} with #{@children&.size.inspect} children!"}
387
450
  finish!
388
451
  end
389
452
  end
390
453
 
454
+ @fiber.async_task = self
455
+
391
456
  self.root.resume(@fiber)
392
457
  end
393
-
394
- # Set the current fiber's `:async_task` to this task.
395
- def set!
396
- # This is actually fiber-local:
397
- Thread.current[:async_task] = self
398
- end
399
458
  end
400
459
  end
@@ -1,17 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2021-2022, by Samuel Williams.
4
+ # Copyright, 2021-2024, by Samuel Williams.
5
5
 
6
- require_relative 'condition'
6
+ require_relative "condition"
7
7
 
8
8
  module Async
9
+ # A synchronization primitive that allows one task to wait for another task to resolve a value.
9
10
  class Variable
11
+ # Create a new variable.
12
+ #
13
+ # @parameter condition [Condition] The condition to use for synchronization.
10
14
  def initialize(condition = Condition.new)
11
15
  @condition = condition
12
16
  @value = nil
13
17
  end
14
18
 
19
+ # Resolve the value.
20
+ #
21
+ # Signals all waiting tasks.
22
+ #
23
+ # @parameter value [Object] The value to resolve.
15
24
  def resolve(value = true)
16
25
  @value = value
17
26
  condition = @condition
@@ -22,17 +31,29 @@ module Async
22
31
  condition.signal(value)
23
32
  end
24
33
 
34
+ # Alias for {#resolve}.
35
+ def value=(value)
36
+ self.resolve(value)
37
+ end
38
+
39
+ # Whether the value has been resolved.
40
+ #
41
+ # @returns [Boolean] Whether the value has been resolved.
25
42
  def resolved?
26
43
  @condition.nil?
27
44
  end
28
45
 
29
- def value
46
+ # Wait for the value to be resolved.
47
+ #
48
+ # @returns [Object] The resolved value.
49
+ def wait
30
50
  @condition&.wait
31
51
  return @value
32
52
  end
33
53
 
34
- def wait
35
- self.value
54
+ # Alias for {#wait}.
55
+ def value
56
+ self.wait
36
57
  end
37
58
  end
38
59
  end
data/lib/async/version.rb CHANGED
@@ -4,5 +4,5 @@
4
4
  # Copyright, 2017-2024, by Samuel Williams.
5
5
 
6
6
  module Async
7
- VERSION = "2.12.1"
7
+ VERSION = "2.21.3"
8
8
  end
data/lib/async/waiter.rb CHANGED
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2022, by Samuel Williams.
4
+ # Copyright, 2022-2024, by Samuel Williams.
5
+ # Copyright, 2024, by Patrik Wenger.
5
6
 
6
7
  module Async
7
8
  # A composable synchronization primitive, which allows one task to wait for a number of other tasks to complete. It can be used in conjunction with {Semaphore} and/or {Barrier}.
8
9
  class Waiter
10
+ # Create a waiter instance.
11
+ #
12
+ # @parameter parent [Interface(:async) | Nil] The parent task to use for asynchronous operations.
13
+ # @parameter finished [Async::Condition] The condition to signal when a task completes.
9
14
  def initialize(parent: nil, finished: Async::Condition.new)
10
15
  @finished = finished
11
16
  @done = []
@@ -15,8 +20,8 @@ module Async
15
20
 
16
21
  # Execute a child task and add it to the waiter.
17
22
  # @asynchronous Executes the given block concurrently.
18
- def async(parent: (@parent or Task.current), &block)
19
- parent.async do |task|
23
+ def async(parent: (@parent or Task.current), **options, &block)
24
+ parent.async(**options) do |task|
20
25
  yield(task)
21
26
  ensure
22
27
  @done << task
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2024, by Samuel Williams.
5
+
6
+ require "etc"
7
+
8
+ module Async
9
+ # A simple work pool that offloads work to a background thread.
10
+ #
11
+ # @private
12
+ class WorkerPool
13
+ # Used to augment the scheduler to add support for blocking operations.
14
+ module BlockingOperationWait
15
+ # Wait for the given work to be executed.
16
+ #
17
+ # @public Since *Async v2.19* and *Ruby v3.4*.
18
+ # @asynchronous May be non-blocking.
19
+ #
20
+ # @parameter work [Proc] The work to execute on a background thread.
21
+ # @returns [Object] The result of the work.
22
+ def blocking_operation_wait(work)
23
+ @worker_pool.call(work)
24
+ end
25
+ end
26
+
27
+ # Execute the given work in a background thread.
28
+ class Promise
29
+ # Create a new promise.
30
+ #
31
+ # @parameter work [Proc] The work to be done.
32
+ def initialize(work)
33
+ @work = work
34
+ @state = :pending
35
+ @value = nil
36
+ @guard = ::Mutex.new
37
+ @condition = ::ConditionVariable.new
38
+ @thread = nil
39
+ end
40
+
41
+ # Execute the work and resolve the promise.
42
+ def call
43
+ work = nil
44
+
45
+ @guard.synchronize do
46
+ @thread = ::Thread.current
47
+
48
+ return unless work = @work
49
+ end
50
+
51
+ resolve(work.call)
52
+ rescue Exception => error
53
+ reject(error)
54
+ end
55
+
56
+ private def resolve(value)
57
+ @guard.synchronize do
58
+ @work = nil
59
+ @thread = nil
60
+ @value = value
61
+ @state = :resolved
62
+ @condition.broadcast
63
+ end
64
+ end
65
+
66
+ private def reject(error)
67
+ @guard.synchronize do
68
+ @work = nil
69
+ @thread = nil
70
+ @value = error
71
+ @state = :failed
72
+ @condition.broadcast
73
+ end
74
+ end
75
+
76
+ # Cancel the work and raise an exception in the background thread.
77
+ def cancel
78
+ return unless @work
79
+
80
+ @guard.synchronize do
81
+ @work = nil
82
+ @state = :cancelled
83
+ @thread&.raise(Interrupt)
84
+ end
85
+ end
86
+
87
+ # Wait for the work to be done.
88
+ #
89
+ # @returns [Object] The result of the work.
90
+ def wait
91
+ @guard.synchronize do
92
+ while @state == :pending
93
+ @condition.wait(@guard)
94
+ end
95
+
96
+ if @state == :failed
97
+ raise @value
98
+ else
99
+ return @value
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ # A background worker thread.
106
+ class Worker
107
+ # Create a new worker.
108
+ def initialize
109
+ @work = ::Thread::Queue.new
110
+ @thread = ::Thread.new(&method(:run))
111
+ end
112
+
113
+ # Execute work until the queue is closed.
114
+ def run
115
+ while work = @work.pop
116
+ work.call
117
+ end
118
+ end
119
+
120
+ # Close the worker thread.
121
+ def close
122
+ if thread = @thread
123
+ @thread = nil
124
+ thread.kill
125
+ end
126
+ end
127
+
128
+ # Call the work and notify the scheduler when it is done.
129
+ def call(work)
130
+ promise = Promise.new(work)
131
+
132
+ @work.push(promise)
133
+
134
+ begin
135
+ return promise.wait
136
+ ensure
137
+ promise.cancel
138
+ end
139
+ end
140
+ end
141
+
142
+ # Create a new work pool.
143
+ #
144
+ # @parameter size [Integer] The number of threads to use.
145
+ def initialize(size: Etc.nprocessors)
146
+ @ready = ::Thread::Queue.new
147
+
148
+ size.times do
149
+ @ready.push(Worker.new)
150
+ end
151
+ end
152
+
153
+ # Close the work pool. Kills all outstanding work.
154
+ def close
155
+ if ready = @ready
156
+ @ready = nil
157
+ ready.close
158
+
159
+ while worker = ready.pop
160
+ worker.close
161
+ end
162
+ end
163
+ end
164
+
165
+ # Offload work to a thread.
166
+ #
167
+ # @parameter work [Proc] The work to be done.
168
+ def call(work)
169
+ if ready = @ready
170
+ worker = ready.pop
171
+
172
+ begin
173
+ worker.call(work)
174
+ ensure
175
+ ready.push(worker)
176
+ end
177
+ else
178
+ raise RuntimeError, "No worker available!"
179
+ end
180
+ end
181
+ end
182
+ end
data/lib/async/wrapper.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2022, by Samuel Williams.
4
+ # Copyright, 2017-2024, by Samuel Williams.
5
5
  # Copyright, 2017, by Kent Gruber.
6
6
 
7
+ warn "Async::Wrapper is deprecated and will be removed on 2025-03-31. Please use native interfaces instead.", uplevel: 1, category: :deprecated
8
+
7
9
  module Async
8
10
  # Represents an asynchronous IO within a reactor.
9
11
  # @deprecated With no replacement. Prefer native interfaces.
@@ -23,6 +25,7 @@ module Async
23
25
 
24
26
  attr_accessor :reactor
25
27
 
28
+ # Dup the underlying IO.
26
29
  def dup
27
30
  self.class.new(@io.dup)
28
31
  end
@@ -51,11 +54,12 @@ module Async
51
54
  @io.to_io.wait(::IO::READABLE|::IO::WRITABLE|::IO::PRIORITY, timeout) or raise TimeoutError
52
55
  end
53
56
 
54
- # Close the io and monitor.
57
+ # Close the underlying IO.
55
58
  def close
56
59
  @io.close
57
60
  end
58
61
 
62
+ # Whether the underlying IO is closed.
59
63
  def closed?
60
64
  @io.closed?
61
65
  end
data/lib/async.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2022, by Samuel Williams.
4
+ # Copyright, 2017-2024, by Samuel Williams.
5
5
  # Copyright, 2020, by Salim Semaoune.
6
6
 
7
7
  require_relative "async/version"
@@ -10,5 +10,6 @@ require_relative "async/reactor"
10
10
  require_relative "kernel/async"
11
11
  require_relative "kernel/sync"
12
12
 
13
+ # Asynchronous programming framework.
13
14
  module Async
14
15
  end