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.
@@ -5,30 +5,44 @@
5
5
  # Copyright, 2020, by Jun Jiang.
6
6
  # Copyright, 2021, by Julien Portalier.
7
7
 
8
- require_relative 'clock'
9
- require_relative 'task'
8
+ require_relative "clock"
9
+ require_relative "task"
10
+ require_relative "worker_pool"
10
11
 
11
- require 'io/event'
12
+ require "io/event"
12
13
 
13
- require 'console'
14
- require 'resolv'
14
+ require "console"
15
+ require "resolv"
15
16
 
16
17
  module Async
17
18
  # Handles scheduling of fibers. Implements the fiber scheduler interface.
18
19
  class Scheduler < Node
20
+ DEFAULT_WORKER_POOL = ENV.fetch("ASYNC_SCHEDULER_DEFAULT_WORKER_POOL", nil).then do |value|
21
+ value == "true" ? true : nil
22
+ end
23
+
24
+ # Raised when an operation is attempted on a closed scheduler.
19
25
  class ClosedError < RuntimeError
26
+ # Create a new error.
27
+ #
28
+ # @parameter message [String] The error message.
20
29
  def initialize(message = "Scheduler is closed!")
21
30
  super
22
31
  end
23
32
  end
24
33
 
25
34
  # Whether the fiber scheduler is supported.
26
- # @public Since `stable-v1`.
35
+ # @public Since *Async v1*.
27
36
  def self.supported?
28
37
  true
29
38
  end
30
39
 
31
- def initialize(parent = nil, selector: nil)
40
+ # Create a new scheduler.
41
+ #
42
+ # @public Since *Async v1*.
43
+ # @parameter parent [Node | Nil] The parent node to use for task hierarchy.
44
+ # @parameter selector [IO::Event::Selector] The selector to use for event handling.
45
+ def initialize(parent = nil, selector: nil, worker_pool: DEFAULT_WORKER_POOL)
32
46
  super(parent)
33
47
 
34
48
  @selector = selector || ::IO::Event::Selector.new(Fiber.current)
@@ -40,9 +54,19 @@ module Async
40
54
  @idle_time = 0.0
41
55
 
42
56
  @timers = ::IO::Event::Timers.new
57
+ if worker_pool == true
58
+ @worker_pool = WorkerPool.new
59
+ else
60
+ @worker_pool = worker_pool
61
+ end
62
+
63
+ if @worker_pool
64
+ self.singleton_class.prepend(WorkerPool::BlockingOperationWait)
65
+ end
43
66
  end
44
67
 
45
68
  # Compute the scheduler load according to the busy and idle times that are updated by the run loop.
69
+ #
46
70
  # @returns [Float] The load of the scheduler. 0.0 means no load, 1.0 means fully loaded or over-loaded.
47
71
  def load
48
72
  total_time = @busy_time + @idle_time
@@ -62,52 +86,61 @@ module Async
62
86
  return @busy_time / total_time
63
87
  end
64
88
  end
65
-
66
- def scheduler_close
89
+
90
+ # Invoked when the fiber scheduler is being closed.
91
+ #
92
+ # Executes the run loop until all tasks are finished, then closes the scheduler.
93
+ def scheduler_close(error = $!)
67
94
  # If the execution context (thread) was handling an exception, we want to exit as quickly as possible:
68
- unless $!
95
+ unless error
69
96
  self.run
70
97
  end
71
98
  ensure
72
99
  self.close
73
100
  end
74
101
 
75
- # Terminate the scheduler. We deliberately ignore interrupts here, as this code can be called from an interrupt, and we don't want to be interrupted while cleaning up.
102
+ # Terminate all child tasks.
76
103
  def terminate
77
- Thread.handle_interrupt(::Interrupt => :never) do
78
- super
104
+ # If that doesn't work, take more serious action:
105
+ @children&.each do |child|
106
+ child.terminate
79
107
  end
108
+
109
+ return @children.nil?
80
110
  end
81
111
 
82
- # @public Since `stable-v1`.
112
+ # Terminate all child tasks and close the scheduler.
113
+ # @public Since *Async v1*.
83
114
  def close
84
- # It's critical to stop all tasks. Otherwise they might be holding on to resources which are never closed/released correctly.
85
- until self.terminate
86
- self.run_once!
115
+ self.run_loop do
116
+ until self.terminate
117
+ self.run_once!
118
+ end
87
119
  end
88
120
 
89
121
  Kernel.raise "Closing scheduler with blocked operations!" if @blocked > 0
90
-
91
- # We depend on GVL for consistency:
92
- # @guard.synchronize do
93
-
122
+ ensure
94
123
  # We want `@selector = nil` to be a visible side effect from this point forward, specifically in `#interrupt` and `#unblock`. If the selector is closed, then we don't want to push any fibers to it.
95
124
  selector = @selector
96
125
  @selector = nil
97
126
 
98
127
  selector&.close
99
128
 
100
- # end
129
+ worker_pool = @worker_pool
130
+ @worker_pool = nil
131
+
132
+ worker_pool&.close
101
133
 
102
134
  consume
103
135
  end
104
136
 
105
137
  # @returns [Boolean] Whether the scheduler has been closed.
106
- # @public Since `stable-v1`.
138
+ # @public Since *Async v1*.
107
139
  def closed?
108
140
  @selector.nil?
109
141
  end
110
142
 
143
+ # @returns [String] A description of the scheduler.
111
144
  def to_s
112
145
  "\#<#{self.description} #{@children&.size || 0} children (#{stopped? ? 'stopped' : 'running'})>"
113
146
  end
@@ -135,16 +168,31 @@ module Async
135
168
  @selector.push(fiber)
136
169
  end
137
170
 
138
- def raise(*arguments)
139
- @selector.raise(*arguments)
171
+ # Raise an exception on a specified fiber with the given arguments.
172
+ #
173
+ # This internally schedules the current fiber to be ready, before raising the exception, so that it will later resume execution.
174
+ #
175
+ # @parameter fiber [Fiber] The fiber to raise the exception on.
176
+ # @parameter *arguments [Array] The arguments to pass to the fiber.
177
+ def raise(...)
178
+ @selector.raise(...)
140
179
  end
141
180
 
181
+ # Resume execution of the specified fiber.
182
+ #
183
+ # @parameter fiber [Fiber] The fiber to resume.
184
+ # @parameter arguments [Array] The arguments to pass to the fiber.
142
185
  def resume(fiber, *arguments)
143
186
  @selector.resume(fiber, *arguments)
144
187
  end
145
188
 
146
189
  # Invoked when a fiber tries to perform a blocking operation which cannot continue. A corresponding call {unblock} must be performed to allow this fiber to continue.
190
+ #
191
+ # @public Since *Async v2*.
147
192
  # @asynchronous May only be called on same thread as fiber scheduler.
193
+ #
194
+ # @parameter blocker [Object] The object that is blocking the fiber.
195
+ # @parameter timeout [Float | Nil] The maximum time to block, or if nil, indefinitely.
148
196
  def block(blocker, timeout)
149
197
  # $stderr.puts "block(#{blocker}, #{Fiber.current}, #{timeout})"
150
198
  fiber = Fiber.current
@@ -167,7 +215,13 @@ module Async
167
215
  timer&.cancel!
168
216
  end
169
217
 
218
+ # Unblock a fiber that was previously blocked.
219
+ #
220
+ # @public Since *Async v2* and *Ruby v3.1*.
170
221
  # @asynchronous May be called from any thread.
222
+ #
223
+ # @parameter blocker [Object] The object that was blocking the fiber.
224
+ # @parameter fiber [Fiber] The fiber to unblock.
171
225
  def unblock(blocker, fiber)
172
226
  # $stderr.puts "unblock(#{blocker}, #{fiber})"
173
227
 
@@ -178,7 +232,12 @@ module Async
178
232
  end
179
233
  end
180
234
 
181
- # @asynchronous May be non-blocking..
235
+ # Sleep for the specified duration.
236
+ #
237
+ # @public Since *Async v2* and *Ruby v3.1*.
238
+ # @asynchronous May be non-blocking.
239
+ #
240
+ # @parameter duration [Numeric | Nil] The time in seconds to sleep, or if nil, indefinitely.
182
241
  def kernel_sleep(duration = nil)
183
242
  if duration
184
243
  self.block(nil, duration)
@@ -187,7 +246,12 @@ module Async
187
246
  end
188
247
  end
189
248
 
190
- # @asynchronous May be non-blocking..
249
+ # Resolve the address of the given hostname.
250
+ #
251
+ # @public Since *Async v2*.
252
+ # @asynchronous May be non-blocking.
253
+ #
254
+ # @parameter hostname [String] The hostname to resolve.
191
255
  def address_resolve(hostname)
192
256
  # On some platforms, hostnames may contain a device-specific suffix (e.g. %en0). We need to strip this before resolving.
193
257
  # See <https://github.com/socketry/async/issues/180> for more details.
@@ -195,7 +259,6 @@ module Async
195
259
  ::Resolv.getaddresses(hostname)
196
260
  end
197
261
 
198
-
199
262
  if IO.method_defined?(:timeout)
200
263
  private def get_timeout(io)
201
264
  io.timeout
@@ -206,7 +269,14 @@ module Async
206
269
  end
207
270
  end
208
271
 
209
- # @asynchronous May be non-blocking..
272
+ # Wait for the specified IO to become ready for the specified events.
273
+ #
274
+ # @public Since *Async v2*.
275
+ # @asynchronous May be non-blocking.
276
+ #
277
+ # @parameter io [IO] The IO object to wait on.
278
+ # @parameter events [Integer] The events to wait for, e.g. `IO::READABLE`, `IO::WRITABLE`, etc.
279
+ # @parameter timeout [Float | Nil] The maximum time to wait, or if nil, indefinitely.
210
280
  def io_wait(io, events, timeout = nil)
211
281
  fiber = Fiber.current
212
282
 
@@ -218,7 +288,7 @@ module Async
218
288
  elsif timeout = get_timeout(io)
219
289
  # Otherwise, if we default to the io's timeout, we raise an exception:
220
290
  timer = @timers.after(timeout) do
221
- fiber.raise(::IO::TimeoutError, "Timeout while waiting for IO to become ready!")
291
+ fiber.raise(::IO::TimeoutError, "Timeout (#{timeout}s) while waiting for IO to become ready!")
222
292
  end
223
293
  end
224
294
 
@@ -228,12 +298,21 @@ module Async
228
298
  end
229
299
 
230
300
  if ::IO::Event::Support.buffer?
301
+ # Read from the specified IO into the buffer.
302
+ #
303
+ # @public Since *Async v2* and Ruby with `IO::Buffer` support.
304
+ # @asynchronous May be non-blocking.
305
+ #
306
+ # @parameter io [IO] The IO object to read from.
307
+ # @parameter buffer [IO::Buffer] The buffer to read into.
308
+ # @parameter length [Integer] The minimum number of bytes to read.
309
+ # @parameter offset [Integer] The offset within the buffer to read into.
231
310
  def io_read(io, buffer, length, offset = 0)
232
311
  fiber = Fiber.current
233
312
 
234
313
  if timeout = get_timeout(io)
235
314
  timer = @timers.after(timeout) do
236
- fiber.raise(::IO::TimeoutError, "Timeout while waiting for IO to become readable!")
315
+ fiber.raise(::IO::TimeoutError, "Timeout (#{timeout}s) while waiting for IO to become readable!")
237
316
  end
238
317
  end
239
318
 
@@ -243,12 +322,21 @@ module Async
243
322
  end
244
323
 
245
324
  if RUBY_ENGINE != "ruby" || RUBY_VERSION >= "3.3.1"
325
+ # Write the specified buffer to the IO.
326
+ #
327
+ # @public Since *Async v2* and *Ruby v3.3.1* with `IO::Buffer` support.
328
+ # @asynchronous May be non-blocking.
329
+ #
330
+ # @parameter io [IO] The IO object to write to.
331
+ # @parameter buffer [IO::Buffer] The buffer to write from.
332
+ # @parameter length [Integer] The minimum number of bytes to write.
333
+ # @parameter offset [Integer] The offset within the buffer to write from.
246
334
  def io_write(io, buffer, length, offset = 0)
247
335
  fiber = Fiber.current
248
336
 
249
337
  if timeout = get_timeout(io)
250
338
  timer = @timers.after(timeout) do
251
- fiber.raise(::IO::TimeoutError, "Timeout while waiting for IO to become writable!")
339
+ fiber.raise(::IO::TimeoutError, "Timeout (#{timeout}s) while waiting for IO to become writable!")
252
340
  end
253
341
  end
254
342
 
@@ -260,6 +348,10 @@ module Async
260
348
  end
261
349
 
262
350
  # Wait for the specified process ID to exit.
351
+ #
352
+ # @public Since *Async v2*.
353
+ # @asynchronous May be non-blocking.
354
+ #
263
355
  # @parameter pid [Integer] The process ID to wait for.
264
356
  # @parameter flags [Integer] A bit-mask of flags suitable for `Process::Status.wait`.
265
357
  # @returns [Process::Status] A process status instance.
@@ -268,28 +360,13 @@ module Async
268
360
  return @selector.process_wait(Fiber.current, pid, flags)
269
361
  end
270
362
 
271
- # Run one iteration of the event loop.
272
- # Does not handle interrupts.
273
- # @parameter timeout [Float | Nil] The maximum timeout, or if nil, indefinite.
274
- # @returns [Boolean] Whether there is more work to do.
275
- def run_once(timeout = nil)
276
- Kernel::raise "Running scheduler on non-blocking fiber!" unless Fiber.blocking?
277
-
278
- # If we are finished, we stop the task tree and exit:
279
- if self.finished?
280
- return false
281
- end
282
-
283
- return run_once!(timeout)
284
- end
285
-
286
363
  # Run one iteration of the event loop.
287
364
  #
288
365
  # When terminating the event loop, we already know we are finished. So we don't need to check the task tree. This is a logical requirement because `run_once` ignores transient tasks. For example, a single top level transient task is not enough to keep the reactor running, but during termination we must still process it in order to terminate child tasks.
289
366
  #
290
367
  # @parameter timeout [Float | Nil] The maximum timeout, or if nil, indefinite.
291
368
  # @returns [Boolean] Whether there is more work to do.
292
- private def run_once!(timeout = 0)
369
+ private def run_once!(timeout = nil)
293
370
  start_time = Async::Clock.now
294
371
 
295
372
  interval = @timers.wait_interval
@@ -326,7 +403,30 @@ module Async
326
403
  return true
327
404
  end
328
405
 
406
+ # Run one iteration of the event loop.
407
+ #
408
+ # @public Since *Async v1*.
409
+ # @asynchronous Must be invoked from blocking (root) fiber.
410
+ #
411
+ # @parameter timeout [Float | Nil] The maximum timeout, or if nil, indefinite.
412
+ # @returns [Boolean] Whether there is more work to do.
413
+ def run_once(timeout = nil)
414
+ Kernel.raise "Running scheduler on non-blocking fiber!" unless Fiber.blocking?
415
+
416
+ if self.finished?
417
+ self.stop
418
+ end
419
+
420
+ # If we are finished, we stop the task tree and exit:
421
+ if @children.nil?
422
+ return false
423
+ end
424
+
425
+ return run_once!(timeout)
426
+ end
427
+
329
428
  # Checks and clears the interrupted state of the scheduler.
429
+ #
330
430
  # @returns [Boolean] Whether the reactor has been interrupted.
331
431
  private def interrupted?
332
432
  if @interrupted
@@ -341,26 +441,34 @@ module Async
341
441
  return false
342
442
  end
343
443
 
344
- # Run the reactor until all tasks are finished. Proxies arguments to {#async} immediately before entering the loop, if a block is provided.
345
- def run(...)
346
- Kernel::raise ClosedError if @selector.nil?
347
-
348
- initial_task = self.async(...) if block_given?
444
+ # Stop all children, including transient children.
445
+ #
446
+ # @public Since *Async v1*.
447
+ def stop
448
+ @children&.each do |child|
449
+ child.stop
450
+ end
451
+ end
452
+
453
+ private def run_loop(&block)
349
454
  interrupt = nil
350
455
 
351
456
  begin
352
457
  # In theory, we could use Exception here to be a little bit safer, but we've only shown the case for SignalException to be a problem, so let's not over-engineer this.
353
458
  Thread.handle_interrupt(::SignalException => :never) do
354
- while true
355
- # If we are interrupted, we need to exit:
356
- break if self.interrupted?
357
-
459
+ until self.interrupted?
358
460
  # If we are finished, we need to exit:
359
- break unless self.run_once
461
+ break unless yield
360
462
  end
361
463
  end
362
464
  rescue Interrupt => interrupt
465
+ # If an interrupt did occur during an iteration of the event loop, we need to handle it. More specifically, `self.stop` is not safe to interrupt without potentially corrupting the task tree.
363
466
  Thread.handle_interrupt(::SignalException => :never) do
467
+ Console.debug(self) do |buffer|
468
+ buffer.puts "Scheduler interrupted: #{interrupt.inspect}"
469
+ self.print_hierarchy(buffer)
470
+ end
471
+
364
472
  self.stop
365
473
  end
366
474
 
@@ -368,37 +476,48 @@ module Async
368
476
  end
369
477
 
370
478
  # If the event loop was interrupted, and we finished exiting normally (due to the interrupt), we need to re-raise the interrupt so that the caller can handle it too.
371
- Kernel.raise interrupt if interrupt
479
+ if interrupt
480
+ Kernel.raise(interrupt)
481
+ end
482
+ end
483
+
484
+ # Run the reactor until all tasks are finished. Proxies arguments to {#async} immediately before entering the loop, if a block is provided.
485
+ #
486
+ # Forwards all parameters to {#async} if a block is given.
487
+ #
488
+ # @public Since *Async v1*.
489
+ #
490
+ # @yields {|task| ...} The top level task, if a block is given.
491
+ # @returns [Task] The initial task that was scheduled into the reactor.
492
+ def run(...)
493
+ Kernel.raise ClosedError if @selector.nil?
494
+
495
+ initial_task = self.async(...) if block_given?
496
+
497
+ self.run_loop do
498
+ run_once
499
+ end
372
500
 
373
501
  return initial_task
374
- ensure
375
- Console.debug(self) {"Exiting run-loop because #{$! ? $! : 'finished'}."}
376
502
  end
377
503
 
378
- # Start an asynchronous task within the specified reactor. The task will be
379
- # executed until the first blocking call, at which point it will yield and
380
- # and this method will return.
504
+ # Start an asynchronous task within the specified reactor. The task will be executed until the first blocking call, at which point it will yield and and this method will return.
381
505
  #
382
- # This is the main entry point for scheduling asynchronus tasks.
506
+ # @public Since *Async v1*.
507
+ # @asynchronous May context switch immediately to new task.
508
+ # @deprecated Use {#run} or {Task#async} instead.
383
509
  #
384
510
  # @yields {|task| ...} Executed within the task.
385
511
  # @returns [Task] The task that was scheduled into the reactor.
386
- # @deprecated With no replacement.
387
512
  def async(*arguments, **options, &block)
388
- Kernel::raise ClosedError if @selector.nil?
513
+ # warn "Async::Scheduler#async is deprecated. Use `run` or `Task#async` instead.", uplevel: 1, category: :deprecated
514
+
515
+ Kernel.raise ClosedError if @selector.nil?
389
516
 
390
517
  task = Task.new(Task.current? || self, **options, &block)
391
518
 
392
- # I want to take a moment to explain the logic of this.
393
- # When calling an async block, we deterministically execute it until the
394
- # first blocking operation. We don't *have* to do this - we could schedule
395
- # it for later execution, but it's useful to:
396
- # - Fail at the point of the method call where possible.
397
- # - Execute determinstically where possible.
398
- # - Avoid scheduler overhead if no blocking operation is performed.
399
519
  task.run(*arguments)
400
520
 
401
- # Console.debug "Initial execution of task #{fiber} complete (#{result} -> #{fiber.alive?})..."
402
521
  return task
403
522
  end
404
523
 
@@ -407,7 +526,14 @@ module Async
407
526
  end
408
527
 
409
528
  # Invoke the block, but after the specified timeout, raise {TimeoutError} in any currenly blocking operation. If the block runs to completion before the timeout occurs or there are no non-blocking operations after the timeout expires, the code will complete without any exception.
529
+ #
530
+ # @public Since *Async v1*.
531
+ # @asynchronous May raise an exception at any interruption point (e.g. blocking operations).
532
+ #
410
533
  # @parameter duration [Numeric] The time in seconds, in which the task should complete.
534
+ # @parameter exception [Class] The exception class to raise.
535
+ # @parameter message [String] The message to pass to the exception.
536
+ # @yields {|duration| ...} The block to execute with a timeout.
411
537
  def with_timeout(duration, exception = TimeoutError, message = "execution expired", &block)
412
538
  fiber = Fiber.current
413
539
 
@@ -422,6 +548,15 @@ module Async
422
548
  timer&.cancel!
423
549
  end
424
550
 
551
+ # Invoke the block, but after the specified timeout, raise the specified exception with the given message. If the block runs to completion before the timeout occurs or there are no non-blocking operations after the timeout expires, the code will complete without any exception.
552
+ #
553
+ # @public Since *Async v1* and *Ruby v3.1*. May be invoked from `Timeout.timeout`.
554
+ # @asynchronous May raise an exception at any interruption point (e.g. blocking operations).
555
+ #
556
+ # @parameter duration [Numeric] The time in seconds, in which the task should complete.
557
+ # @parameter exception [Class] The exception class to raise.
558
+ # @parameter message [String] The message to pass to the exception.
559
+ # @yields {|duration| ...} The block to execute with a timeout.
425
560
  def timeout_after(duration, exception, message, &block)
426
561
  with_timeout(duration, exception, message) do |timer|
427
562
  yield duration
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2023, by Samuel Williams.
4
+ # Copyright, 2018-2024, by Samuel Williams.
5
5
 
6
- require_relative 'list'
6
+ require_relative "list"
7
7
 
8
8
  module Async
9
9
  # A synchronization primitive, which limits access to a given resource.
10
- # @public Since `stable-v1`.
10
+ # @public Since *Async v1*.
11
11
  class Semaphore
12
12
  # @parameter limit [Integer] The maximum number of times the semaphore can be acquired before it blocks.
13
13
  # @parameter parent [Task | Semaphore | Nil] The parent for holding any children tasks.