atomic-ruby 0.15.4 → 0.16.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7c0136cf37fe5133affc1f090fa517636f5bfda0f5f43564a4877eee15bae3f
4
- data.tar.gz: 0ae3a3aadabcc2bb69621df62188800451289a46032fa57a49c1af9326bcc31e
3
+ metadata.gz: ecdfa4ed4bb185acac2d6ce288418318e23c1e4825fddff06d74a702d19d0d39
4
+ data.tar.gz: d2c935cb440869f1f622a30a585d71767ef57e3d08de78f332810e63acff1aa5
5
5
  SHA512:
6
- metadata.gz: 1786faa43a25379cbe68c9de2372c6168a62ab225436c45bbf5049791c39191e3ed75955ab2f5f6385069ddeb86640aa11ba431ce9f2ab54335005a61bd42092
7
- data.tar.gz: 704c7e7224c9eec351aa0174f597c10c992f4e1594921b8ca91e9ad2df43a6b3eb2c260b9b25ab2d47f8fb0934afa9739ed5312fb00d5d7519d9283c6bf68170
6
+ metadata.gz: 10e31be43b93f0d22de13ec3bea7f7d8bf411aa5282c5d36de2ee8da7693b968ab1aacbdc6c428317450670585bb857f0b8ecf657b71af6f18fcea9ab8c8c314
7
+ data.tar.gz: d99ed0322536e33b725a5ddda3202eced49bc71de567d6505079d3fbcd8b11b4df9d2216c834a5b0fa546105860eef3ccf0848d4455870992061991be6f772aa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.16.0] - 2026-10-18
4
+
5
+ - Add `AtomicThreadPool#wait_for_capacity`
6
+
7
+ ## [0.15.5] - 2026-09-03
8
+
9
+ - Adapt `AtomicThreadPool` scaling to workload pressure
10
+
3
11
  ## [0.15.4] - 2026-09-01
4
12
 
5
13
  - Keep temporary `AtomicThreadPool` workers alive for five seconds
data/README.md CHANGED
@@ -122,8 +122,10 @@ p results.sort #=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
122
122
 
123
123
  Pass `max_size` to let the pool temporarily add workers when queued work is
124
124
  held up by blocking operations. The pool measures time spent blocked outside
125
- the GVL and CPU time used by its workers, so it does not add more threads when
126
- Ruby execution is the bottleneck. It returns to `size` when the queue drains.
125
+ the GVL and CPU time used while running Ruby, so it does not add more threads
126
+ when Ruby execution is the bottleneck. Temporary workers remain available
127
+ between blocking bursts, but retire when Ruby execution becomes the bottleneck
128
+ or they remain idle.
127
129
 
128
130
  ```ruby
129
131
  pool = AtomicThreadPool.new(size: 4, max_size: 16)
@@ -268,7 +270,7 @@ puts "Atomic Ruby Atomic Bank Account: #{results[2].real.round(6)} seconds"
268
270
 
269
271
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
270
272
  concurrent-ruby version: 1.3.8
271
- atomic-ruby version: 0.15.4
273
+ atomic-ruby version: 0.16.0
272
274
 
273
275
  Balances:
274
276
  Synchronized Bank Account Balance: 975
@@ -359,7 +361,7 @@ end
359
361
 
360
362
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
361
363
  concurrent-ruby version: 1.3.8
362
- atomic-ruby version: 0.15.4
364
+ atomic-ruby version: 0.16.0
363
365
 
364
366
  Warming up --------------------------------------
365
367
  Synchronized Boolean Toggle 165.000 i/100ms
@@ -442,7 +444,7 @@ end
442
444
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_condition_variable_benchmark.rb
443
445
 
444
446
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
445
- atomic-ruby version: 0.15.4
447
+ atomic-ruby version: 0.16.0
446
448
 
447
449
  Warming up --------------------------------------
448
450
  Synchronized Condition Variable Wait/Signal 4.062k i/100ms
@@ -517,7 +519,7 @@ end
517
519
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_queue_benchmark.rb
518
520
 
519
521
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
520
- atomic-ruby version: 0.15.4
522
+ atomic-ruby version: 0.16.0
521
523
 
522
524
  Warming up --------------------------------------
523
525
  Synchronized Queue Push/Pop 184.000 i/100ms
@@ -618,17 +620,17 @@ puts "Atomic Ruby Adaptive Thread Pool: #{adaptive_results[1].real.round(6)} sec
618
620
  ```
619
621
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_thread_pool_benchmark.rb
620
622
 
621
- ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
623
+ ruby version: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +YJIT +PRISM [arm64-darwin23]
622
624
  concurrent-ruby version: 1.3.8
623
- atomic-ruby version: 0.15.4
625
+ atomic-ruby version: 0.16.0
624
626
 
625
627
  Fixed Pool Results:
626
- Concurrent Ruby Fixed Thread Pool: 5.069464 seconds
627
- Atomic Ruby Fixed Thread Pool: 4.791382 seconds
628
+ Concurrent Ruby Fixed Thread Pool: 5.147226 seconds
629
+ Atomic Ruby Fixed Thread Pool: 4.743301 seconds
628
630
 
629
631
  Adaptive Pool Results:
630
- Concurrent Ruby IO Thread Pool: 0.22309 seconds
631
- Atomic Ruby Adaptive Thread Pool: 0.61561 seconds
632
+ Concurrent Ruby IO Thread Pool: 0.217971 seconds
633
+ Atomic Ruby Adaptive Thread Pool: 0.227701 seconds
632
634
  ```
633
635
 
634
636
  </details>
@@ -682,7 +684,7 @@ puts "Atomic Ruby Atomic Count Down Latch: #{results[1].total.round(6)} CPU seco
682
684
 
683
685
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
684
686
  concurrent-ruby version: 1.3.8
685
- atomic-ruby version: 0.15.4
687
+ atomic-ruby version: 0.16.0
686
688
 
687
689
  Benchmark Results:
688
690
  Concurrent Ruby Count Down Latch: 0.000511 CPU seconds, 2.001465 elapsed seconds
@@ -52,9 +52,10 @@ module AtomicRuby
52
52
  # that cannot be safely shared across ractors.
53
53
  class AtomicThreadPool
54
54
  AUTOSCALE_IDLE_TIME = 5
55
- AUTOSCALE_INTERVAL = 0.01
56
- AUTOSCALE_WINDOW = 0.05
57
- private_constant :AUTOSCALE_IDLE_TIME, :AUTOSCALE_INTERVAL, :AUTOSCALE_WINDOW
55
+ AUTOSCALE_INITIAL_WINDOW = 0.002
56
+ AUTOSCALE_MAX_WINDOW = 0.05
57
+ AUTOSCALE_SAMPLES_PER_WINDOW = 5
58
+ private_constant :AUTOSCALE_IDLE_TIME, :AUTOSCALE_INITIAL_WINDOW, :AUTOSCALE_MAX_WINDOW, :AUTOSCALE_SAMPLES_PER_WINDOW
58
59
 
59
60
  class Error < StandardError; end
60
61
 
@@ -70,8 +71,9 @@ module AtomicRuby
70
71
  # if work remains queued while its active workers spend most of their time
71
72
  # blocked outside the GVL, but not when Ruby execution is using the
72
73
  # available CPU.
73
- # Temporary workers remain available between bursts before the pool returns
74
- # to its baseline size. Omitting `max_size` creates a fixed-size pool.
74
+ # Temporary workers remain available between blocking bursts, but retire
75
+ # when Ruby execution becomes the bottleneck or they remain idle. Omitting
76
+ # `max_size` creates a fixed-size pool.
75
77
  #
76
78
  # @param size [Integer] The baseline number of worker threads (must be positive)
77
79
  # @param max_size [Integer, Float, nil] Maximum number of worker threads,
@@ -116,13 +118,15 @@ module AtomicRuby
116
118
  @queue = AtomicQueue.new
117
119
  @shutdown = AtomicBoolean.new(false)
118
120
  @work_available = AtomicConditionVariable.new
121
+ @capacity_changed = AtomicConditionVariable.new
119
122
  @alive_thread_count = Atom.new(0)
120
123
  @active_thread_count = Atom.new(0)
121
124
  @threads = []
122
125
  @next_thread_number = 0
123
126
  if adaptive?
124
127
  @autoscale_available = AtomicConditionVariable.new
125
- @trim_requested = AtomicBoolean.new(false)
128
+ @idle_trim_requested = AtomicBoolean.new(false)
129
+ @ruby_cpu_trim_requested = AtomicBoolean.new(false)
126
130
  @thread_pool_monitor = ThreadPoolMonitor.new
127
131
  @thread_pool_monitor.start
128
132
  end
@@ -155,7 +159,7 @@ module AtomicRuby
155
159
  # @rbs (Proc work) -> void
156
160
  def <<(work)
157
161
  Thread.handle_interrupt(Exception => :never) do
158
- @trim_requested&.make_false
162
+ @idle_trim_requested&.make_false
159
163
  raise EnqueuedWorkAfterShutdownError unless @queue.push(work)
160
164
 
161
165
  @work_available.signal
@@ -235,6 +239,25 @@ module AtomicRuby
235
239
  @active_thread_count.value
236
240
  end
237
241
 
242
+ # Blocks until the given block returns a truthy value, checking it again
243
+ # whenever a worker takes or finishes work.
244
+ #
245
+ # Useful for applying backpressure, such as holding off on accepting more
246
+ # work until the pool has capacity for it.
247
+ #
248
+ # @yieldreturn [untyped] Truthy to stop waiting, falsy to keep waiting
249
+ # @return [untyped] The first truthy value returned by the block
250
+ #
251
+ # @example Wait until no work is queued
252
+ # pool = AtomicThreadPool.new(size: 2)
253
+ # 5.times { pool << proc { sleep(0.1) } }
254
+ # pool.wait_for_capacity { pool.queue_length.zero? } #=> true
255
+ #
256
+ # @rbs () { () -> untyped } -> untyped
257
+ def wait_for_capacity(&block)
258
+ @capacity_changed.wait(&block)
259
+ end
260
+
238
261
  # Gracefully shuts down the thread pool.
239
262
  #
240
263
  # This method:
@@ -309,7 +332,8 @@ module AtomicRuby
309
332
 
310
333
  # Creates a worker thread.
311
334
  #
312
- # Temporary workers leave after the queue remains empty.
335
+ # Temporary workers retire when Ruby execution becomes the bottleneck or
336
+ # they remain idle.
313
337
  #
314
338
  # @param temporary [true, false] whether the worker belongs above the baseline
315
339
  # @return [Thread]
@@ -333,10 +357,13 @@ module AtomicRuby
333
357
  should_exit = false
334
358
 
335
359
  @work_available.wait do
336
- work = @queue.pop
337
- unless work
338
- should_exit = @shutdown.true? && @queue.empty?
339
- should_exit ||= temporary && @trim_requested.true? && @queue.empty?
360
+ should_exit = temporary && @ruby_cpu_trim_requested.true?
361
+ unless should_exit
362
+ work = @queue.pop
363
+ unless work
364
+ should_exit = @shutdown.true? && @queue.empty?
365
+ should_exit ||= temporary && @idle_trim_requested.true? && @queue.empty?
366
+ end
340
367
  end
341
368
  work || should_exit
342
369
  end
@@ -344,6 +371,11 @@ module AtomicRuby
344
371
  break if should_exit
345
372
 
346
373
  @active_thread_count.swap { |current_count| current_count + 1 }
374
+ @capacity_changed.broadcast unless @capacity_changed.waiter_count.zero?
375
+ if temporary
376
+ work_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
377
+ running_cpu_time_at_start = @thread_pool_monitor.snapshot[6]
378
+ end
347
379
  begin
348
380
  @thread_pool_monitor&.start_work
349
381
  work.call
@@ -357,6 +389,15 @@ module AtomicRuby
357
389
  ensure
358
390
  @thread_pool_monitor&.stop_work
359
391
  @active_thread_count.swap { |current_count| current_count - 1 }
392
+ @capacity_changed.broadcast unless @capacity_changed.waiter_count.zero?
393
+ if temporary && !@queue.empty?
394
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - work_started_at
395
+ running_cpu_time = @thread_pool_monitor.snapshot[6] - running_cpu_time_at_start
396
+ if running_cpu_time * 2 >= elapsed_time * 1_000_000_000
397
+ @ruby_cpu_trim_requested.make_true
398
+ @work_available.broadcast
399
+ end
400
+ end
360
401
  end
361
402
  end
362
403
  ensure
@@ -369,7 +410,10 @@ module AtomicRuby
369
410
  thread
370
411
  end
371
412
 
372
- # Adds temporary workers while queued work is held up by blocked workers.
413
+ # Scales temporary workers in response to workload pressure.
414
+ #
415
+ # Sampling starts aggressively so the pool can react to short bursts, then
416
+ # backs off when more workers would not improve throughput.
373
417
  #
374
418
  # @return [void]
375
419
  #
@@ -379,8 +423,10 @@ module AtomicRuby
379
423
  pressure_started_at = nil
380
424
  idle_started_at = nil
381
425
  phase_samples = [0, 0, 0]
426
+ sampling_window = AUTOSCALE_INITIAL_WINDOW
382
427
 
383
428
  loop do
429
+ @ruby_cpu_trim_requested.make_false if @alive_thread_count.value <= @size
384
430
  @autoscale_available.wait do
385
431
  @shutdown.true? || !@queue.empty? || @alive_thread_count.value > @size
386
432
  end
@@ -394,24 +440,36 @@ module AtomicRuby
394
440
  previous_snapshot = snapshot
395
441
  idle_started_at ||= now
396
442
  phase_samples.fill(0)
443
+ sampling_window = AUTOSCALE_INITIAL_WINDOW
397
444
 
398
445
  if now - idle_started_at >= AUTOSCALE_IDLE_TIME
399
- @trim_requested.make_true
446
+ @idle_trim_requested.make_true
400
447
  @work_available.broadcast
401
448
  end
402
449
  else
403
- @trim_requested.make_false
450
+ @idle_trim_requested.make_false
404
451
  idle_started_at = nil
405
452
 
406
453
  if pressure_started_at.nil?
407
454
  previous_snapshot = snapshot
408
455
  pressure_started_at = now
409
456
  phase_samples.fill(0)
410
- elsif now - pressure_started_at >= AUTOSCALE_WINDOW
411
- if should_grow?(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
457
+ elsif now - pressure_started_at >= sampling_window
458
+ case scaling_direction(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
459
+ when :up
460
+ @ruby_cpu_trim_requested.make_false
412
461
  worker_count = @threads.length
413
462
  workers_to_add = [worker_count, @queue.size, @max_size - worker_count].min
414
463
  workers_to_add.times { spawn_worker(temporary: true) }
464
+ sampling_window = AUTOSCALE_INITIAL_WINDOW
465
+ when :down
466
+ unless @ruby_cpu_trim_requested.true?
467
+ @ruby_cpu_trim_requested.make_true
468
+ @work_available.broadcast
469
+ end
470
+ sampling_window = [sampling_window * 2, AUTOSCALE_MAX_WINDOW].min
471
+ else
472
+ sampling_window = [sampling_window * 2, AUTOSCALE_MAX_WINDOW].min
415
473
  end
416
474
  previous_snapshot = snapshot
417
475
  pressure_started_at = now
@@ -421,30 +479,28 @@ module AtomicRuby
421
479
  end
422
480
  end
423
481
 
424
- sleep AUTOSCALE_INTERVAL
482
+ sleep((@queue.empty? ? AUTOSCALE_MAX_WINDOW : sampling_window) / AUTOSCALE_SAMPLES_PER_WINDOW)
425
483
  end
426
484
  end
427
485
 
428
- # Returns whether another worker is likely to improve throughput.
486
+ # Returns the direction in which the pool should scale.
429
487
  #
430
488
  # Requiring workers to spend a majority of their time blocked outside the
431
- # GVL recognizes blocking operations. The pool only grows while its workers
432
- # use less than half of one CPU, avoiding extra threads when Ruby
433
- # execution is the bottleneck without mistaking OS scheduling delays for
434
- # GVL contention.
489
+ # GVL recognizes blocking operations. The pool grows while Ruby execution
490
+ # uses less than half of one CPU and shrinks when Ruby execution becomes
491
+ # the bottleneck, without mistaking OS scheduling delays for GVL contention.
435
492
  #
436
493
  # @param previous_snapshot [Array<Integer>] previous GVL state snapshot
437
494
  # @param snapshot [Array<Integer>] current GVL state snapshot
438
495
  # @param phase_samples [Array<Integer>] sampled current GVL states
439
496
  # @param elapsed_time [Float] seconds covered by the snapshots
440
- # @return [true, false]
497
+ # @return [:up, :down, nil]
441
498
  #
442
- # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples, Float elapsed_time) -> bool
443
- def should_grow?(previous_snapshot, snapshot, phase_samples, elapsed_time)
499
+ # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples, Float elapsed_time) -> (:up | :down)?
500
+ def scaling_direction(previous_snapshot, snapshot, phase_samples, elapsed_time)
444
501
  @threads.select!(&:alive?)
445
502
  workers = @threads
446
- return false if workers.length >= @max_size
447
- return false if @active_thread_count.value < workers.length
503
+ return if @active_thread_count.value < workers.length
448
504
 
449
505
  running_time = snapshot[3] - previous_snapshot[3]
450
506
  waiting_time = snapshot[4] - previous_snapshot[4]
@@ -452,15 +508,17 @@ module AtomicRuby
452
508
  running_cpu_time = snapshot[6] - previous_snapshot[6]
453
509
  total_time = running_time + waiting_time + blocked_time
454
510
 
455
- minimum_measured_time = (AUTOSCALE_WINDOW * 1_000_000_000 * workers.length / 2).to_i
511
+ minimum_measured_time = (elapsed_time * 1_000_000_000 * workers.length / 2).to_i
456
512
  if total_time < minimum_measured_time
457
513
  running_time, waiting_time, blocked_time = phase_samples
458
514
  total_time = running_time + waiting_time + blocked_time
459
515
  end
460
516
 
461
- total_time.positive? &&
462
- blocked_time * 2 > total_time &&
463
- running_cpu_time * 2 < elapsed_time * 1_000_000_000
517
+ ruby_cpu_bound = running_cpu_time * 2 >= elapsed_time * 1_000_000_000
518
+ blocking = total_time.positive? && blocked_time * 2 > total_time && !ruby_cpu_bound
519
+
520
+ return :up if blocking && workers.length < @max_size
521
+ return :down if ruby_cpu_bound && workers.length > @size
464
522
  end
465
523
  end
466
524
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicRuby
4
- VERSION = "0.15.4"
4
+ VERSION = "0.16.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atomic-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.4
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young
@@ -52,7 +52,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
54
  requirements: []
55
- rubygems_version: 4.0.16
55
+ rubygems_version: 4.0.20
56
56
  specification_version: 4
57
57
  summary: Atomic primitives for Ruby
58
58
  test_files: []