async 2.17.0 → 2.32.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/context/best-practices.md +188 -0
  4. data/context/debugging.md +63 -0
  5. data/context/getting-started.md +177 -0
  6. data/context/index.yaml +29 -0
  7. data/context/scheduler.md +109 -0
  8. data/context/tasks.md +448 -0
  9. data/context/thread-safety.md +651 -0
  10. data/lib/async/barrier.md +1 -2
  11. data/lib/async/barrier.rb +35 -12
  12. data/lib/async/clock.rb +11 -2
  13. data/lib/async/condition.md +1 -1
  14. data/lib/async/condition.rb +18 -34
  15. data/lib/async/console.rb +42 -0
  16. data/lib/async/deadline.rb +70 -0
  17. data/lib/async/idler.rb +2 -1
  18. data/lib/async/limited_queue.rb +13 -0
  19. data/lib/async/list.rb +16 -8
  20. data/lib/async/node.rb +5 -3
  21. data/lib/async/notification.rb +13 -9
  22. data/lib/async/priority_queue.rb +253 -0
  23. data/lib/async/promise.rb +188 -0
  24. data/lib/async/queue.rb +70 -82
  25. data/lib/async/reactor.rb +4 -2
  26. data/lib/async/scheduler.rb +233 -54
  27. data/lib/async/semaphore.rb +3 -3
  28. data/lib/async/stop.rb +82 -0
  29. data/lib/async/task.rb +111 -81
  30. data/lib/async/timeout.rb +88 -0
  31. data/lib/async/variable.rb +15 -4
  32. data/lib/async/version.rb +2 -2
  33. data/lib/async/waiter.rb +6 -1
  34. data/lib/kernel/async.rb +1 -1
  35. data/lib/kernel/sync.rb +14 -5
  36. data/lib/metrics/provider/async/task.rb +20 -0
  37. data/lib/metrics/provider/async.rb +6 -0
  38. data/lib/traces/provider/async/barrier.rb +17 -0
  39. data/lib/traces/provider/async/task.rb +40 -0
  40. data/lib/traces/provider/async.rb +7 -0
  41. data/license.md +8 -1
  42. data/readme.md +50 -7
  43. data/releases.md +357 -0
  44. data.tar.gz.sig +0 -0
  45. metadata +61 -20
  46. metadata.gz.sig +0 -0
  47. data/lib/async/waiter.md +0 -50
  48. data/lib/async/wrapper.rb +0 -65
data/lib/async/barrier.rb CHANGED
@@ -1,21 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2025, by Samuel Williams.
5
5
 
6
- require_relative 'list'
7
- require_relative 'task'
6
+ require_relative "list"
7
+ require_relative "task"
8
+ require_relative "queue"
8
9
 
9
10
  module Async
10
11
  # A general purpose synchronisation primitive, which allows one task to wait for a number of other tasks to complete. It can be used in conjunction with {Semaphore}.
11
12
  #
12
- # @public Since `stable-v1`.
13
+ # @public Since *Async v1*.
13
14
  class Barrier
14
15
  # Initialize the barrier.
15
16
  # @parameter parent [Task | Semaphore | Nil] The parent for holding any children tasks.
16
- # @public Since `stable-v1`.
17
+ # @public Since *Async v1*.
17
18
  def initialize(parent: nil)
18
19
  @tasks = List.new
20
+ @finished = Queue.new
19
21
 
20
22
  @parent = parent
21
23
  end
@@ -41,11 +43,17 @@ module Async
41
43
  # Execute a child task and add it to the barrier.
42
44
  # @asynchronous Executes the given block concurrently.
43
45
  def async(*arguments, parent: (@parent or Task.current), **options, &block)
44
- task = parent.async(*arguments, **options, &block)
46
+ raise "Barrier is stopped!" if @finished.closed?
45
47
 
46
- @tasks.append(TaskNode.new(task))
48
+ waiting = nil
47
49
 
48
- return task
50
+ parent.async(*arguments, **options) do |task, *arguments|
51
+ waiting = TaskNode.new(task)
52
+ @tasks.append(waiting)
53
+ block.call(task, *arguments)
54
+ ensure
55
+ @finished.signal(waiting) unless @finished.closed?
56
+ end
49
57
  end
50
58
 
51
59
  # Whether there are any tasks being held by the barrier.
@@ -55,14 +63,27 @@ module Async
55
63
  end
56
64
 
57
65
  # Wait for all tasks to complete by invoking {Task#wait} on each waiting task, which may raise an error. As long as the task has completed, it will be removed from the barrier.
66
+ #
67
+ # @yields {|task| ...} If a block is given, the unwaited task is yielded. You must invoke {Task#wait} yourself. In addition, you may `break` if you have captured enough results.
68
+ #
58
69
  # @asynchronous Will wait for tasks to finish executing.
59
70
  def wait
60
- @tasks.each do |waiting|
71
+ while !@tasks.empty?
72
+ # Wait for a task to finish (we get the task node):
73
+ return unless waiting = @finished.wait
74
+
75
+ # Remove the task as it is now finishing:
76
+ @tasks.remove?(waiting)
77
+
78
+ # Get the task:
61
79
  task = waiting.task
62
- begin
80
+
81
+ # If a block is given, the user can implement their own behaviour:
82
+ if block_given?
83
+ yield task
84
+ else
85
+ # Wait for it to either complete or raise an error:
63
86
  task.wait
64
- ensure
65
- @tasks.remove?(waiting) unless task.alive?
66
87
  end
67
88
  end
68
89
  end
@@ -73,6 +94,8 @@ module Async
73
94
  @tasks.each do |waiting|
74
95
  waiting.task.stop
75
96
  end
97
+
98
+ @finished.close
76
99
  end
77
100
  end
78
101
  end
data/lib/async/clock.rb CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2022, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  # A convenient wrapper around the internal monotonic clock.
8
- # @public Since `stable-v1`.
8
+ # @public Since *Async v1*.
9
9
  class Clock
10
10
  # Get the current elapsed monotonic time.
11
11
  def self.now
@@ -61,5 +61,14 @@ module Async
61
61
 
62
62
  return total
63
63
  end
64
+
65
+ # Reset the total elapsed time. If the clock is currently running, reset the start time to now.
66
+ def reset!
67
+ @total = 0
68
+
69
+ if @started
70
+ @started = Clock.now
71
+ end
72
+ end
64
73
  end
65
74
  end
@@ -15,7 +15,7 @@ Sync do
15
15
  end
16
16
 
17
17
  Async do |task|
18
- task.sleep(1)
18
+ sleep(1)
19
19
  Console.info "Signalling condition..."
20
20
  condition.signal("Hello World")
21
21
  end
@@ -1,75 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2024, by Samuel Williams.
4
+ # Copyright, 2017-2025, by Samuel Williams.
5
5
  # Copyright, 2017, by Kent Gruber.
6
6
 
7
- require 'fiber'
8
- require_relative 'list'
7
+ require "fiber"
8
+ require_relative "list"
9
9
 
10
10
  module Async
11
11
  # A synchronization primitive, which allows fibers to wait until a particular condition is (edge) triggered.
12
- # @public Since `stable-v1`.
12
+ # @public Since *Async v1*.
13
13
  class Condition
14
14
  # Create a new condition.
15
15
  def initialize
16
- @waiting = List.new
16
+ @ready = ::Thread::Queue.new
17
17
  end
18
18
 
19
- class FiberNode < List::Node
20
- def initialize(fiber)
21
- @fiber = fiber
22
- end
23
-
24
- def transfer(*arguments)
25
- @fiber.transfer(*arguments)
26
- end
27
-
28
- def alive?
29
- @fiber.alive?
30
- end
31
- end
32
-
33
- private_constant :FiberNode
34
-
35
19
  # Queue up the current fiber and wait on yielding the task.
36
20
  # @returns [Object]
37
21
  def wait
38
- @waiting.stack(FiberNode.new(Fiber.current)) do
39
- Fiber.scheduler.transfer
40
- end
22
+ @ready.pop
41
23
  end
42
24
 
43
- # @deprecated Replaced by {#waiting?}
25
+ # @returns [Boolean] If there are no fibers waiting on this condition.
44
26
  def empty?
45
- @waiting.empty?
27
+ @ready.num_waiting.zero?
46
28
  end
47
29
 
48
30
  # @returns [Boolean] Is any fiber waiting on this notification?
49
31
  def waiting?
50
- @waiting.size > 0
32
+ !self.empty?
51
33
  end
52
34
 
53
35
  # Signal to a given task that it should resume operations.
54
36
  # @parameter value [Object | Nil] The value to return to the waiting fibers.
55
37
  def signal(value = nil)
56
- return if @waiting.empty?
38
+ return if empty?
57
39
 
58
- waiting = self.exchange
40
+ ready = self.exchange
59
41
 
60
- waiting.each do |fiber|
61
- Fiber.scheduler.resume(fiber, value) if fiber.alive?
42
+ ready.num_waiting.times do
43
+ ready.push(value)
62
44
  end
63
45
 
46
+ ready.close
47
+
64
48
  return nil
65
49
  end
66
50
 
67
51
  protected
68
52
 
69
53
  def exchange
70
- waiting = @waiting
71
- @waiting = List.new
72
- return waiting
54
+ ready = @ready
55
+ @ready = ::Thread::Queue.new
56
+ return ready
73
57
  end
74
58
  end
75
59
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2024, by Samuel Williams.
5
+
6
+ module Async
7
+ # Shims for the console gem, redirecting warnings and above to `Kernel#warn`.
8
+ #
9
+ # If you require this file, the `async` library will not depend on the `console` gem.
10
+ #
11
+ # That includes any gems that sit within the `Async` namespace.
12
+ #
13
+ # This is an experimental feature.
14
+ module Console
15
+ # Log a message at the debug level. The shim is silent.
16
+ def self.debug(...)
17
+ end
18
+
19
+ # Log a message at the info level. The shim is silent.
20
+ def self.info(...)
21
+ end
22
+
23
+ # Log a message at the warn level. The shim redirects to `Kernel#warn`.
24
+ def self.warn(*arguments, exception: nil, **options)
25
+ if exception
26
+ super(*arguments, exception.full_message, **options)
27
+ else
28
+ super(*arguments, **options)
29
+ end
30
+ end
31
+
32
+ # Log a message at the error level. The shim redirects to `Kernel#warn`.
33
+ def self.error(...)
34
+ self.warn(...)
35
+ end
36
+
37
+ # Log a message at the fatal level. The shim redirects to `Kernel#warn`.
38
+ def self.fatal(...)
39
+ self.warn(...)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "clock"
7
+
8
+ # @namespace
9
+ module Async
10
+ # Represents a deadline timeout with decrementing remaining time.
11
+ # Includes an efficient representation for zero (non-blocking) timeouts.
12
+ # @public Since *Async v2.31*.
13
+ class Deadline
14
+ # Singleton module for immediate timeouts (zero or negative).
15
+ # Avoids object allocation for fast path (non-blocking) timeouts.
16
+ module Zero
17
+ # Check if the deadline has expired.
18
+ # @returns [Boolean] Always returns true since zero timeouts are immediately expired.
19
+ def self.expired?
20
+ true
21
+ end
22
+
23
+ # Get the remaining time.
24
+ # @returns [Integer] Always returns 0 since zero timeouts have no remaining time.
25
+ def self.remaining
26
+ 0
27
+ end
28
+ end
29
+
30
+ # Create a deadline for the given timeout.
31
+ # @parameter timeout [Numeric | Nil] The timeout duration, or nil for no timeout.
32
+ # @returns [Deadline | Nil] A deadline instance, Zero singleton, or nil.
33
+ def self.start(timeout)
34
+ if timeout.nil?
35
+ nil
36
+ elsif timeout <= 0
37
+ Zero
38
+ else
39
+ self.new(timeout)
40
+ end
41
+ end
42
+
43
+ # Create a new deadline with the specified remaining time.
44
+ # @parameter remaining [Numeric] The initial remaining time.
45
+ def initialize(remaining)
46
+ @remaining = remaining
47
+ @start = Clock.now
48
+ end
49
+
50
+ # Get the remaining time, updating internal state.
51
+ # Each call to this method advances the internal clock and reduces
52
+ # the remaining time by the elapsed duration since the last call.
53
+ # @returns [Numeric] The remaining time (may be negative if expired).
54
+ def remaining
55
+ now = Clock.now
56
+ delta = now - @start
57
+ @start = now
58
+
59
+ @remaining -= delta
60
+
61
+ return @remaining
62
+ end
63
+
64
+ # Check if the deadline has expired.
65
+ # @returns [Boolean] True if no time remains.
66
+ def expired?
67
+ self.remaining <= 0
68
+ end
69
+ end
70
+ end
data/lib/async/idler.rb CHANGED
@@ -7,7 +7,8 @@ module Async
7
7
  # A load balancing mechanism that can be used process work when the system is idle.
8
8
  class Idler
9
9
  # Create a new idler.
10
- # @public Since `stable-v2`.
10
+ #
11
+ # @public Since *Async v2*.
11
12
  #
12
13
  # @parameter maximum_load [Numeric] The maximum load before we start shedding work.
13
14
  # @parameter backoff [Numeric] The initial backoff time, used for delaying work.
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ # The implementation lives in `queue.rb` but later we may move it here for better autoload/inference.
7
+ require_relative "queue"
8
+
9
+ module Async
10
+ class LimitedQueue < Queue
11
+ singleton_class.remove_method(:new)
12
+ end
13
+ end
data/lib/async/list.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2022-2024, by Samuel Williams.
4
+ # Copyright, 2022-2025, by Samuel Williams.
5
+ # Copyright, 2025, by Shopify Inc.
5
6
 
6
7
  module Async
7
8
  # A general doublely linked list. This is used internally by {Async::Barrier} and {Async::Condition} to manage child tasks.
@@ -18,6 +19,7 @@ module Async
18
19
  sprintf("#<%s:0x%x size=%d>", self.class.name, object_id, @size)
19
20
  end
20
21
 
22
+ # @returns [String] A short summary of the list.
21
23
  alias inspect to_s
22
24
 
23
25
  # Fast, safe, unbounded accumulation of children.
@@ -134,7 +136,7 @@ module Async
134
136
  return removed(node)
135
137
  end
136
138
 
137
- # @returns [Boolean] Returns true if the list is empty.
139
+ # @returns [Boolean] True if the list is empty.
138
140
  def empty?
139
141
  @size == 0
140
142
  end
@@ -143,26 +145,26 @@ module Async
143
145
  # previous = self
144
146
  # current = @tail
145
147
  # found = node.equal?(self)
146
-
148
+
147
149
  # while true
148
150
  # break if current.equal?(self)
149
-
151
+
150
152
  # if current.head != previous
151
153
  # raise "Invalid previous linked list node!"
152
154
  # end
153
-
155
+
154
156
  # if current.is_a?(List) and !current.equal?(self)
155
157
  # raise "Invalid list in list node!"
156
158
  # end
157
-
159
+
158
160
  # if node
159
161
  # found ||= current.equal?(node)
160
162
  # end
161
-
163
+
162
164
  # previous = current
163
165
  # current = current.tail
164
166
  # end
165
-
167
+
166
168
  # if node and !found
167
169
  # raise "Node not found in list!"
168
170
  # end
@@ -238,6 +240,12 @@ module Async
238
240
  attr_accessor :head
239
241
  attr_accessor :tail
240
242
 
243
+ # @returns [String] A string representation of the node.
244
+ def to_s
245
+ sprintf("#<%s:0x%x>", self.class.name, object_id)
246
+ end
247
+
248
+ # @returns [String] A string representation of the node.
241
249
  alias inspect to_s
242
250
  end
243
251
 
data/lib/async/node.rb CHANGED
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2024, by Samuel Williams.
4
+ # Copyright, 2017-2025, by Samuel Williams.
5
5
  # Copyright, 2017, by Kent Gruber.
6
6
  # Copyright, 2022, by Shannon Skipper.
7
+ # Copyright, 2025, by Shopify Inc.
7
8
 
8
- require 'fiber/annotation'
9
+ require "fiber/annotation"
9
10
 
10
- require_relative 'list'
11
+ require_relative "list"
11
12
 
12
13
  module Async
13
14
  # A list of children tasks.
@@ -180,6 +181,7 @@ module Async
180
181
  "\#<#{self.description}>"
181
182
  end
182
183
 
184
+ # @returns [String] A description of the node.
183
185
  alias inspect to_s
184
186
 
185
187
  # Change the parent of this node.
@@ -1,32 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
- require_relative 'condition'
6
+ require_relative "condition"
7
7
 
8
8
  module Async
9
9
  # A synchronization primitive, which allows fibers to wait until a notification is received. Does not block the task which signals the notification. Waiting tasks are resumed on next iteration of the reactor.
10
- # @public Since `stable-v1`.
10
+ # @public Since *Async v1*.
11
11
  class Notification < Condition
12
12
  # Signal to a given task that it should resume operations.
13
- def signal(value = nil, task: Task.current)
14
- return if @waiting.empty?
13
+ #
14
+ # @returns [Boolean] if a task was signalled.
15
+ def signal(value = nil)
16
+ return false if empty?
15
17
 
16
18
  Fiber.scheduler.push Signal.new(self.exchange, value)
17
19
 
18
- return nil
20
+ return true
19
21
  end
20
22
 
21
- Signal = Struct.new(:waiting, :value) do
23
+ Signal = Struct.new(:ready, :value) do
22
24
  def alive?
23
25
  true
24
26
  end
25
27
 
26
28
  def transfer
27
- waiting.each do |fiber|
28
- fiber.transfer(value) if fiber.alive?
29
+ ready.num_waiting.times do
30
+ ready.push(value)
29
31
  end
32
+
33
+ ready.close
30
34
  end
31
35
  end
32
36