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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +15 -13
- data/lib/atomic-ruby/atomic_thread_pool.rb +90 -32
- data/lib/atomic-ruby/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecdfa4ed4bb185acac2d6ce288418318e23c1e4825fddff06d74a702d19d0d39
|
|
4
|
+
data.tar.gz: d2c935cb440869f1f622a30a585d71767ef57e3d08de78f332810e63acff1aa5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
126
|
-
Ruby execution is the bottleneck.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
625
|
+
atomic-ruby version: 0.16.0
|
|
624
626
|
|
|
625
627
|
Fixed Pool Results:
|
|
626
|
-
Concurrent Ruby Fixed Thread Pool: 5.
|
|
627
|
-
Atomic Ruby Fixed Thread Pool: 4.
|
|
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.
|
|
631
|
-
Atomic Ruby Adaptive Thread Pool: 0.
|
|
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.
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
74
|
-
#
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
337
|
-
unless
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
#
|
|
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
|
-
@
|
|
446
|
+
@idle_trim_requested.make_true
|
|
400
447
|
@work_available.broadcast
|
|
401
448
|
end
|
|
402
449
|
else
|
|
403
|
-
@
|
|
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 >=
|
|
411
|
-
|
|
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
|
|
482
|
+
sleep((@queue.empty? ? AUTOSCALE_MAX_WINDOW : sampling_window) / AUTOSCALE_SAMPLES_PER_WINDOW)
|
|
425
483
|
end
|
|
426
484
|
end
|
|
427
485
|
|
|
428
|
-
# Returns
|
|
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
|
|
432
|
-
#
|
|
433
|
-
#
|
|
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 [
|
|
497
|
+
# @return [:up, :down, nil]
|
|
441
498
|
#
|
|
442
|
-
# @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples, Float elapsed_time) ->
|
|
443
|
-
def
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
data/lib/atomic-ruby/version.rb
CHANGED
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.
|
|
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.
|
|
55
|
+
rubygems_version: 4.0.20
|
|
56
56
|
specification_version: 4
|
|
57
57
|
summary: Atomic primitives for Ruby
|
|
58
58
|
test_files: []
|