async 1.31.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,41 @@
1
+ A synchronization primitive, which limits access to a given resource, such as a limited number of database connections, open files, or network connections.
2
+
3
+ ## Example
4
+
5
+ ~~~ ruby
6
+ require 'async'
7
+ require 'async/semaphore'
8
+ require 'net/http'
9
+
10
+ Sync do
11
+ # Only allow two concurrent tasks at a time:
12
+ semaphore = Async::Semaphore.new(2)
13
+
14
+ # Generate an array of 10 numbers:
15
+ terms = ['ruby', 'python', 'go', 'java', 'c++']
16
+
17
+ # Search for the terms:
18
+ terms.each do |term|
19
+ semaphore.async do |task|
20
+ Console.logger.info("Searching for #{term}...")
21
+ response = Net::HTTP.get(URI "https://www.google.com/search?q=#{term}")
22
+ Console.logger.info("Got response #{response.size} bytes.")
23
+ end
24
+ end
25
+ end
26
+ ~~~
27
+
28
+ ### Output
29
+
30
+ ~~~
31
+ 0.0s info: Searching for ruby... [ec=0x3c] [pid=50523]
32
+ 0.04s info: Searching for python... [ec=0x21c] [pid=50523]
33
+ 1.7s info: Got response 182435 bytes. [ec=0x3c] [pid=50523]
34
+ 1.71s info: Searching for go... [ec=0x834] [pid=50523]
35
+ 3.0s info: Got response 204854 bytes. [ec=0x21c] [pid=50523]
36
+ 3.0s info: Searching for java... [ec=0xf64] [pid=50523]
37
+ 4.32s info: Got response 103235 bytes. [ec=0x834] [pid=50523]
38
+ 4.32s info: Searching for c++... [ec=0x12d4] [pid=50523]
39
+ 4.65s info: Got response 109697 bytes. [ec=0xf64] [pid=50523]
40
+ 6.64s info: Got response 87249 bytes. [ec=0x12d4] [pid=50523]
41
+ ~~~
@@ -21,8 +21,11 @@
21
21
  # THE SOFTWARE.
22
22
 
23
23
  module Async
24
- # A semaphore is used to control access to a common resource in a concurrent system. A useful way to think of a semaphore as used in the real-world systems is as a record of how many units of a particular resource are available, coupled with operations to adjust that record safely (i.e. to avoid race conditions) as units are required or become free, and, if necessary, wait until a unit of the resource becomes available.
24
+ # A synchronization primitive, which limits access to a given resource.
25
+ # @public Since `stable-v1`.
25
26
  class Semaphore
27
+ # @parameter limit [Integer] The maximum number of times the semaphore can be acquired before it blocks.
28
+ # @parameter parent [Task | Semaphore | Nil] The parent for holding any children tasks.
26
29
  def initialize(limit = 1, parent: nil)
27
30
  @count = 0
28
31
  @limit = limit
@@ -40,25 +43,6 @@ module Async
40
43
  # The tasks waiting on this semaphore.
41
44
  attr :waiting
42
45
 
43
- # Allow setting the limit. This is useful for cases where the semaphore is used to limit the number of concurrent tasks, but the number of tasks is not known in advance or needs to be modified.
44
- #
45
- # On increasing the limit, some tasks may be immediately resumed. On decreasing the limit, some tasks may execute until the count is < than the limit.
46
- #
47
- # @parameter limit [Integer] The new limit.
48
- def limit= limit
49
- difference = limit - @limit
50
- @limit = limit
51
-
52
- # We can't suspend
53
- if difference > 0
54
- difference.times do
55
- break unless fiber = @waiting.shift
56
-
57
- fiber.resume
58
- end
59
- end
60
- end
61
-
62
46
  # Is the semaphore currently acquired?
63
47
  def empty?
64
48
  @count.zero?
@@ -86,8 +70,8 @@ module Async
86
70
 
87
71
  # Acquire the semaphore, block if we are at the limit.
88
72
  # If no block is provided, you must call release manually.
89
- # @yield when the semaphore can be acquired
90
- # @return the result of the block if invoked
73
+ # @yields {...} When the semaphore can be acquired.
74
+ # @returns The result of the block if invoked.
91
75
  def acquire
92
76
  wait
93
77
 
@@ -108,7 +92,7 @@ module Async
108
92
 
109
93
  while (@limit - @count) > 0 and fiber = @waiting.shift
110
94
  if fiber.alive?
111
- fiber.resume
95
+ Fiber.scheduler.resume(fiber)
112
96
  end
113
97
  end
114
98
  end
@@ -121,7 +105,7 @@ module Async
121
105
 
122
106
  if blocking?
123
107
  @waiting << fiber
124
- Task.yield while blocking?
108
+ Fiber.scheduler.transfer while blocking?
125
109
  end
126
110
  rescue Exception
127
111
  @waiting.delete(fiber)
data/lib/async/task.rb CHANGED
@@ -21,7 +21,6 @@
21
21
  # THE SOFTWARE.
22
22
 
23
23
  require 'fiber'
24
- require 'forwardable'
25
24
 
26
25
  require_relative 'node'
27
26
  require_relative 'condition'
@@ -38,55 +37,45 @@ module Async
38
37
  true
39
38
  end
40
39
 
41
- def resume
40
+ def transfer
42
41
  @task.stop
43
42
  end
44
43
  end
45
44
  end
46
45
 
47
- # A task represents the state associated with the execution of an asynchronous
48
- # block.
46
+ # Raised if a timeout occurs on a specific Fiber. Handled gracefully by `Task`.
47
+ # @public Since `stable-v1`.
48
+ class TimeoutError < StandardError
49
+ def initialize(message = "execution expired")
50
+ super
51
+ end
52
+ end
53
+
54
+ # Encapsulates the state of a running task and it's result.
55
+ # @public Since `stable-v1`.
49
56
  class Task < Node
50
- extend Forwardable
51
-
52
- # Yield the unerlying `result` for the task. If the result
53
- # is an Exception, then that result will be raised an its
54
- # exception.
55
- # @return [Object] result of the task
56
- # @raise [Exception] if the result is an exception
57
- # @yield [result] result of the task if a block if given.
57
+ # @deprecated With no replacement.
58
58
  def self.yield
59
- if block_given?
60
- result = yield
61
- else
62
- result = Fiber.yield
63
- end
64
-
65
- if result.is_a? Exception
66
- raise result
67
- else
68
- return result
69
- end
59
+ Fiber.scheduler.transfer
70
60
  end
71
61
 
72
62
  # Create a new task.
73
- # @param reactor [Async::Reactor] the reactor this task will run within.
74
- # @param parent [Async::Task] the parent task.
75
- def initialize(reactor, parent = Task.current?, logger: nil, finished: nil, **options, &block)
76
- super(parent || reactor, **options)
77
-
78
- @reactor = reactor
63
+ # @parameter reactor [Reactor] the reactor this task will run within.
64
+ # @parameter parent [Task] the parent task.
65
+ def initialize(parent = Task.current?, finished: nil, **options, &block)
66
+ super(parent, **options)
79
67
 
80
68
  @status = :initialized
81
69
  @result = nil
82
70
  @finished = finished
83
71
 
84
- @logger = logger || @parent.logger
85
-
86
- @fiber = make_fiber(&block)
72
+ @block = block
73
+ @fiber = nil
87
74
  end
88
75
 
89
- attr :logger
76
+ def reactor
77
+ self.root
78
+ end
90
79
 
91
80
  if Fiber.current.respond_to?(:backtrace)
92
81
  def backtrace(*arguments)
@@ -98,14 +87,19 @@ module Async
98
87
  "\#<#{self.description} (#{@status})>"
99
88
  end
100
89
 
101
- # @attr ios [Reactor] The reactor the task was created within.
102
- attr :reactor
90
+ # @deprecated Prefer {Kernel#sleep} except when compatibility with `stable-v1` is required.
91
+ def sleep(duration = nil)
92
+ super
93
+ end
103
94
 
104
- def_delegators :@reactor, :with_timeout, :sleep
95
+ # @deprecated Replaced by {Scheduler#timeout_after}.
96
+ def with_timeout(timeout, exception = TimeoutError, message = "execution expired", &block)
97
+ Fiber.scheduler.timeout_after(timeout, exception, message, &block)
98
+ end
105
99
 
106
100
  # Yield back to the reactor and allow other fibers to execute.
107
101
  def yield
108
- Task.yield{reactor.yield}
102
+ Fiber.scheduler.yield
109
103
  end
110
104
 
111
105
  # @attr fiber [Fiber] The fiber which is being used for the execution of this task.
@@ -123,14 +117,14 @@ module Async
123
117
  if @status == :initialized
124
118
  @status = :running
125
119
 
126
- @fiber.resume(*arguments)
120
+ schedule(arguments)
127
121
  else
128
122
  raise RuntimeError, "Task already running!"
129
123
  end
130
124
  end
131
125
 
132
126
  def async(*arguments, **options, &block)
133
- task = Task.new(@reactor, self, **options, &block)
127
+ task = Task.new(self, **options, &block)
134
128
 
135
129
  task.run(*arguments)
136
130
 
@@ -138,22 +132,26 @@ module Async
138
132
  end
139
133
 
140
134
  # Retrieve the current result of the task. Will cause the caller to wait until result is available.
141
- # @raise [RuntimeError] if the task's fiber is the current fiber.
142
- # @return [Object] the final expression/result of the task's block.
135
+ # @raises[RuntimeError] If the task's fiber is the current fiber.
136
+ # @returns [Object] The final expression/result of the task's block.
143
137
  def wait
144
- raise RuntimeError, "Cannot wait on own fiber" if Fiber.current.equal?(@fiber)
138
+ raise "Cannot wait on own fiber" if Fiber.current.equal?(@fiber)
145
139
 
146
140
  if running?
147
141
  @finished ||= Condition.new
148
142
  @finished.wait
143
+ end
144
+
145
+ case @result
146
+ when Exception
147
+ raise @result
149
148
  else
150
- Task.yield{@result}
149
+ return @result
151
150
  end
152
151
  end
153
152
 
154
- # Deprecated.
155
- alias result wait
156
- # Soon to become attr :result
153
+ # Access the result of the task without waiting. May be nil if the task is not completed.
154
+ attr :result
157
155
 
158
156
  # Stop the task and all of its children.
159
157
  def stop(later = false)
@@ -165,15 +163,15 @@ module Async
165
163
  if self.running?
166
164
  if self.current?
167
165
  if later
168
- @reactor << Stop::Later.new(self)
166
+ Fiber.scheduler.push Stop::Later.new(self)
169
167
  else
170
168
  raise Stop, "Stopping current task!"
171
169
  end
172
170
  elsif @fiber&.alive?
173
171
  begin
174
- @fiber.resume(Stop.new)
172
+ Fiber.scheduler.raise(@fiber, Stop)
175
173
  rescue FiberError
176
- @reactor << Stop::Later.new(self)
174
+ Fiber.scheduler.push Stop::Later.new(self)
177
175
  end
178
176
  end
179
177
  else
@@ -183,14 +181,14 @@ module Async
183
181
  end
184
182
 
185
183
  # Lookup the {Task} for the current fiber. Raise `RuntimeError` if none is available.
186
- # @return [Async::Task]
187
- # @raise [RuntimeError] if task was not {set!} for the current fiber.
184
+ # @returns [Task]
185
+ # @raises[RuntimeError] If task was not {set!} for the current fiber.
188
186
  def self.current
189
187
  Thread.current[:async_task] or raise RuntimeError, "No async task available!"
190
188
  end
191
189
 
192
190
  # Check if there is a task defined for the current fiber.
193
- # @return [Async::Task, nil]
191
+ # @returns [Task | Nil]
194
192
  def self.current?
195
193
  Thread.current[:async_task]
196
194
  end
@@ -200,13 +198,13 @@ module Async
200
198
  end
201
199
 
202
200
  # Check if the task is running.
203
- # @return [Boolean]
201
+ # @returns [Boolean]
204
202
  def running?
205
203
  @status == :running
206
204
  end
207
205
 
208
206
  # Whether we can remove this node from the reactor graph.
209
- # @return [Boolean]
207
+ # @returns [Boolean]
210
208
  def finished?
211
209
  super && @status != :running
212
210
  end
@@ -230,35 +228,34 @@ module Async
230
228
  private
231
229
 
232
230
  # 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.
233
- def fail!(exception = false, propagate = true)
231
+ # As an explcit choice, the user can start a task which doesn't propagate exceptions. This only applies to `StandardError` and derived tasks. This allows tasks to internally capture their error state which is raised when invoking `Task#result` similar to how `Thread#join` works. This mode makes {ruby Async::Task} behave more like a promise, and you would need to ensure that someone calls `Task#result` otherwise you might miss important errors.
232
+ def fail!(exception = nil, propagate = true)
234
233
  @status = :failed
235
234
  @result = exception
236
235
 
237
- if exception
238
- if propagate
239
- raise exception
240
- elsif @finished.nil?
241
- # If no one has called wait, we log this as a warning:
242
- Console.logger.warn(self, "Task may have ended with unhandled exception.", exception)
243
- else
244
- Console.logger.debug(self, exception)
245
- end
236
+ if propagate
237
+ raise
238
+ elsif @finished.nil?
239
+ # If no one has called wait, we log this as an error:
240
+ Console.logger.error(self) {$!}
241
+ else
242
+ Console.logger.debug(self) {$!}
246
243
  end
247
244
  end
248
245
 
249
246
  def stop!
250
- # logger.debug(self) {"Task was stopped with #{@children&.size.inspect} children!"}
247
+ # Console.logger.info(self, self.annotation) {"Task was stopped with #{@children&.size.inspect} children!"}
251
248
  @status = :stopped
252
249
 
253
250
  stop_children(true)
254
251
  end
255
252
 
256
- def make_fiber(&block)
257
- Fiber.new do |*arguments|
253
+ def schedule(arguments)
254
+ @fiber = Fiber.new do
258
255
  set!
259
256
 
260
257
  begin
261
- @result = yield(self, *arguments)
258
+ @result = @block.call(self, *arguments)
262
259
  @status = :complete
263
260
  # Console.logger.debug(self) {"Task was completed with #{@children.size} children!"}
264
261
  rescue Stop
@@ -268,10 +265,12 @@ module Async
268
265
  rescue Exception => exception
269
266
  fail!(exception, true)
270
267
  ensure
271
- # Console.logger.debug(self) {"Task ensure $!=#{$!} with #{@children.size} children!"}
268
+ # Console.logger.info(self) {"Task ensure $! = #{$!} with #{@children&.size.inspect} children!"}
272
269
  finish!
273
270
  end
274
271
  end
272
+
273
+ self.root.resume(@fiber)
275
274
  end
276
275
 
277
276
  # Finish the current task, and all bound bound IO objects.
@@ -292,7 +291,6 @@ module Async
292
291
  def set!
293
292
  # This is actually fiber-local:
294
293
  Thread.current[:async_task] = self
295
- Console.logger = @logger if @logger
296
294
  end
297
295
  end
298
296
  end
data/lib/async/version.rb CHANGED
@@ -21,5 +21,5 @@
21
21
  # THE SOFTWARE.
22
22
 
23
23
  module Async
24
- VERSION = "1.31.0"
24
+ VERSION = "2.0.0"
25
25
  end
data/lib/async/wrapper.rb CHANGED
@@ -20,220 +20,60 @@
20
20
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
21
  # THE SOFTWARE.
22
22
 
23
- require 'nio'
24
-
25
23
  module Async
26
24
  # Represents an asynchronous IO within a reactor.
25
+ # @deprecated With no replacement. Prefer native interfaces.
27
26
  class Wrapper
27
+ # An exception that occurs when the asynchronous operation was cancelled.
28
28
  class Cancelled < StandardError
29
- class From
30
- def initialize
31
- @backtrace = caller[5..-1]
32
- end
33
-
34
- attr :backtrace
35
-
36
- def cause
37
- nil
38
- end
39
-
40
- def message
41
- "Cancelled"
42
- end
43
- end
44
-
45
- def initialize
46
- super "The operation has been cancelled!"
47
-
48
- @cause = From.new
49
- end
50
-
51
- attr :cause
52
29
  end
53
30
 
54
- # wait_readable, wait_writable and wait_any are not re-entrant, and will raise this failure.
55
- class WaitError < StandardError
56
- def initialize
57
- super "A fiber is already waiting!"
58
- end
59
- end
60
-
61
- # @param io the native object to wrap.
62
- # @param reactor [Reactor] the reactor that is managing this wrapper, or not specified, it's looked up by way of {Task.current}.
31
+ # @parameter io the native object to wrap.
32
+ # @parameter reactor [Reactor] the reactor that is managing this wrapper, or not specified, it's looked up by way of {Task.current}.
63
33
  def initialize(io, reactor = nil)
64
34
  @io = io
65
-
66
35
  @reactor = reactor
67
- @monitor = nil
68
36
 
69
- @readable = nil
70
- @writable = nil
71
- @any = nil
37
+ @timeout = nil
72
38
  end
73
39
 
74
- def dup
75
- self.class.new(@io.dup, @reactor)
76
- end
40
+ attr_accessor :reactor
77
41
 
78
- def resume(*arguments)
79
- # It's possible that the monitor was closed before calling resume.
80
- return unless @monitor
81
-
82
- readiness = @monitor.readiness
83
-
84
- if @readable and (readiness == :r or readiness == :rw)
85
- @readable.resume(*arguments)
86
- end
87
-
88
- if @writable and (readiness == :w or readiness == :rw)
89
- @writable.resume(*arguments)
90
- end
91
-
92
- if @any
93
- @any.resume(*arguments)
94
- end
42
+ def dup
43
+ self.class.new(@io.dup)
95
44
  end
96
45
 
97
46
  # The underlying native `io`.
98
47
  attr :io
99
48
 
100
- # The reactor this wrapper is associated with, if any.
101
- attr :reactor
102
-
103
- # The monitor for this wrapper, if any.
104
- attr :monitor
105
-
106
- # Bind this wrapper to a different reactor. Assign nil to convert to an unbound wrapper (can be used from any reactor/task but with slightly increased overhead.)
107
- # Binding to a reactor is purely a performance consideration. Generally, I don't like APIs that exist only due to optimisations. This is borderline, so consider this functionality semi-private.
108
- def reactor= reactor
109
- return if @reactor.equal?(reactor)
110
-
111
- cancel_monitor
112
-
113
- @reactor = reactor
49
+ # Wait for the io to become readable.
50
+ def wait_readable(timeout = @timeout)
51
+ @io.to_io.wait_readable(timeout) or raise TimeoutError
114
52
  end
115
53
 
116
- # Wait for the io to become readable.
117
- def wait_readable(timeout = nil)
118
- raise WaitError if @readable
119
-
120
- self.reactor = Task.current.reactor
121
-
122
- begin
123
- @readable = Fiber.current
124
- wait_for(timeout)
125
- ensure
126
- @readable = nil
127
- @monitor.interests = interests if @monitor
128
- end
54
+ # Wait for the io to become writable.
55
+ def wait_priority(timeout = @timeout)
56
+ @io.to_io.wait_priority(timeout) or raise TimeoutError
129
57
  end
130
58
 
131
59
  # Wait for the io to become writable.
132
- def wait_writable(timeout = nil)
133
- raise WaitError if @writable
134
-
135
- self.reactor = Task.current.reactor
136
-
137
- begin
138
- @writable = Fiber.current
139
- wait_for(timeout)
140
- ensure
141
- @writable = nil
142
- @monitor.interests = interests if @monitor
143
- end
60
+ def wait_writable(timeout = @timeout)
61
+ @io.to_io.wait_writable(timeout) or raise TimeoutError
144
62
  end
145
63
 
146
64
  # Wait fo the io to become either readable or writable.
147
- # @param duration [Float] timeout after the given duration if not `nil`.
148
- def wait_any(timeout = nil)
149
- raise WaitError if @any
150
-
151
- self.reactor = Task.current.reactor
152
-
153
- begin
154
- @any = Fiber.current
155
- wait_for(timeout)
156
- ensure
157
- @any = nil
158
- @monitor.interests = interests if @monitor
159
- end
65
+ # @parameter duration [Float] timeout after the given duration if not `nil`.
66
+ def wait_any(timeout = @timeout)
67
+ @io.wait_any(timeout) or raise TimeoutError
160
68
  end
161
69
 
162
70
  # Close the io and monitor.
163
71
  def close
164
- cancel_monitor
165
-
166
72
  @io.close
167
73
  end
168
74
 
169
75
  def closed?
170
76
  @io.closed?
171
77
  end
172
-
173
- private
174
-
175
- # What an abomination.
176
- def interests
177
- if @any
178
- return :rw
179
- elsif @readable
180
- if @writable
181
- return :rw
182
- else
183
- return :r
184
- end
185
- elsif @writable
186
- return :w
187
- end
188
-
189
- return nil
190
- end
191
-
192
- def cancel_monitor
193
- if @readable
194
- readable = @readable
195
- @readable = nil
196
-
197
- readable.resume(Cancelled.new)
198
- end
199
-
200
- if @writable
201
- writable = @writable
202
- @writable = nil
203
-
204
- writable.resume(Cancelled.new)
205
- end
206
-
207
- if @any
208
- any = @any
209
- @any = nil
210
-
211
- any.resume(Cancelled.new)
212
- end
213
-
214
- if @monitor
215
- @monitor.close
216
- @monitor = nil
217
- end
218
- end
219
-
220
- def wait_for(timeout)
221
- if @monitor
222
- @monitor.interests = interests
223
- else
224
- @monitor = @reactor.register(@io, interests, self)
225
- end
226
-
227
- # If the user requested an explicit timeout for this operation:
228
- if timeout
229
- @reactor.with_timeout(timeout) do
230
- Task.yield
231
- end
232
- else
233
- Task.yield
234
- end
235
-
236
- return true
237
- end
238
78
  end
239
79
  end
data/lib/async.rb CHANGED
@@ -21,15 +21,10 @@
21
21
  # THE SOFTWARE.
22
22
 
23
23
  require_relative "async/version"
24
- require_relative "async/logger"
25
24
  require_relative "async/reactor"
26
25
 
27
26
  require_relative "kernel/async"
28
27
  require_relative "kernel/sync"
29
28
 
30
29
  module Async
31
- # Invoke `Reactor.run` with all arguments/block.
32
- def self.run(*arguments, &block)
33
- Reactor.run(*arguments, &block)
34
- end
35
30
  end
data/lib/kernel/async.rb CHANGED
@@ -24,7 +24,31 @@ require_relative "../async/reactor"
24
24
 
25
25
  module Kernel
26
26
  # Run the given block of code in a task, asynchronously, creating a reactor if necessary.
27
- def Async(*arguments, **options, &block)
28
- ::Async::Reactor.run(*arguments, **options, &block)
27
+ #
28
+ # The preferred method to invoke asynchronous behavior at the top level.
29
+ #
30
+ # - When invoked within an existing reactor task, it will run the given block
31
+ # asynchronously. Will return the task once it has been scheduled.
32
+ # - When invoked at the top level, will create and run a reactor, and invoke
33
+ # the block as an asynchronous task. Will block until the reactor finishes
34
+ # running.
35
+ #
36
+ # @yields {|task| ...} The block that will execute asynchronously.
37
+ # @parameter task [Async::Task] The task that is executing the given block.
38
+ #
39
+ # @public Since `stable-v1`.
40
+ # @asynchronous May block until given block completes executing.
41
+ def Async(...)
42
+ if current = ::Async::Task.current?
43
+ return current.async(...)
44
+ else
45
+ reactor = ::Async::Reactor.new
46
+
47
+ begin
48
+ return reactor.run(...)
49
+ ensure
50
+ Fiber.set_scheduler(nil)
51
+ end
52
+ end
29
53
  end
30
54
  end