async 1.32.1 → 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,57 +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)
87
-
88
- @defer_stop = nil
72
+ @block = block
73
+ @fiber = nil
89
74
  end
90
75
 
91
- attr :logger
76
+ def reactor
77
+ self.root
78
+ end
92
79
 
93
80
  if Fiber.current.respond_to?(:backtrace)
94
81
  def backtrace(*arguments)
@@ -100,14 +87,19 @@ module Async
100
87
  "\#<#{self.description} (#{@status})>"
101
88
  end
102
89
 
103
- # @attr ios [Reactor] The reactor the task was created within.
104
- attr :reactor
90
+ # @deprecated Prefer {Kernel#sleep} except when compatibility with `stable-v1` is required.
91
+ def sleep(duration = nil)
92
+ super
93
+ end
105
94
 
106
- 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
107
99
 
108
100
  # Yield back to the reactor and allow other fibers to execute.
109
101
  def yield
110
- Task.yield{reactor.yield}
102
+ Fiber.scheduler.yield
111
103
  end
112
104
 
113
105
  # @attr fiber [Fiber] The fiber which is being used for the execution of this task.
@@ -125,14 +117,14 @@ module Async
125
117
  if @status == :initialized
126
118
  @status = :running
127
119
 
128
- @fiber.resume(*arguments)
120
+ schedule(arguments)
129
121
  else
130
122
  raise RuntimeError, "Task already running!"
131
123
  end
132
124
  end
133
125
 
134
126
  def async(*arguments, **options, &block)
135
- task = Task.new(@reactor, self, **options, &block)
127
+ task = Task.new(self, **options, &block)
136
128
 
137
129
  task.run(*arguments)
138
130
 
@@ -140,22 +132,26 @@ module Async
140
132
  end
141
133
 
142
134
  # Retrieve the current result of the task. Will cause the caller to wait until result is available.
143
- # @raise [RuntimeError] if the task's fiber is the current fiber.
144
- # @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.
145
137
  def wait
146
- raise RuntimeError, "Cannot wait on own fiber" if Fiber.current.equal?(@fiber)
138
+ raise "Cannot wait on own fiber" if Fiber.current.equal?(@fiber)
147
139
 
148
140
  if running?
149
141
  @finished ||= Condition.new
150
142
  @finished.wait
143
+ end
144
+
145
+ case @result
146
+ when Exception
147
+ raise @result
151
148
  else
152
- Task.yield{@result}
149
+ return @result
153
150
  end
154
151
  end
155
152
 
156
- # Deprecated.
157
- alias result wait
158
- # 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
159
155
 
160
156
  # Stop the task and all of its children.
161
157
  def stop(later = false)
@@ -164,25 +160,18 @@ module Async
164
160
  return
165
161
  end
166
162
 
167
- # If we are deferring stop...
168
- if @defer_stop == false
169
- # Don't stop now... but update the state so we know we need to stop later.
170
- @defer_stop = true
171
- return false
172
- end
173
-
174
163
  if self.running?
175
164
  if self.current?
176
165
  if later
177
- @reactor << Stop::Later.new(self)
166
+ Fiber.scheduler.push Stop::Later.new(self)
178
167
  else
179
168
  raise Stop, "Stopping current task!"
180
169
  end
181
170
  elsif @fiber&.alive?
182
171
  begin
183
- @fiber.resume(Stop.new)
172
+ Fiber.scheduler.raise(@fiber, Stop)
184
173
  rescue FiberError
185
- @reactor << Stop::Later.new(self)
174
+ Fiber.scheduler.push Stop::Later.new(self)
186
175
  end
187
176
  end
188
177
  else
@@ -191,50 +180,15 @@ module Async
191
180
  end
192
181
  end
193
182
 
194
- # Defer the handling of stop. During the execution of the given block, if a stop is requested, it will be deferred until the block exits. This is useful for ensuring graceful shutdown of servers and other long-running tasks. You should wrap the response handling code in a defer_stop block to ensure that the task is stopped when the response is complete but not before.
195
- #
196
- # You can nest calls to defer_stop, but the stop will only be deferred until the outermost block exits.
197
- #
198
- # If stop is invoked a second time, it will be immediately executed.
199
- #
200
- # @yields {} The block of code to execute.
201
- def defer_stop
202
- # Tri-state variable for controlling stop:
203
- # - nil: defer_stop has not been called.
204
- # - false: defer_stop has been called and we are not stopping.
205
- # - true: defer_stop has been called and we will stop when exiting the block.
206
- if @defer_stop.nil?
207
- # If we are not deferring stop already, we can defer it now:
208
- @defer_stop = false
209
-
210
- begin
211
- yield
212
- rescue Stop
213
- # If we are exiting due to a stop, we shouldn't try to invoke stop again:
214
- @defer_stop = nil
215
- raise
216
- ensure
217
- # If we were asked to stop, we should do so now:
218
- if @defer_stop
219
- @defer_stop = nil
220
- self.stop
221
- end
222
- end
223
- else
224
- # If we are deferring stop already, entering it again is a no-op.
225
- yield
226
- end
227
- end
228
-
229
183
  # Lookup the {Task} for the current fiber. Raise `RuntimeError` if none is available.
230
- # @return [Async::Task]
231
- # @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.
232
186
  def self.current
233
187
  Thread.current[:async_task] or raise RuntimeError, "No async task available!"
234
188
  end
235
189
 
236
190
  # Check if there is a task defined for the current fiber.
237
- # @return [Async::Task, nil]
191
+ # @returns [Task | Nil]
238
192
  def self.current?
239
193
  Thread.current[:async_task]
240
194
  end
@@ -244,13 +198,13 @@ module Async
244
198
  end
245
199
 
246
200
  # Check if the task is running.
247
- # @return [Boolean]
201
+ # @returns [Boolean]
248
202
  def running?
249
203
  @status == :running
250
204
  end
251
205
 
252
206
  # Whether we can remove this node from the reactor graph.
253
- # @return [Boolean]
207
+ # @returns [Boolean]
254
208
  def finished?
255
209
  super && @status != :running
256
210
  end
@@ -274,35 +228,34 @@ module Async
274
228
  private
275
229
 
276
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.
277
- 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)
278
233
  @status = :failed
279
234
  @result = exception
280
235
 
281
- if exception
282
- if propagate
283
- raise exception
284
- elsif @finished.nil?
285
- # If no one has called wait, we log this as a warning:
286
- Console.logger.warn(self, "Task may have ended with unhandled exception.", exception)
287
- else
288
- Console.logger.debug(self, exception)
289
- 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) {$!}
290
243
  end
291
244
  end
292
245
 
293
246
  def stop!
294
- # 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!"}
295
248
  @status = :stopped
296
249
 
297
250
  stop_children(true)
298
251
  end
299
252
 
300
- def make_fiber(&block)
301
- Fiber.new do |*arguments|
253
+ def schedule(arguments)
254
+ @fiber = Fiber.new do
302
255
  set!
303
256
 
304
257
  begin
305
- @result = yield(self, *arguments)
258
+ @result = @block.call(self, *arguments)
306
259
  @status = :complete
307
260
  # Console.logger.debug(self) {"Task was completed with #{@children.size} children!"}
308
261
  rescue Stop
@@ -312,10 +265,12 @@ module Async
312
265
  rescue Exception => exception
313
266
  fail!(exception, true)
314
267
  ensure
315
- # Console.logger.debug(self) {"Task ensure $!=#{$!} with #{@children.size} children!"}
268
+ # Console.logger.info(self) {"Task ensure $! = #{$!} with #{@children&.size.inspect} children!"}
316
269
  finish!
317
270
  end
318
271
  end
272
+
273
+ self.root.resume(@fiber)
319
274
  end
320
275
 
321
276
  # Finish the current task, and all bound bound IO objects.
@@ -336,7 +291,6 @@ module Async
336
291
  def set!
337
292
  # This is actually fiber-local:
338
293
  Thread.current[:async_task] = self
339
- Console.logger = @logger if @logger
340
294
  end
341
295
  end
342
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.32.1"
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