atomic-ruby 0.15.0 → 0.15.1

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: 7a3341865540be4c1099997f8a21e35ac9d16427f7ba2cf6898f568aba7d2372
4
- data.tar.gz: cf4afb42fb8b9680fce99518f5f627827dad0dca8e4725a7ed1b7c409bffd024
3
+ metadata.gz: af7a35affcaa2790e6944ee01eb6af8f4d92b5c2479bb1995128742a562fd395
4
+ data.tar.gz: d3908277e6ef09a70f7d46a057a5db7f60c179885d980b4fbbf407bdade6b073
5
5
  SHA512:
6
- metadata.gz: d1e237a56143ea169b19593dd7564d21e1f0dfe25bdb34ed9fc32a7960e52f1dcea353b1f594d754aa47361b42ec5b7a1bbd06962896c0a2eeaea4cbb4197cac
7
- data.tar.gz: b6592601f77c203782a67024bb075a2f018f8ab89b1e931474009c206b7705b295156f8ff7d31fc5fa54c869d5bd013107768806435703979f747a705addcad9
6
+ metadata.gz: f5a3525b194636ddb439c0c8e9597e3ed1870a8c0165bc1582cb120a45c49b30a8cf6bd60f393a9e3e0c848abf760b181dce1b9b3026199ff8ffb2ba833dcc1a
7
+ data.tar.gz: e89a1f593774fc1722bfc8a9229385682e33a10df705ba78da3c67f904ab566c770b7aebdebc32069ed7f4dfc2781ad52165f08f10cf3fd4e4d723c41687e02f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.15.1] - 2026-08-30
4
+
5
+ - Prevent OS scheduling delays from stopping adaptive thread pool growth
6
+
3
7
  ## [0.15.0] - 2026-08-29
4
8
 
5
9
  - Add adaptive scaling to `AtomicThreadPool` with `max_size:`
data/README.md CHANGED
@@ -122,8 +122,8 @@ 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 waiting for it, so it does not add more threads when GVL contention
126
- is the bottleneck. It returns to `size` when the queue drains.
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.
127
127
 
128
128
  ```ruby
129
129
  pool = AtomicThreadPool.new(size: 4, max_size: 16)
@@ -268,7 +268,7 @@ puts "Atomic Ruby Atomic Bank Account: #{results[2].real.round(6)} seconds"
268
268
 
269
269
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
270
270
  concurrent-ruby version: 1.3.6
271
- atomic-ruby version: 0.15.0
271
+ atomic-ruby version: 0.15.1
272
272
 
273
273
  Balances:
274
274
  Synchronized Bank Account Balance: 975
@@ -359,7 +359,7 @@ end
359
359
 
360
360
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
361
361
  concurrent-ruby version: 1.3.6
362
- atomic-ruby version: 0.15.0
362
+ atomic-ruby version: 0.15.1
363
363
 
364
364
  Warming up --------------------------------------
365
365
  Synchronized Boolean Toggle 157.000 i/100ms
@@ -442,7 +442,7 @@ end
442
442
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_condition_variable_benchmark.rb
443
443
 
444
444
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
445
- atomic-ruby version: 0.15.0
445
+ atomic-ruby version: 0.15.1
446
446
 
447
447
  Warming up --------------------------------------
448
448
  Synchronized Condition Variable Wait/Signal 3.977k i/100ms
@@ -517,7 +517,7 @@ end
517
517
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_queue_benchmark.rb
518
518
 
519
519
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
520
- atomic-ruby version: 0.15.0
520
+ atomic-ruby version: 0.15.1
521
521
 
522
522
  Warming up --------------------------------------
523
523
  Synchronized Queue Push/Pop 181.000 i/100ms
@@ -587,11 +587,11 @@ puts "Atomic Ruby Atomic Thread Pool: #{results[1].real.round(6)} seconds"
587
587
 
588
588
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
589
589
  concurrent-ruby version: 1.3.8
590
- atomic-ruby version: 0.15.0
590
+ atomic-ruby version: 0.15.1
591
591
 
592
592
  Benchmark Results:
593
- Concurrent Ruby Thread Pool: 5.030964 seconds
594
- Atomic Ruby Atomic Thread Pool: 4.676908 seconds
593
+ Concurrent Ruby Thread Pool: 5.079561 seconds
594
+ Atomic Ruby Atomic Thread Pool: 4.99168 seconds
595
595
  ```
596
596
 
597
597
  </details>
@@ -535,12 +535,14 @@ typedef struct {
535
535
  _Atomic unsigned long long running_time;
536
536
  _Atomic unsigned long long waiting_time;
537
537
  _Atomic unsigned long long blocked_time;
538
+ _Atomic unsigned long long running_cpu_time;
538
539
  } atomic_ruby_thread_pool_monitor_t;
539
540
 
540
541
  typedef struct {
541
542
  atomic_ruby_thread_pool_monitor_t *monitor;
542
543
  atomic_ruby_thread_pool_worker_phase_t phase;
543
544
  unsigned long long phase_started_at;
545
+ unsigned long long running_cpu_started_at;
544
546
  } atomic_ruby_thread_pool_worker_state_t;
545
547
 
546
548
  static rb_internal_thread_specific_key_t atomic_ruby_thread_pool_worker_key;
@@ -569,14 +571,27 @@ static unsigned long long atomic_ruby_monotonic_time(void) {
569
571
  return (unsigned long long)time.tv_sec * 1000000000ULL + (unsigned long long)time.tv_nsec;
570
572
  }
571
573
 
574
+ static unsigned long long atomic_ruby_thread_cpu_time(void) {
575
+ struct timespec time;
576
+ if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time) == -1) return 0;
577
+
578
+ return (unsigned long long)time.tv_sec * 1000000000ULL + (unsigned long long)time.tv_nsec;
579
+ }
580
+
572
581
  static void atomic_ruby_thread_pool_worker_leave_phase(atomic_ruby_thread_pool_worker_state_t *state, unsigned long long now) {
573
582
  unsigned long long elapsed = now != 0 && state->phase_started_at != 0 && now >= state->phase_started_at ? now - state->phase_started_at : 0;
574
583
 
575
584
  switch (state->phase) {
576
- case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING:
585
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING: {
586
+ unsigned long long cpu_now = atomic_ruby_thread_cpu_time();
587
+ unsigned long long cpu_elapsed = cpu_now != 0 && state->running_cpu_started_at != 0 && cpu_now >= state->running_cpu_started_at
588
+ ? cpu_now - state->running_cpu_started_at
589
+ : 0;
577
590
  atomic_fetch_sub_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
578
591
  atomic_fetch_add_explicit(&state->monitor->running_time, elapsed, memory_order_relaxed);
592
+ atomic_fetch_add_explicit(&state->monitor->running_cpu_time, cpu_elapsed, memory_order_relaxed);
579
593
  break;
594
+ }
580
595
  case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
581
596
  atomic_fetch_sub_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
582
597
  atomic_fetch_add_explicit(&state->monitor->waiting_time, elapsed, memory_order_relaxed);
@@ -597,6 +612,7 @@ static void atomic_ruby_thread_pool_worker_enter_phase(atomic_ruby_thread_pool_w
597
612
  switch (phase) {
598
613
  case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING:
599
614
  atomic_fetch_add_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
615
+ state->running_cpu_started_at = atomic_ruby_thread_cpu_time();
600
616
  break;
601
617
  case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
602
618
  atomic_fetch_add_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
@@ -638,6 +654,7 @@ static VALUE rb_cThreadPoolMonitor_allocate(VALUE klass) {
638
654
  atomic_init(&monitor->running_time, 0);
639
655
  atomic_init(&monitor->waiting_time, 0);
640
656
  atomic_init(&monitor->blocked_time, 0);
657
+ atomic_init(&monitor->running_cpu_time, 0);
641
658
  return obj;
642
659
  }
643
660
 
@@ -650,6 +667,7 @@ static VALUE rb_cThreadPoolMonitor_register_worker(VALUE self) {
650
667
  state->monitor = monitor;
651
668
  state->phase = ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE;
652
669
  state->phase_started_at = 0;
670
+ state->running_cpu_started_at = 0;
653
671
  rb_internal_thread_specific_set(thread, atomic_ruby_thread_pool_worker_key, state);
654
672
  return Qnil;
655
673
  }
@@ -683,13 +701,14 @@ static VALUE rb_cThreadPoolMonitor_snapshot(VALUE self) {
683
701
  TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
684
702
 
685
703
  return rb_ary_new_from_args(
686
- 6,
704
+ 7,
687
705
  UINT2NUM(atomic_load_explicit(&monitor->running_count, memory_order_relaxed)),
688
706
  UINT2NUM(atomic_load_explicit(&monitor->waiting_count, memory_order_relaxed)),
689
707
  UINT2NUM(atomic_load_explicit(&monitor->blocked_count, memory_order_relaxed)),
690
708
  ULL2NUM(atomic_load_explicit(&monitor->running_time, memory_order_relaxed)),
691
709
  ULL2NUM(atomic_load_explicit(&monitor->waiting_time, memory_order_relaxed)),
692
- ULL2NUM(atomic_load_explicit(&monitor->blocked_time, memory_order_relaxed))
710
+ ULL2NUM(atomic_load_explicit(&monitor->blocked_time, memory_order_relaxed)),
711
+ ULL2NUM(atomic_load_explicit(&monitor->running_cpu_time, memory_order_relaxed))
693
712
  );
694
713
  }
695
714
 
@@ -69,7 +69,8 @@ module AtomicRuby
69
69
  #
70
70
  # When `max_size` is greater than `size`, the pool adds temporary workers
71
71
  # if work remains queued while its active workers spend most of their time
72
- # blocked outside the GVL, but not when they are waiting for the GVL.
72
+ # blocked outside the GVL, but not when Ruby execution is using the
73
+ # available CPU.
73
74
  # Temporary workers leave after the queue remains empty, returning the pool
74
75
  # to its baseline size. Omitting `max_size` creates a fixed-size pool.
75
76
  #
@@ -403,7 +404,7 @@ module AtomicRuby
403
404
  pressure_started_at = now
404
405
  phase_samples.fill(0)
405
406
  elsif now - pressure_started_at >= AUTOSCALE_WINDOW
406
- if should_grow?(previous_snapshot, snapshot, phase_samples)
407
+ if should_grow?(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
407
408
  growth_samples += 1
408
409
  if growth_samples >= AUTOSCALE_GROWTH_SAMPLES
409
410
  spawn_worker(temporary: true)
@@ -427,25 +428,28 @@ module AtomicRuby
427
428
  # Returns whether another worker is likely to improve throughput.
428
429
  #
429
430
  # Requiring workers to spend a majority of their time blocked outside the
430
- # GVL recognizes blocking operations. The pool only grows when less than
431
- # two percent of their time was spent waiting for the GVL.
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.
432
435
  #
433
436
  # @param previous_snapshot [Array<Integer>] previous GVL state snapshot
434
437
  # @param snapshot [Array<Integer>] current GVL state snapshot
435
438
  # @param phase_samples [Array<Integer>] sampled current GVL states
439
+ # @param elapsed_time [Float] seconds covered by the snapshots
436
440
  # @return [true, false]
437
441
  #
438
- # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples) -> bool
439
- def should_grow?(previous_snapshot, snapshot, phase_samples)
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)
440
444
  @threads.select!(&:alive?)
441
445
  workers = @threads
442
446
  return false if workers.length >= @max_size
443
447
  return false if @active_thread_count.value < workers.length
444
448
 
445
- _running_count, _waiting_count, _blocked_count, running_time, waiting_time, blocked_time = snapshot
446
- running_time -= previous_snapshot[3]
447
- waiting_time -= previous_snapshot[4]
448
- blocked_time -= previous_snapshot[5]
449
+ running_time = snapshot[3] - previous_snapshot[3]
450
+ waiting_time = snapshot[4] - previous_snapshot[4]
451
+ blocked_time = snapshot[5] - previous_snapshot[5]
452
+ running_cpu_time = snapshot[6] - previous_snapshot[6]
449
453
  total_time = running_time + waiting_time + blocked_time
450
454
 
451
455
  minimum_measured_time = (AUTOSCALE_WINDOW * 1_000_000_000 * workers.length / 2).to_i
@@ -455,8 +459,8 @@ module AtomicRuby
455
459
  end
456
460
 
457
461
  total_time.positive? &&
458
- waiting_time * 50 < total_time &&
459
- blocked_time * 2 > total_time
462
+ blocked_time * 2 > total_time &&
463
+ running_cpu_time * 2 < elapsed_time * 1_000_000_000
460
464
  end
461
465
  end
462
466
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicRuby
4
- VERSION = "0.15.0"
4
+ VERSION = "0.15.1"
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.0
4
+ version: 0.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young