atomic-ruby 0.14.1 → 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: 1310b5db37f97fe6e8a8e9da89083554f2bc5cd4500027e853e268d99db15cc6
4
- data.tar.gz: 94039f36f8c248fc2bbc60529deff3c72d933d6c6e638d6f70136f64ad798825
3
+ metadata.gz: af7a35affcaa2790e6944ee01eb6af8f4d92b5c2479bb1995128742a562fd395
4
+ data.tar.gz: d3908277e6ef09a70f7d46a057a5db7f60c179885d980b4fbbf407bdade6b073
5
5
  SHA512:
6
- metadata.gz: 9ad6f1cefcf6a6f39635baa7e2d384e6d6a4e273927c9a442bcb4a63036172dcc1c222fb4fd02d61878205db8163f91f70b4a8a753c48c2de760bc74950987f7
7
- data.tar.gz: 448588150ab485b7a1cb6f7dfe892feba5b15c7b32d6d766570ef4097ec48a3ee0a7fbdc20fa92e9875d7701ee42e31660df9c759d24243f250a7987357d870b
6
+ metadata.gz: f5a3525b194636ddb439c0c8e9597e3ed1870a8c0165bc1582cb120a45c49b30a8cf6bd60f393a9e3e0c848abf760b181dce1b9b3026199ff8ffb2ba833dcc1a
7
+ data.tar.gz: e89a1f593774fc1722bfc8a9229385682e33a10df705ba78da3c67f904ab566c770b7aebdebc32069ed7f4dfc2781ad52165f08f10cf3fd4e4d723c41687e02f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
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
+
7
+ ## [0.15.0] - 2026-08-29
8
+
9
+ - Add adaptive scaling to `AtomicThreadPool` with `max_size:`
10
+
3
11
  ## [0.14.1] - 2026-08-20
4
12
 
5
13
  - Fix O(n) in `AtomicThreadPool#length` by tracking the alive count
data/README.md CHANGED
@@ -120,6 +120,18 @@ p results #=> [8, 7, 10, 9, 6, 5, 3, 4, 2, 1]
120
120
  p results.sort #=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
121
121
  ```
122
122
 
123
+ Pass `max_size` to let the pool temporarily add workers when queued work is
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.
127
+
128
+ ```ruby
129
+ pool = AtomicThreadPool.new(size: 4, max_size: 16)
130
+
131
+ # Grow without a limit
132
+ pool = AtomicThreadPool.new(size: 4, max_size: Float::INFINITY)
133
+ ```
134
+
123
135
  `AtomicCountDownLatch`:
124
136
 
125
137
  ```ruby
@@ -256,7 +268,7 @@ puts "Atomic Ruby Atomic Bank Account: #{results[2].real.round(6)} seconds"
256
268
 
257
269
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
258
270
  concurrent-ruby version: 1.3.6
259
- atomic-ruby version: 0.14.1
271
+ atomic-ruby version: 0.15.1
260
272
 
261
273
  Balances:
262
274
  Synchronized Bank Account Balance: 975
@@ -347,7 +359,7 @@ end
347
359
 
348
360
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
349
361
  concurrent-ruby version: 1.3.6
350
- atomic-ruby version: 0.14.1
362
+ atomic-ruby version: 0.15.1
351
363
 
352
364
  Warming up --------------------------------------
353
365
  Synchronized Boolean Toggle 157.000 i/100ms
@@ -430,7 +442,7 @@ end
430
442
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_condition_variable_benchmark.rb
431
443
 
432
444
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
433
- atomic-ruby version: 0.14.1
445
+ atomic-ruby version: 0.15.1
434
446
 
435
447
  Warming up --------------------------------------
436
448
  Synchronized Condition Variable Wait/Signal 3.977k i/100ms
@@ -505,7 +517,7 @@ end
505
517
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_queue_benchmark.rb
506
518
 
507
519
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
508
- atomic-ruby version: 0.14.1
520
+ atomic-ruby version: 0.15.1
509
521
 
510
522
  Warming up --------------------------------------
511
523
  Synchronized Queue Push/Pop 181.000 i/100ms
@@ -575,11 +587,11 @@ puts "Atomic Ruby Atomic Thread Pool: #{results[1].real.round(6)} seconds"
575
587
 
576
588
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
577
589
  concurrent-ruby version: 1.3.8
578
- atomic-ruby version: 0.14.1
590
+ atomic-ruby version: 0.15.1
579
591
 
580
592
  Benchmark Results:
581
- Concurrent Ruby Thread Pool: 5.046895 seconds
582
- Atomic Ruby Atomic Thread Pool: 4.717221 seconds
593
+ Concurrent Ruby Thread Pool: 5.079561 seconds
594
+ Atomic Ruby Atomic Thread Pool: 4.99168 seconds
583
595
  ```
584
596
 
585
597
  </details>
@@ -521,6 +521,197 @@ static VALUE rb_cAtomicConditionVariable_waiter_count(VALUE self) {
521
521
  return UINT2NUM((unsigned int)RUBY_ATOMIC_LOAD(atomic_ruby_condition_variable->count));
522
522
  }
523
523
 
524
+ typedef enum {
525
+ ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE,
526
+ ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING,
527
+ ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING,
528
+ ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED
529
+ } atomic_ruby_thread_pool_worker_phase_t;
530
+
531
+ typedef struct {
532
+ _Atomic unsigned int running_count;
533
+ _Atomic unsigned int waiting_count;
534
+ _Atomic unsigned int blocked_count;
535
+ _Atomic unsigned long long running_time;
536
+ _Atomic unsigned long long waiting_time;
537
+ _Atomic unsigned long long blocked_time;
538
+ _Atomic unsigned long long running_cpu_time;
539
+ } atomic_ruby_thread_pool_monitor_t;
540
+
541
+ typedef struct {
542
+ atomic_ruby_thread_pool_monitor_t *monitor;
543
+ atomic_ruby_thread_pool_worker_phase_t phase;
544
+ unsigned long long phase_started_at;
545
+ unsigned long long running_cpu_started_at;
546
+ } atomic_ruby_thread_pool_worker_state_t;
547
+
548
+ static rb_internal_thread_specific_key_t atomic_ruby_thread_pool_worker_key;
549
+
550
+ static void atomic_ruby_thread_pool_monitor_free(void *ptr) {
551
+ xfree(ptr);
552
+ }
553
+
554
+ static size_t atomic_ruby_thread_pool_monitor_memsize(const void *ptr) {
555
+ return sizeof(atomic_ruby_thread_pool_monitor_t);
556
+ }
557
+
558
+ static const rb_data_type_t atomic_ruby_thread_pool_monitor_type = {
559
+ .wrap_struct_name = "AtomicRuby::ThreadPoolMonitor",
560
+ .function = {
561
+ .dfree = atomic_ruby_thread_pool_monitor_free,
562
+ .dsize = atomic_ruby_thread_pool_monitor_memsize
563
+ },
564
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED
565
+ };
566
+
567
+ static unsigned long long atomic_ruby_monotonic_time(void) {
568
+ struct timespec time;
569
+ if (clock_gettime(CLOCK_MONOTONIC, &time) == -1) return 0;
570
+
571
+ return (unsigned long long)time.tv_sec * 1000000000ULL + (unsigned long long)time.tv_nsec;
572
+ }
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
+
581
+ static void atomic_ruby_thread_pool_worker_leave_phase(atomic_ruby_thread_pool_worker_state_t *state, unsigned long long now) {
582
+ unsigned long long elapsed = now != 0 && state->phase_started_at != 0 && now >= state->phase_started_at ? now - state->phase_started_at : 0;
583
+
584
+ switch (state->phase) {
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;
590
+ atomic_fetch_sub_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
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);
593
+ break;
594
+ }
595
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
596
+ atomic_fetch_sub_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
597
+ atomic_fetch_add_explicit(&state->monitor->waiting_time, elapsed, memory_order_relaxed);
598
+ break;
599
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED:
600
+ atomic_fetch_sub_explicit(&state->monitor->blocked_count, 1, memory_order_relaxed);
601
+ atomic_fetch_add_explicit(&state->monitor->blocked_time, elapsed, memory_order_relaxed);
602
+ break;
603
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE:
604
+ break;
605
+ }
606
+ }
607
+
608
+ static void atomic_ruby_thread_pool_worker_enter_phase(atomic_ruby_thread_pool_worker_state_t *state, atomic_ruby_thread_pool_worker_phase_t phase, unsigned long long now) {
609
+ state->phase = phase;
610
+ state->phase_started_at = now;
611
+
612
+ switch (phase) {
613
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING:
614
+ atomic_fetch_add_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
615
+ state->running_cpu_started_at = atomic_ruby_thread_cpu_time();
616
+ break;
617
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
618
+ atomic_fetch_add_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
619
+ break;
620
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED:
621
+ atomic_fetch_add_explicit(&state->monitor->blocked_count, 1, memory_order_relaxed);
622
+ break;
623
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE:
624
+ break;
625
+ }
626
+ }
627
+
628
+ 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) {
629
+ atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(event_data->thread, atomic_ruby_thread_pool_worker_key);
630
+ if (state == NULL || state->phase == ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE) return;
631
+
632
+ unsigned long long now = atomic_ruby_monotonic_time();
633
+ atomic_ruby_thread_pool_worker_leave_phase(state, now);
634
+
635
+ switch (event) {
636
+ case RUBY_INTERNAL_THREAD_EVENT_READY:
637
+ atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING, now);
638
+ break;
639
+ case RUBY_INTERNAL_THREAD_EVENT_RESUMED:
640
+ atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING, now);
641
+ break;
642
+ case RUBY_INTERNAL_THREAD_EVENT_SUSPENDED:
643
+ atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED, now);
644
+ break;
645
+ }
646
+ }
647
+
648
+ static VALUE rb_cThreadPoolMonitor_allocate(VALUE klass) {
649
+ atomic_ruby_thread_pool_monitor_t *monitor;
650
+ VALUE obj = TypedData_Make_Struct(klass, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
651
+ atomic_init(&monitor->running_count, 0);
652
+ atomic_init(&monitor->waiting_count, 0);
653
+ atomic_init(&monitor->blocked_count, 0);
654
+ atomic_init(&monitor->running_time, 0);
655
+ atomic_init(&monitor->waiting_time, 0);
656
+ atomic_init(&monitor->blocked_time, 0);
657
+ atomic_init(&monitor->running_cpu_time, 0);
658
+ return obj;
659
+ }
660
+
661
+ static VALUE rb_cThreadPoolMonitor_register_worker(VALUE self) {
662
+ atomic_ruby_thread_pool_monitor_t *monitor;
663
+ TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
664
+
665
+ VALUE thread = rb_thread_current();
666
+ atomic_ruby_thread_pool_worker_state_t *state = ALLOC(atomic_ruby_thread_pool_worker_state_t);
667
+ state->monitor = monitor;
668
+ state->phase = ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE;
669
+ state->phase_started_at = 0;
670
+ state->running_cpu_started_at = 0;
671
+ rb_internal_thread_specific_set(thread, atomic_ruby_thread_pool_worker_key, state);
672
+ return Qnil;
673
+ }
674
+
675
+ static VALUE rb_cThreadPoolMonitor_unregister_worker(VALUE self) {
676
+ VALUE thread = rb_thread_current();
677
+ atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(thread, atomic_ruby_thread_pool_worker_key);
678
+ if (state == NULL) return Qnil;
679
+
680
+ atomic_ruby_thread_pool_worker_leave_phase(state, atomic_ruby_monotonic_time());
681
+ rb_internal_thread_specific_set(thread, atomic_ruby_thread_pool_worker_key, NULL);
682
+ xfree(state);
683
+ return Qnil;
684
+ }
685
+
686
+ static VALUE rb_cThreadPoolMonitor_start_work(VALUE self) {
687
+ atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(rb_thread_current(), atomic_ruby_thread_pool_worker_key);
688
+ atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING, atomic_ruby_monotonic_time());
689
+ return Qnil;
690
+ }
691
+
692
+ static VALUE rb_cThreadPoolMonitor_stop_work(VALUE self) {
693
+ atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(rb_thread_current(), atomic_ruby_thread_pool_worker_key);
694
+ atomic_ruby_thread_pool_worker_leave_phase(state, atomic_ruby_monotonic_time());
695
+ state->phase = ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE;
696
+ return Qnil;
697
+ }
698
+
699
+ static VALUE rb_cThreadPoolMonitor_snapshot(VALUE self) {
700
+ atomic_ruby_thread_pool_monitor_t *monitor;
701
+ TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
702
+
703
+ return rb_ary_new_from_args(
704
+ 7,
705
+ UINT2NUM(atomic_load_explicit(&monitor->running_count, memory_order_relaxed)),
706
+ UINT2NUM(atomic_load_explicit(&monitor->waiting_count, memory_order_relaxed)),
707
+ UINT2NUM(atomic_load_explicit(&monitor->blocked_count, memory_order_relaxed)),
708
+ ULL2NUM(atomic_load_explicit(&monitor->running_time, memory_order_relaxed)),
709
+ ULL2NUM(atomic_load_explicit(&monitor->waiting_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))
712
+ );
713
+ }
714
+
524
715
  RUBY_FUNC_EXPORTED void Init_atomic_ruby(void) {
525
716
  #ifdef ATOMIC_RUBY_RACTOR_SAFE
526
717
  rb_ext_ractor_safe(true);
@@ -563,4 +754,21 @@ RUBY_FUNC_EXPORTED void Init_atomic_ruby(void) {
563
754
  rb_define_private_method(rb_cAtomicConditionVariable, "_shift_thread", rb_cAtomicConditionVariable_shift_thread, 0);
564
755
  rb_define_private_method(rb_cAtomicConditionVariable, "_drain_threads", rb_cAtomicConditionVariable_drain_threads, 0);
565
756
  rb_define_private_method(rb_cAtomicConditionVariable, "_waiter_count", rb_cAtomicConditionVariable_waiter_count, 0);
757
+
758
+ atomic_ruby_thread_pool_worker_key = rb_internal_thread_specific_key_create();
759
+ rb_internal_thread_add_event_hook(
760
+ atomic_ruby_thread_pool_event_callback,
761
+ RUBY_INTERNAL_THREAD_EVENT_READY |
762
+ RUBY_INTERNAL_THREAD_EVENT_RESUMED |
763
+ RUBY_INTERNAL_THREAD_EVENT_SUSPENDED,
764
+ NULL
765
+ );
766
+ VALUE rb_cThreadPoolMonitor = rb_define_class_under(rb_mAtomicRuby, "ThreadPoolMonitor", rb_cObject);
767
+ rb_define_alloc_func(rb_cThreadPoolMonitor, rb_cThreadPoolMonitor_allocate);
768
+ rb_define_method(rb_cThreadPoolMonitor, "register_worker", rb_cThreadPoolMonitor_register_worker, 0);
769
+ rb_define_method(rb_cThreadPoolMonitor, "unregister_worker", rb_cThreadPoolMonitor_unregister_worker, 0);
770
+ rb_define_method(rb_cThreadPoolMonitor, "start_work", rb_cThreadPoolMonitor_start_work, 0);
771
+ rb_define_method(rb_cThreadPoolMonitor, "stop_work", rb_cThreadPoolMonitor_stop_work, 0);
772
+ rb_define_method(rb_cThreadPoolMonitor, "snapshot", rb_cThreadPoolMonitor_snapshot, 0);
773
+ rb_funcall(rb_mAtomicRuby, rb_intern("private_constant"), 1, ID2SYM(rb_intern("ThreadPoolMonitor")));
566
774
  }
@@ -3,8 +3,12 @@
3
3
 
4
4
  #include "ruby.h"
5
5
  #include "ruby/atomic.h"
6
+ #include "ruby/thread.h"
6
7
  #include "ruby/version.h"
7
8
 
9
+ #include <stdatomic.h>
10
+ #include <time.h>
11
+
8
12
  #if RUBY_API_VERSION_CODE >= 40000
9
13
  #define ATOMIC_RUBY_RACTOR_SAFE 1
10
14
  #include "ruby/ractor.h"
@@ -7,11 +7,13 @@ require_relative "atomic_condition_variable"
7
7
  require_relative "atomic_queue"
8
8
 
9
9
  module AtomicRuby
10
- # Provides a fixed-size thread pool using atomic operations for work queuing.
10
+ # Provides a thread pool using atomic operations for work queuing.
11
11
  #
12
- # AtomicThreadPool maintains a fixed number of worker threads that process
13
- # work items from an {AtomicQueue}. Both enqueueing and dequeueing are O(1)
14
- # and lock-free, so concurrent producers and consumers never block one
12
+ # AtomicThreadPool maintains a baseline number of worker threads that process
13
+ # work items from an {AtomicQueue}. When `max_size` is provided, it can
14
+ # temporarily add workers when work remains queued and its workers spend most
15
+ # of their time blocked outside the GVL. Both enqueueing and dequeueing are
16
+ # O(1) and lock-free, so concurrent producers and consumers never block one
15
17
  # another.
16
18
  #
17
19
  # @example Basic usage
@@ -31,6 +33,11 @@ module AtomicRuby
31
33
  # pool.shutdown
32
34
  # puts results.sort #=> [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
33
35
  #
36
+ # @example Scaling for blocking work
37
+ # pool = AtomicThreadPool.new(size: 2, max_size: 8)
38
+ # 20.times { pool << proc { Net::HTTP.get(uri) } }
39
+ # pool.shutdown
40
+ #
34
41
  # @example Monitoring pool state
35
42
  # pool = AtomicThreadPool.new(size: 3)
36
43
  # puts pool.length #=> 3
@@ -44,6 +51,12 @@ module AtomicRuby
44
51
  # @note This class is NOT Ractor-safe as it contains mutable thread state
45
52
  # that cannot be safely shared across ractors.
46
53
  class AtomicThreadPool
54
+ AUTOSCALE_GROWTH_SAMPLES = 3
55
+ AUTOSCALE_IDLE_TIME = 1
56
+ AUTOSCALE_INTERVAL = 0.01
57
+ AUTOSCALE_WINDOW = 0.05
58
+ private_constant :AUTOSCALE_GROWTH_SAMPLES, :AUTOSCALE_IDLE_TIME, :AUTOSCALE_INTERVAL, :AUTOSCALE_WINDOW
59
+
47
60
  class Error < StandardError; end
48
61
 
49
62
  # Error raised when attempting to enqueue work after shutdown.
@@ -52,15 +65,26 @@ module AtomicRuby
52
65
  def message = "cannot queue work after shutdown"
53
66
  end
54
67
 
55
- # Creates a new thread pool with the specified size.
56
- #
57
- # @param size [Integer] The number of worker threads to create (must be positive)
68
+ # Creates a new thread pool with the specified baseline size.
69
+ #
70
+ # When `max_size` is greater than `size`, the pool adds temporary workers
71
+ # if work remains queued while its active workers spend most of their time
72
+ # blocked outside the GVL, but not when Ruby execution is using the
73
+ # available CPU.
74
+ # Temporary workers leave after the queue remains empty, returning the pool
75
+ # to its baseline size. Omitting `max_size` creates a fixed-size pool.
76
+ #
77
+ # @param size [Integer] The baseline number of worker threads (must be positive)
78
+ # @param max_size [Integer, Float, nil] Maximum number of worker threads,
79
+ # `Float::INFINITY` for no limit, or nil for a fixed-size pool
58
80
  # @param name [String, nil] Optional name for the thread pool (used in thread names)
59
81
  # @param on_error [Proc, nil] Optional error handler called with the exception when
60
82
  # a work item raises. Receives the exception as its argument. When nil, errors
61
83
  # are printed to stderr
62
84
  #
63
85
  # @raise [ArgumentError] if size is not a positive integer
86
+ # @raise [ArgumentError] if max_size is not an integer greater than or equal
87
+ # to size or `Float::INFINITY`
64
88
  # @raise [ArgumentError] if name is provided but not a string
65
89
  # @raise [ArgumentError] if on_error is provided but not a Proc
66
90
  #
@@ -70,17 +94,23 @@ module AtomicRuby
70
94
  # @example Create a named pool
71
95
  # pool = AtomicThreadPool.new(size: 2, name: "Database Workers")
72
96
  #
97
+ # @example Create an adaptive pool
98
+ # pool = AtomicThreadPool.new(size: 2, max_size: 8)
99
+ #
73
100
  # @example Create a pool with a custom error handler
74
101
  # errors = []
75
102
  # pool = AtomicThreadPool.new(size: 2, on_error: ->(err) { errors << err })
76
103
  #
77
- # @rbs (size: Integer, ?name: String?, ?on_error: Proc?) -> void
78
- def initialize(size:, name: nil, on_error: nil)
104
+ # @rbs (size: Integer, ?max_size: (Integer | Float)?, ?name: String?, ?on_error: Proc?) -> void
105
+ def initialize(size:, max_size: nil, name: nil, on_error: nil)
79
106
  raise ArgumentError, "size must be a positive Integer" unless size.is_a?(Integer) && size > 0
107
+ valid_max_size = max_size.nil? || max_size == Float::INFINITY || (max_size.is_a?(Integer) && max_size >= size)
108
+ raise ArgumentError, "max_size must be an Integer greater than or equal to size or Float::INFINITY" unless valid_max_size
80
109
  raise ArgumentError, "name must be a String" unless name.nil? || name.is_a?(String)
81
110
  raise ArgumentError, "on_error must be a Proc" unless on_error.nil? || on_error.is_a?(Proc)
82
111
 
83
112
  @size = size
113
+ @max_size = max_size || size
84
114
  @name = name
85
115
  @on_error = on_error
86
116
 
@@ -90,6 +120,12 @@ module AtomicRuby
90
120
  @alive_thread_count = Atom.new(0)
91
121
  @active_thread_count = Atom.new(0)
92
122
  @threads = []
123
+ @next_thread_number = 0
124
+ if adaptive?
125
+ @autoscale_available = AtomicConditionVariable.new
126
+ @trim_requested = AtomicBoolean.new(false)
127
+ @thread_pool_monitor = ThreadPoolMonitor.new
128
+ end
93
129
 
94
130
  start
95
131
  end
@@ -120,15 +156,17 @@ module AtomicRuby
120
156
  def <<(work)
121
157
  raise EnqueuedWorkAfterShutdownError if @shutdown.true?
122
158
 
159
+ @trim_requested&.make_false
123
160
  @queue.push(work)
124
161
  @work_available.signal
162
+ @autoscale_available&.signal
125
163
  end
126
164
 
127
165
  # Returns the number of currently alive worker threads.
128
166
  #
129
167
  # This count decreases as the pool shuts down and threads terminate.
130
- # During normal operation, this should equal the size parameter
131
- # passed to the constructor.
168
+ # An adaptive pool may report a value between the `size` and `max_size`
169
+ # parameters passed to the constructor.
132
170
  #
133
171
  # @return [Integer] The number of alive worker threads
134
172
  #
@@ -222,7 +260,9 @@ module AtomicRuby
222
260
  return if @shutdown.true?
223
261
 
224
262
  @shutdown.make_true
263
+ @autoscale_available&.broadcast
225
264
  @work_available.broadcast
265
+ @autoscaler&.join
226
266
  @threads.each(&:join)
227
267
  end
228
268
 
@@ -238,49 +278,189 @@ module AtomicRuby
238
278
  #
239
279
  # @rbs () -> void
240
280
  def start
241
- @size.times do |num|
242
- @threads << Thread.new(num) do |idx|
243
- thread_name = String.new("AtomicThreadPool thread #{idx}")
281
+ @size.times { spawn_worker }
282
+
283
+ if adaptive?
284
+ @autoscaler = Thread.new do
285
+ thread_name = String.new("AtomicThreadPool autoscaler")
244
286
  thread_name << " for #{@name}" if @name
245
287
  Thread.current.name = thread_name
288
+ autoscale
289
+ end
290
+ end
246
291
 
247
- @alive_thread_count.swap { |current_count| current_count + 1 }
292
+ Thread.pass until @alive_thread_count.value == @size
293
+ end
248
294
 
249
- begin
250
- loop do
251
- work = nil
252
- should_shutdown = false
295
+ # Returns whether the pool may grow beyond its baseline size.
296
+ #
297
+ # @return [true, false]
298
+ #
299
+ # @rbs () -> bool
300
+ def adaptive?
301
+ @max_size > @size
302
+ end
303
+
304
+ # Creates a worker thread.
305
+ #
306
+ # Temporary workers leave after the queue remains empty.
307
+ #
308
+ # @param temporary [true, false] whether the worker belongs above the baseline
309
+ # @return [Thread]
310
+ #
311
+ # @rbs (?temporary: bool) -> Thread
312
+ def spawn_worker(temporary: false)
313
+ thread_number = @next_thread_number
314
+ @next_thread_number += 1
315
+
316
+ thread = Thread.new(thread_number) do |idx|
317
+ thread_name = String.new("AtomicThreadPool thread #{idx}")
318
+ thread_name << " for #{@name}" if @name
319
+ Thread.current.name = thread_name
253
320
 
254
- @work_available.wait do
255
- work = @queue.pop
256
- should_shutdown = @shutdown.true? && @queue.empty? unless work
257
- work || should_shutdown
321
+ @thread_pool_monitor&.register_worker
322
+ @alive_thread_count.swap { |current_count| current_count + 1 }
323
+
324
+ begin
325
+ loop do
326
+ work = nil
327
+ should_exit = false
328
+
329
+ @work_available.wait do
330
+ work = @queue.pop
331
+ unless work
332
+ should_exit = @shutdown.true? && @queue.empty?
333
+ should_exit ||= temporary && @trim_requested.true? && @queue.empty?
258
334
  end
335
+ work || should_exit
336
+ end
259
337
 
260
- break if should_shutdown
261
-
262
- @active_thread_count.swap { |current_count| current_count + 1 }
263
- begin
264
- work.call
265
- rescue => err
266
- if @on_error
267
- @on_error.call(err)
268
- else
269
- warn "#{thread_name} rescued:"
270
- warn err.full_message
271
- end
272
- ensure
273
- @active_thread_count.swap { |current_count| current_count - 1 }
338
+ break if should_exit
339
+
340
+ @active_thread_count.swap { |current_count| current_count + 1 }
341
+ begin
342
+ @thread_pool_monitor&.start_work
343
+ work.call
344
+ rescue => err
345
+ if @on_error
346
+ @on_error.call(err)
347
+ else
348
+ warn "#{thread_name} rescued:"
349
+ warn err.full_message
274
350
  end
351
+ ensure
352
+ @thread_pool_monitor&.stop_work
353
+ @active_thread_count.swap { |current_count| current_count - 1 }
275
354
  end
276
- ensure
277
- @alive_thread_count.swap { |current_count| current_count - 1 }
278
355
  end
356
+ ensure
357
+ @alive_thread_count.swap { |current_count| current_count - 1 }
358
+ @thread_pool_monitor&.unregister_worker
279
359
  end
280
360
  end
281
- @threads.freeze
282
361
 
283
- Thread.pass until @alive_thread_count.value == @size
362
+ @threads << thread
363
+ thread
364
+ end
365
+
366
+ # Adds temporary workers while queued work is held up by blocked workers.
367
+ #
368
+ # @return [void]
369
+ #
370
+ # @rbs () -> void
371
+ def autoscale
372
+ previous_snapshot = @thread_pool_monitor.snapshot
373
+ pressure_started_at = nil
374
+ idle_started_at = nil
375
+ growth_samples = 0
376
+ phase_samples = [0, 0, 0]
377
+
378
+ loop do
379
+ @autoscale_available.wait do
380
+ @shutdown.true? || !@queue.empty? || @alive_thread_count.value > @size
381
+ end
382
+ break if @shutdown.true?
383
+
384
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
385
+ snapshot = @thread_pool_monitor.snapshot
386
+
387
+ if @queue.empty?
388
+ pressure_started_at = nil
389
+ previous_snapshot = snapshot
390
+ idle_started_at ||= now
391
+ growth_samples = 0
392
+ phase_samples.fill(0)
393
+
394
+ if now - idle_started_at >= AUTOSCALE_IDLE_TIME
395
+ @trim_requested.make_true
396
+ @work_available.broadcast
397
+ end
398
+ else
399
+ @trim_requested.make_false
400
+ idle_started_at = nil
401
+
402
+ if pressure_started_at.nil?
403
+ previous_snapshot = snapshot
404
+ pressure_started_at = now
405
+ phase_samples.fill(0)
406
+ elsif now - pressure_started_at >= AUTOSCALE_WINDOW
407
+ if should_grow?(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
408
+ growth_samples += 1
409
+ if growth_samples >= AUTOSCALE_GROWTH_SAMPLES
410
+ spawn_worker(temporary: true)
411
+ growth_samples = 0
412
+ end
413
+ else
414
+ growth_samples = 0
415
+ end
416
+ previous_snapshot = snapshot
417
+ pressure_started_at = now
418
+ phase_samples.fill(0)
419
+ else
420
+ 3.times { |index| phase_samples[index] += snapshot[index] }
421
+ end
422
+ end
423
+
424
+ sleep AUTOSCALE_INTERVAL
425
+ end
426
+ end
427
+
428
+ # Returns whether another worker is likely to improve throughput.
429
+ #
430
+ # 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.
435
+ #
436
+ # @param previous_snapshot [Array<Integer>] previous GVL state snapshot
437
+ # @param snapshot [Array<Integer>] current GVL state snapshot
438
+ # @param phase_samples [Array<Integer>] sampled current GVL states
439
+ # @param elapsed_time [Float] seconds covered by the snapshots
440
+ # @return [true, false]
441
+ #
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)
444
+ @threads.select!(&:alive?)
445
+ workers = @threads
446
+ return false if workers.length >= @max_size
447
+ return false if @active_thread_count.value < workers.length
448
+
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]
453
+ total_time = running_time + waiting_time + blocked_time
454
+
455
+ minimum_measured_time = (AUTOSCALE_WINDOW * 1_000_000_000 * workers.length / 2).to_i
456
+ if total_time < minimum_measured_time
457
+ running_time, waiting_time, blocked_time = phase_samples
458
+ total_time = running_time + waiting_time + blocked_time
459
+ end
460
+
461
+ total_time.positive? &&
462
+ blocked_time * 2 > total_time &&
463
+ running_cpu_time * 2 < elapsed_time * 1_000_000_000
284
464
  end
285
465
  end
286
466
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicRuby
4
- VERSION = "0.14.1"
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.14.1
4
+ version: 0.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young