atomic-ruby 0.15.0 → 0.15.2

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: 9c6210195ad6d82fbbb6a84f66bc8ce5faef54d50ed3227c3655ec43a996a9f6
4
+ data.tar.gz: eef94abbb8c8bfb1d9a54d36f8f8b0404d49210e6e6a109851fa014a6512aaa5
5
5
  SHA512:
6
- metadata.gz: d1e237a56143ea169b19593dd7564d21e1f0dfe25bdb34ed9fc32a7960e52f1dcea353b1f594d754aa47361b42ec5b7a1bbd06962896c0a2eeaea4cbb4197cac
7
- data.tar.gz: b6592601f77c203782a67024bb075a2f018f8ab89b1e931474009c206b7705b295156f8ff7d31fc5fa54c869d5bd013107768806435703979f747a705addcad9
6
+ metadata.gz: c89b7c62bda07206115c80c45088d1ae44e5c3aa84288932fae8fad51312d3490bf9510af559178fe6311e5d8ce4ecf87dca0da3420ced47a0113783e63a741d
7
+ data.tar.gz: a5c5ecee1ce3f96b5e2c525897c1c979fa3f99b7be6cd971d6882e2a43bf9b735f0de58bcf67dbf1179b0298a006a25ba45e910ee0c104f965c305e4af93f6be
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.15.2] - 2026-08-30
4
+
5
+ - Avoid thread event hook overhead for fixed thread pools
6
+
7
+ ## [0.15.1] - 2026-08-30
8
+
9
+ - Prevent OS scheduling delays from stopping adaptive thread pool growth
10
+
3
11
  ## [0.15.0] - 2026-08-29
4
12
 
5
13
  - 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.2
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.2
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.2
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.2
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.2
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.081233 seconds
594
+ Atomic Ruby Atomic Thread Pool: 4.737824 seconds
595
595
  ```
596
596
 
597
597
  </details>
@@ -535,17 +535,39 @@ 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;
539
+ bool active;
538
540
  } atomic_ruby_thread_pool_monitor_t;
539
541
 
540
542
  typedef struct {
541
543
  atomic_ruby_thread_pool_monitor_t *monitor;
542
544
  atomic_ruby_thread_pool_worker_phase_t phase;
543
545
  unsigned long long phase_started_at;
546
+ unsigned long long running_cpu_started_at;
544
547
  } atomic_ruby_thread_pool_worker_state_t;
545
548
 
546
549
  static rb_internal_thread_specific_key_t atomic_ruby_thread_pool_worker_key;
550
+ static rb_nativethread_lock_t atomic_ruby_thread_pool_monitor_lock;
551
+ static unsigned int atomic_ruby_thread_pool_monitor_count;
552
+ static rb_internal_thread_event_hook_t *atomic_ruby_thread_pool_event_hook;
553
+
554
+ static void atomic_ruby_thread_pool_event_callback(rb_event_flag_t event, const rb_internal_thread_event_data_t *event_data, void *user_data);
555
+
556
+ static void atomic_ruby_thread_pool_monitor_stop(atomic_ruby_thread_pool_monitor_t *monitor) {
557
+ rb_nativethread_lock_lock(&atomic_ruby_thread_pool_monitor_lock);
558
+ if (monitor->active) {
559
+ monitor->active = false;
560
+ atomic_ruby_thread_pool_monitor_count--;
561
+ if (atomic_ruby_thread_pool_monitor_count == 0) {
562
+ rb_internal_thread_remove_event_hook(atomic_ruby_thread_pool_event_hook);
563
+ atomic_ruby_thread_pool_event_hook = NULL;
564
+ }
565
+ }
566
+ rb_nativethread_lock_unlock(&atomic_ruby_thread_pool_monitor_lock);
567
+ }
547
568
 
548
569
  static void atomic_ruby_thread_pool_monitor_free(void *ptr) {
570
+ atomic_ruby_thread_pool_monitor_stop(ptr);
549
571
  xfree(ptr);
550
572
  }
551
573
 
@@ -569,14 +591,27 @@ static unsigned long long atomic_ruby_monotonic_time(void) {
569
591
  return (unsigned long long)time.tv_sec * 1000000000ULL + (unsigned long long)time.tv_nsec;
570
592
  }
571
593
 
594
+ static unsigned long long atomic_ruby_thread_cpu_time(void) {
595
+ struct timespec time;
596
+ if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time) == -1) return 0;
597
+
598
+ return (unsigned long long)time.tv_sec * 1000000000ULL + (unsigned long long)time.tv_nsec;
599
+ }
600
+
572
601
  static void atomic_ruby_thread_pool_worker_leave_phase(atomic_ruby_thread_pool_worker_state_t *state, unsigned long long now) {
573
602
  unsigned long long elapsed = now != 0 && state->phase_started_at != 0 && now >= state->phase_started_at ? now - state->phase_started_at : 0;
574
603
 
575
604
  switch (state->phase) {
576
- case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING:
605
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING: {
606
+ unsigned long long cpu_now = atomic_ruby_thread_cpu_time();
607
+ unsigned long long cpu_elapsed = cpu_now != 0 && state->running_cpu_started_at != 0 && cpu_now >= state->running_cpu_started_at
608
+ ? cpu_now - state->running_cpu_started_at
609
+ : 0;
577
610
  atomic_fetch_sub_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
578
611
  atomic_fetch_add_explicit(&state->monitor->running_time, elapsed, memory_order_relaxed);
612
+ atomic_fetch_add_explicit(&state->monitor->running_cpu_time, cpu_elapsed, memory_order_relaxed);
579
613
  break;
614
+ }
580
615
  case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
581
616
  atomic_fetch_sub_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
582
617
  atomic_fetch_add_explicit(&state->monitor->waiting_time, elapsed, memory_order_relaxed);
@@ -597,6 +632,7 @@ static void atomic_ruby_thread_pool_worker_enter_phase(atomic_ruby_thread_pool_w
597
632
  switch (phase) {
598
633
  case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING:
599
634
  atomic_fetch_add_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
635
+ state->running_cpu_started_at = atomic_ruby_thread_cpu_time();
600
636
  break;
601
637
  case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
602
638
  atomic_fetch_add_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
@@ -638,9 +674,38 @@ static VALUE rb_cThreadPoolMonitor_allocate(VALUE klass) {
638
674
  atomic_init(&monitor->running_time, 0);
639
675
  atomic_init(&monitor->waiting_time, 0);
640
676
  atomic_init(&monitor->blocked_time, 0);
677
+ atomic_init(&monitor->running_cpu_time, 0);
678
+ monitor->active = false;
641
679
  return obj;
642
680
  }
643
681
 
682
+ static VALUE rb_cThreadPoolMonitor_start(VALUE self) {
683
+ atomic_ruby_thread_pool_monitor_t *monitor;
684
+ TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
685
+ rb_nativethread_lock_lock(&atomic_ruby_thread_pool_monitor_lock);
686
+ if (!monitor->active) {
687
+ monitor->active = true;
688
+ if (atomic_ruby_thread_pool_monitor_count++ == 0) {
689
+ atomic_ruby_thread_pool_event_hook = rb_internal_thread_add_event_hook(
690
+ atomic_ruby_thread_pool_event_callback,
691
+ RUBY_INTERNAL_THREAD_EVENT_READY |
692
+ RUBY_INTERNAL_THREAD_EVENT_RESUMED |
693
+ RUBY_INTERNAL_THREAD_EVENT_SUSPENDED,
694
+ NULL
695
+ );
696
+ }
697
+ }
698
+ rb_nativethread_lock_unlock(&atomic_ruby_thread_pool_monitor_lock);
699
+ return Qnil;
700
+ }
701
+
702
+ static VALUE rb_cThreadPoolMonitor_stop(VALUE self) {
703
+ atomic_ruby_thread_pool_monitor_t *monitor;
704
+ TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
705
+ atomic_ruby_thread_pool_monitor_stop(monitor);
706
+ return Qnil;
707
+ }
708
+
644
709
  static VALUE rb_cThreadPoolMonitor_register_worker(VALUE self) {
645
710
  atomic_ruby_thread_pool_monitor_t *monitor;
646
711
  TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
@@ -650,6 +715,7 @@ static VALUE rb_cThreadPoolMonitor_register_worker(VALUE self) {
650
715
  state->monitor = monitor;
651
716
  state->phase = ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE;
652
717
  state->phase_started_at = 0;
718
+ state->running_cpu_started_at = 0;
653
719
  rb_internal_thread_specific_set(thread, atomic_ruby_thread_pool_worker_key, state);
654
720
  return Qnil;
655
721
  }
@@ -683,13 +749,14 @@ static VALUE rb_cThreadPoolMonitor_snapshot(VALUE self) {
683
749
  TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
684
750
 
685
751
  return rb_ary_new_from_args(
686
- 6,
752
+ 7,
687
753
  UINT2NUM(atomic_load_explicit(&monitor->running_count, memory_order_relaxed)),
688
754
  UINT2NUM(atomic_load_explicit(&monitor->waiting_count, memory_order_relaxed)),
689
755
  UINT2NUM(atomic_load_explicit(&monitor->blocked_count, memory_order_relaxed)),
690
756
  ULL2NUM(atomic_load_explicit(&monitor->running_time, memory_order_relaxed)),
691
757
  ULL2NUM(atomic_load_explicit(&monitor->waiting_time, memory_order_relaxed)),
692
- ULL2NUM(atomic_load_explicit(&monitor->blocked_time, memory_order_relaxed))
758
+ ULL2NUM(atomic_load_explicit(&monitor->blocked_time, memory_order_relaxed)),
759
+ ULL2NUM(atomic_load_explicit(&monitor->running_cpu_time, memory_order_relaxed))
693
760
  );
694
761
  }
695
762
 
@@ -737,15 +804,13 @@ RUBY_FUNC_EXPORTED void Init_atomic_ruby(void) {
737
804
  rb_define_private_method(rb_cAtomicConditionVariable, "_waiter_count", rb_cAtomicConditionVariable_waiter_count, 0);
738
805
 
739
806
  atomic_ruby_thread_pool_worker_key = rb_internal_thread_specific_key_create();
740
- rb_internal_thread_add_event_hook(
741
- atomic_ruby_thread_pool_event_callback,
742
- RUBY_INTERNAL_THREAD_EVENT_READY |
743
- RUBY_INTERNAL_THREAD_EVENT_RESUMED |
744
- RUBY_INTERNAL_THREAD_EVENT_SUSPENDED,
745
- NULL
746
- );
807
+ rb_nativethread_lock_initialize(&atomic_ruby_thread_pool_monitor_lock);
808
+ atomic_ruby_thread_pool_monitor_count = 0;
809
+ atomic_ruby_thread_pool_event_hook = NULL;
747
810
  VALUE rb_cThreadPoolMonitor = rb_define_class_under(rb_mAtomicRuby, "ThreadPoolMonitor", rb_cObject);
748
811
  rb_define_alloc_func(rb_cThreadPoolMonitor, rb_cThreadPoolMonitor_allocate);
812
+ rb_define_method(rb_cThreadPoolMonitor, "start", rb_cThreadPoolMonitor_start, 0);
813
+ rb_define_method(rb_cThreadPoolMonitor, "stop", rb_cThreadPoolMonitor_stop, 0);
749
814
  rb_define_method(rb_cThreadPoolMonitor, "register_worker", rb_cThreadPoolMonitor_register_worker, 0);
750
815
  rb_define_method(rb_cThreadPoolMonitor, "unregister_worker", rb_cThreadPoolMonitor_unregister_worker, 0);
751
816
  rb_define_method(rb_cThreadPoolMonitor, "start_work", rb_cThreadPoolMonitor_start_work, 0);
@@ -4,6 +4,7 @@
4
4
  #include "ruby.h"
5
5
  #include "ruby/atomic.h"
6
6
  #include "ruby/thread.h"
7
+ #include "ruby/thread_native.h"
7
8
  #include "ruby/version.h"
8
9
 
9
10
  #include <stdatomic.h>
@@ -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
  #
@@ -124,6 +125,7 @@ module AtomicRuby
124
125
  @autoscale_available = AtomicConditionVariable.new
125
126
  @trim_requested = AtomicBoolean.new(false)
126
127
  @thread_pool_monitor = ThreadPoolMonitor.new
128
+ @thread_pool_monitor.start
127
129
  end
128
130
 
129
131
  start
@@ -263,6 +265,7 @@ module AtomicRuby
263
265
  @work_available.broadcast
264
266
  @autoscaler&.join
265
267
  @threads.each(&:join)
268
+ @thread_pool_monitor&.stop
266
269
  end
267
270
 
268
271
  private
@@ -403,7 +406,7 @@ module AtomicRuby
403
406
  pressure_started_at = now
404
407
  phase_samples.fill(0)
405
408
  elsif now - pressure_started_at >= AUTOSCALE_WINDOW
406
- if should_grow?(previous_snapshot, snapshot, phase_samples)
409
+ if should_grow?(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
407
410
  growth_samples += 1
408
411
  if growth_samples >= AUTOSCALE_GROWTH_SAMPLES
409
412
  spawn_worker(temporary: true)
@@ -427,25 +430,28 @@ module AtomicRuby
427
430
  # Returns whether another worker is likely to improve throughput.
428
431
  #
429
432
  # 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.
433
+ # GVL recognizes blocking operations. The pool only grows while its workers
434
+ # use less than half of one CPU, avoiding extra threads when Ruby
435
+ # execution is the bottleneck without mistaking OS scheduling delays for
436
+ # GVL contention.
432
437
  #
433
438
  # @param previous_snapshot [Array<Integer>] previous GVL state snapshot
434
439
  # @param snapshot [Array<Integer>] current GVL state snapshot
435
440
  # @param phase_samples [Array<Integer>] sampled current GVL states
441
+ # @param elapsed_time [Float] seconds covered by the snapshots
436
442
  # @return [true, false]
437
443
  #
438
- # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples) -> bool
439
- def should_grow?(previous_snapshot, snapshot, phase_samples)
444
+ # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples, Float elapsed_time) -> bool
445
+ def should_grow?(previous_snapshot, snapshot, phase_samples, elapsed_time)
440
446
  @threads.select!(&:alive?)
441
447
  workers = @threads
442
448
  return false if workers.length >= @max_size
443
449
  return false if @active_thread_count.value < workers.length
444
450
 
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]
451
+ running_time = snapshot[3] - previous_snapshot[3]
452
+ waiting_time = snapshot[4] - previous_snapshot[4]
453
+ blocked_time = snapshot[5] - previous_snapshot[5]
454
+ running_cpu_time = snapshot[6] - previous_snapshot[6]
449
455
  total_time = running_time + waiting_time + blocked_time
450
456
 
451
457
  minimum_measured_time = (AUTOSCALE_WINDOW * 1_000_000_000 * workers.length / 2).to_i
@@ -455,8 +461,8 @@ module AtomicRuby
455
461
  end
456
462
 
457
463
  total_time.positive? &&
458
- waiting_time * 50 < total_time &&
459
- blocked_time * 2 > total_time
464
+ blocked_time * 2 > total_time &&
465
+ running_cpu_time * 2 < elapsed_time * 1_000_000_000
460
466
  end
461
467
  end
462
468
  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.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young